From c453e488ada0366acb0a59c01ab3c488bc0e6727 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 5 Aug 2025 18:19:42 -0700 Subject: [PATCH 01/44] Add a detail screen --- samples/bottom-navigation/build.gradle.kts | 1 + .../navigation/AdaptiveNavDecoration.kt | 9 ++ .../sample/navigation/ContentCircuit.kt | 2 + .../circuit/sample/navigation/DetailScreen.kt | 141 ++++++++++++++++++ .../circuit/sample/navigation/TabScreen.kt | 43 ++++-- 5 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt create mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt diff --git a/samples/bottom-navigation/build.gradle.kts b/samples/bottom-navigation/build.gradle.kts index 0cfe96e487..25def08de8 100644 --- a/samples/bottom-navigation/build.gradle.kts +++ b/samples/bottom-navigation/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.compose) alias(libs.plugins.agp.application) alias(libs.plugins.kotlin.plugin.parcelize) + alias(libs.plugins.compose.hotReload) } android { diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt new file mode 100644 index 0000000000..ace0c7c391 --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt @@ -0,0 +1,9 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sample.navigation + +import com.slack.circuit.runtime.screen.Screen + +interface PrimaryScreen : Screen + +interface SecondaryScreen : Screen diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt index 7493083d70..92d63fa468 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt @@ -11,6 +11,8 @@ fun buildCircuitForTabs(tabs: Collection): Circuit { addPresenterFactory(TabPresenter.Factory(tab::class)) addUiFactory(TabUiFactory(tab::class)) } + addPresenterFactory(DetailPresenter.Factory) + addUiFactory(DetailUiFactory) } .setAnimatedNavDecoratorFactory(CrossFadeNavDecoratorFactory()) .build() diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt new file mode 100644 index 0000000000..db1507548a --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt @@ -0,0 +1,141 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sample.navigation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.slack.circuit.internal.runtime.Parcelize +import com.slack.circuit.runtime.CircuitContext +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.runtime.ui.Ui +import com.slack.circuit.runtime.ui.ui + +@Parcelize data class DetailScreen(val primary: TabScreen) : SecondaryScreen + +data class DetailState( + val label: String, + val description: String, + val eventSink: (DetailEvent) -> Unit, +) : CircuitUiState + +sealed interface DetailEvent : CircuitUiEvent { + data object Close : DetailEvent +} + +@Composable +fun DetailUi(state: DetailState, modifier: Modifier = Modifier) { + Scaffold(modifier = modifier) { padding -> + Card(modifier = Modifier.padding(padding).padding(8.dp).fillMaxSize()) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp).weight(1f), + ) + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier.padding(top = 8.dp) + .clickable( + interactionSource = null, + indication = ripple(bounded = false, radius = 24.dp), + ) { + state.eventSink(DetailEvent.Close) + } + .padding(8.dp), + ) + } + Card( + modifier = Modifier.fillMaxSize().padding(8.dp), + colors = CardDefaults.outlinedCardColors(), + ) { + Text( + text = state.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + .verticalScroll(rememberScrollState()), + ) + } + } + } +} + +class DetailPresenter(private val screen: DetailScreen, private val navigator: Navigator) : + Presenter { + @Composable + override fun present(): DetailState { + return DetailState(label = "Details for ${screen.primary.label}", description = LOREM_IPSUM) { + event -> + when (event) { + DetailEvent.Close -> navigator.pop() + } + } + } + + object Factory : Presenter.Factory { + override fun create( + screen: Screen, + navigator: Navigator, + context: CircuitContext, + ): Presenter<*>? { + return if (screen is DetailScreen) { + DetailPresenter(screen, navigator) + } else { + null + } + } + } +} + +object DetailUiFactory : Ui.Factory { + override fun create(screen: Screen, context: CircuitContext): Ui<*>? { + return if (screen is DetailScreen) { + ui { state, modifier -> DetailUi(state, modifier) } + } else { + null + } + } +} + +private val LOREM_IPSUM = + """ + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tincidunt viverra ex, vel iaculis tellus lacinia lobortis. Nullam nisl nisi, semper et venenatis sed, volutpat porta ipsum. Pellentesque molestie sapien ac convallis bibendum. Quisque laoreet sollicitudin dapibus. Nunc ut ultrices eros. Phasellus ornare scelerisque diam, vitae scelerisque justo venenatis ut. Maecenas gravida fringilla lectus sed lobortis. Proin eu lectus scelerisque, egestas sem nec, posuere risus. + +Sed sit amet vestibulum erat. Donec congue ullamcorper accumsan. Vestibulum nec neque a mi interdum consectetur. Curabitur dictum leo sit amet elit tristique, non sollicitudin mi molestie. Duis euismod nisi a ultricies congue. Aliquam massa est, pellentesque sed convallis ac, sodales quis leo. Fusce sodales velit quis nunc tincidunt molestie. + +Morbi tincidunt aliquet velit, at feugiat sem dictum non. Phasellus cursus rhoncus lorem. Nunc quis neque mi. Praesent luctus, diam nec luctus efficitur, nibh risus tempus elit, ac blandit quam massa in justo. Nunc tincidunt cursus dui. Cras euismod purus nisi. Duis ac orci sapien. Nam urna neque, aliquet et nibh scelerisque, rutrum consectetur dui. Nunc nec gravida nisl, eu euismod felis. Aliquam faucibus leo vitae erat posuere, non fermentum sem lacinia. Ut lobortis, velit quis malesuada molestie, est justo pharetra neque, at venenatis mi lacus quis nisi. Ut dictum sollicitudin odio quis placerat. + +Donec ac sapien vel lectus pretium fringilla. Donec tempor suscipit sapien eu venenatis. Etiam aliquam a nibh maximus suscipit. Quisque facilisis metus in vulputate pharetra. Fusce dictum condimentum quam mattis ullamcorper. Sed accumsan est sit amet tortor porta, et pulvinar diam semper. Aenean congue enim eget pulvinar mollis. Mauris sed auctor sem. Suspendisse potenti. Etiam lacinia tempus purus vel imperdiet. Integer ante massa, mollis vitae eleifend id, egestas vitae nulla. Quisque urna nisi, sagittis at elementum finibus, venenatis nec risus. Sed suscipit diam nulla. Cras vel lectus ligula. + +""" + .trim() diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index 5d44717583..a93daa90b5 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -5,14 +5,18 @@ package com.slack.circuit.sample.navigation import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp @@ -37,7 +41,7 @@ sealed interface TabScreen : Screen { @Parcelize data class Root(override val label: String = "Root") : TabScreen - @Parcelize data class Screen1(override val label: String = "Screen 1") : TabScreen + @Parcelize data class Screen1(override val label: String = "Screen 1") : TabScreen, PrimaryScreen @Parcelize data class Screen2(override val label: String = "Screen 2") : TabScreen @@ -63,10 +67,13 @@ sealed interface TabScreen : Screen { object TabScreenCircuit { - data class State(val label: String, val eventSink: (Event) -> Unit) : CircuitUiState + data class State(val label: String, val hasDetails: Boolean, val eventSink: (Event) -> Unit) : + CircuitUiState sealed interface Event : CircuitUiEvent { data object Next : Event + + data object Details : Event } } @@ -74,9 +81,11 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat Presenter { @Composable override fun present(): TabScreenCircuit.State { - return TabScreenCircuit.State(label = screen.label) { event -> + return TabScreenCircuit.State(label = screen.label, hasDetails = screen is PrimaryScreen) { + event -> when (event) { TabScreenCircuit.Event.Next -> navigator.goTo(screen.next()) + TabScreenCircuit.Event.Details -> navigator.goTo(DetailScreen(screen)) } } } @@ -101,15 +110,25 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat fun TabUI(state: TabScreenCircuit.State, modifier: Modifier = Modifier) { val backStack = LocalBackStack.current?.toImmutableList() ?: persistentListOf() Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - Text( - text = state.label, - style = MaterialTheme.typography.headlineMedium, - modifier = - Modifier.testTag(ContentTags.TAG_LABEL) - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 24.dp, bottom = 8.dp), - ) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = state.label, + style = MaterialTheme.typography.headlineMedium, + modifier = + Modifier.testTag(ContentTags.TAG_LABEL).weight(1f).padding(top = 24.dp, bottom = 8.dp), + ) + if (state.hasDetails) { + Button( + colors = ButtonDefaults.outlinedButtonColors(), + onClick = { state.eventSink(TabScreenCircuit.Event.Details) }, + ) { + Text(text = "View details") + } + } + } LazyColumn( modifier = Modifier.fillMaxSize().testTag(ContentTags.TAG_CONTENT).clickable { From 9dde25142c2faf60f8f214bcf04596bbe516fbe8 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 5 Aug 2025 20:12:36 -0700 Subject: [PATCH 02/44] Rework the content layout to be adaptive --- gradle/libs.versions.toml | 2 + samples/bottom-navigation/build.gradle.kts | 1 + .../circuit/sample/navigation/MainActivity.kt | 9 +- .../navigation/AdaptiveNavDecoration.kt | 103 +++++++++ .../sample/navigation/ContentCircuit.kt | 2 + .../sample/navigation/ContentScaffold.kt | 99 -------- .../sample/navigation/ContentScreen.kt | 215 ++++++++++++++++++ .../circuit/sample/navigation/DetailScreen.kt | 79 ++++--- .../circuit/sample/navigation/TabScreen.kt | 6 +- .../slack/circuit/sample/navigation/main.kt | 9 +- 10 files changed, 378 insertions(+), 147 deletions(-) delete mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScaffold.kt create mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bcd2ea380f..fe65c406a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ compose-material3 = "1.3.2" compose-runtime = "1.8.3" compose-ui = "1.8.3" compose-jb = "1.8.2" +compose-jb-adaptive = "1.1.2" compose-jb-material-icons-core = "1.7.3" compose-bom = "2025.07.00" compose-integration-constraintlayout = "1.1.1" @@ -179,6 +180,7 @@ compose-material-icons = { module = "org.jetbrains.compose.material:material-ico compose-material-iconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose-jb" } compose-material-material = { module = "org.jetbrains.compose.material:material", version.ref = "compose-jb" } compose-material-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-jb" } +compose-material-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "compose-jb-adaptive" } coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } diff --git a/samples/bottom-navigation/build.gradle.kts b/samples/bottom-navigation/build.gradle.kts index 25def08de8..c3f92e33c6 100644 --- a/samples/bottom-navigation/build.gradle.kts +++ b/samples/bottom-navigation/build.gradle.kts @@ -36,6 +36,7 @@ kotlin { dependencies { implementation(libs.compose.foundation) implementation(libs.compose.material.material3) + implementation(libs.compose.material.material3.adaptive) implementation(libs.compose.material.icons) implementation(libs.compose.ui.tooling.preview) implementation(projects.circuitFoundation) diff --git a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt index dee8fada4f..bae8f32f5c 100644 --- a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt +++ b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.core.net.toUri import androidx.core.view.WindowCompat import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals +import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator import com.slack.circuit.runtime.screen.Screen import com.slack.circuitx.android.IntentScreen @@ -46,7 +47,7 @@ class MainActivity : AppCompatActivity() { val tabs = TabScreen.all setContent { MaterialTheme { - val backStack = rememberSaveableBackStack(tabs.first()) + val backStack = rememberSaveableBackStack(ContentScreen(tabs)) val navigator = rememberCircuitNavigator(backStack) // Build the delegate Navigator. val interceptingNavigator = @@ -66,7 +67,11 @@ class MainActivity : AppCompatActivity() { .build() } CircuitCompositionLocals(circuit) { - ContentScaffold(backStack, interceptingNavigator, tabs, Modifier.fillMaxSize()) + NavigableCircuitContent( + navigator = interceptingNavigator, + backStack = backStack, + modifier = Modifier.fillMaxSize(), + ) } } } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt index ace0c7c391..c1322b97e7 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt @@ -2,8 +2,111 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.window.core.layout.WindowWidthSizeClass +import com.slack.circuit.backstack.NavArgument +import com.slack.circuit.backstack.NavDecoration +import com.slack.circuit.foundation.NavigatorDefaults +import com.slack.circuit.foundation.animation.AnimatedNavDecoration +import com.slack.circuit.foundation.animation.AnimatedScreenTransform +import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.screen.Screen +import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory +import kotlin.reflect.KClass +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap interface PrimaryScreen : Screen interface SecondaryScreen : Screen + +// todo Generalize this +@OptIn(ExperimentalCircuitApi::class) +class AdaptiveNavDecoration( + screenTransforms: ImmutableMap, AnimatedScreenTransform>, + onBackInvoked: () -> Unit, +) : NavDecoration { + + // Use GestureNavigationDecorator for the stacked case, normal cross-fade for multipane + private val delegate = + AnimatedNavDecoration( + animatedScreenTransforms = screenTransforms, + decoratorFactory = CrossFadeNavDecoratorFactory(), + ) + + private val delegateStacked = + AnimatedNavDecoration( + animatedScreenTransforms = screenTransforms, + decoratorFactory = + GestureNavigationDecorationFactory( + fallback = NavigatorDefaults.DefaultDecoratorFactory, + onBackInvoked = onBackInvoked, + ), + ) + + @Composable + override fun DecoratedContent( + args: ImmutableList, + modifier: Modifier, + content: @Composable (T) -> Unit, + ) { + // Decorate the content layout based on the window size. + // - Wide enough show as two pane + // - Otherwise stack normally + val windowInfo = currentWindowAdaptiveInfo() + val layoutSideBySide = + when (windowInfo.windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> false + WindowWidthSizeClass.MEDIUM -> true + WindowWidthSizeClass.EXPANDED -> true + else -> false + } + if (layoutSideBySide) { + Row(modifier = modifier) { + // todo Make this smarter + val (primary, stack) = + remember(args) { + val index = args.indexOfFirst { it.screen is PrimaryScreen } + val hasSecondaryStack = + index > 0 && args.subList(0, index).all { it.screen is SecondaryScreen } + println("adaptive: $index $args") + if (hasSecondaryStack) { + args[index] to args.subList(0, index) + } else { + args.first() to null + } + } + + // Always show the primary screen + Box(modifier = Modifier.weight(1f)) { content(primary) } + // Show around the hinge + Box( + modifier = + Modifier.weight(1f) + .fillMaxSize() + .background( + if (stack == null) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.surface + ) + ) { + if (stack != null) { + // Stack everything that is not the root screen + delegate.DecoratedContent(stack, Modifier, content) + } + // todo Show a primary screen empty state here + // else if (rootScreen is PrimaryScreen) {} + } + } + } else { + delegateStacked.DecoratedContent(args, modifier, content) + } + } +} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt index 92d63fa468..4238900265 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt @@ -7,6 +7,8 @@ import com.slack.circuit.foundation.Circuit fun buildCircuitForTabs(tabs: Collection): Circuit { return Circuit.Builder() .apply { + addPresenterFactory(ContentPresenter.Factory) + addUiFactory(ContentUiFactory) for (tab in tabs) { addPresenterFactory(TabPresenter.Factory(tab::class)) addUiFactory(TabUiFactory(tab::class)) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScaffold.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScaffold.kt deleted file mode 100644 index ab60cc6f74..0000000000 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScaffold.kt +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (C) 2025 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.sample.navigation - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContent -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.minimumInteractiveComponentSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.slack.circuit.backstack.SaveableBackStack -import com.slack.circuit.foundation.NavigableCircuitContent -import com.slack.circuit.runtime.Navigator -import kotlinx.collections.immutable.ImmutableList - -@Composable -fun ContentScaffold( - backStack: SaveableBackStack, - navigator: Navigator, - tabs: ImmutableList, - modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier.testTag(ContentTags.TAG_SCAFFOLD).fillMaxSize(), - bottomBar = { BottomTabRow(tabs, backStack, navigator) }, - ) { innerPadding -> - NavigableCircuitContent( - navigator = navigator, - backStack = backStack, - modifier = Modifier.padding(innerPadding).fillMaxSize(), - ) - } -} - -@Composable -private fun BottomTabRow( - tabs: ImmutableList, - backStack: SaveableBackStack, - navigator: Navigator, - modifier: Modifier = Modifier, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Bottom)), - ) { - tabs.forEach { tab -> - val selected = tab == backStack.rootRecord?.screen - Text( - text = tab.label, - color = if (selected) MaterialTheme.colorScheme.onSecondary else Color.Unspecified, - textAlign = TextAlign.Center, - maxLines = 1, - modifier = - Modifier.testTag(ContentTags.TAG_TAB) - .semantics { this.selected = selected } - .weight(1f) - .height(IntrinsicSize.Max) - .clickable { navigator.resetRoot(tab, saveState = true, restoreState = true) } - .background(if (selected) MaterialTheme.colorScheme.secondary else Color.Unspecified) - .padding(horizontal = 8.dp, vertical = 20.dp), - ) - } - Icon( - Icons.Default.Info, - contentDescription = "Info", - modifier = - Modifier.clickable { navigator.goTo(InfoScreen) } - .height(IntrinsicSize.Max) - .minimumInteractiveComponentSize() - .padding(horizontal = 8.dp, vertical = 20.dp), - ) - } -} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt new file mode 100644 index 0000000000..699ee24f84 --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -0,0 +1,215 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sample.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.slack.circuit.backstack.rememberSaveableBackStack +import com.slack.circuit.foundation.LocalCircuit +import com.slack.circuit.foundation.NavEvent +import com.slack.circuit.foundation.NavigableCircuitContent +import com.slack.circuit.foundation.onNavEvent +import com.slack.circuit.foundation.rememberCircuitNavigator +import com.slack.circuit.internal.runtime.Parcelize +import com.slack.circuit.runtime.CircuitContext +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.ExperimentalCircuitApi +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.runtime.ui.Ui +import com.slack.circuit.runtime.ui.ui +import kotlinx.collections.immutable.ImmutableList + +@Parcelize data class ContentScreen(val tabs: ImmutableList) : Screen + +data class ContentState( + val tabs: ImmutableList, + val rootScreen: Screen, + val eventSink: (ContentEvent) -> Unit, +) : CircuitUiState + +sealed interface ContentEvent : CircuitUiEvent { + data class OnNavEvent(val event: NavEvent) : ContentEvent +} + +class ContentPresenter(private val screen: ContentScreen, private val navigator: Navigator) : + Presenter { + @Composable + override fun present(): ContentState { + return ContentState(screen.tabs, screen.tabs.first()) { event -> + when (event) { + is ContentEvent.OnNavEvent -> navigator.onNavEvent(event.event) + } + } + } + + object Factory : Presenter.Factory { + override fun create( + screen: Screen, + navigator: Navigator, + context: CircuitContext, + ): Presenter<*>? { + return if (screen is ContentScreen) { + ContentPresenter(screen, navigator) + } else { + null + } + } + } +} + +object ContentUiFactory : Ui.Factory { + override fun create(screen: Screen, context: CircuitContext): Ui<*>? { + return if (screen is ContentScreen) { + ui { state, modifier -> ContentUi(state, modifier) } + } else { + null + } + } +} + +@OptIn(ExperimentalCircuitApi::class) +@Composable +fun ContentUi(state: ContentState, modifier: Modifier = Modifier) { + val eventSink by rememberUpdatedState(state.eventSink) + val backStack = rememberSaveableBackStack(state.rootScreen) + val contentNavigator = + rememberCircuitNavigator(backStack, onRootPop = {}, enableBackHandler = true) + val interceptingNavigator = + remember(contentNavigator) { + object : Navigator by contentNavigator { + // todo Does root pop need to hand the result up? + override fun resetRoot( + newRoot: Screen, + saveState: Boolean, + restoreState: Boolean, + ): ImmutableList { + return when (newRoot) { + is PrimaryScreen, + is SecondaryScreen -> { + contentNavigator.resetRoot(newRoot, saveState, restoreState) + } + else -> { + eventSink( + ContentEvent.OnNavEvent(NavEvent.ResetRoot(newRoot, saveState, restoreState)) + ) + peekBackStack() + } + } + } + + override fun goTo(screen: Screen): Boolean { + return when (screen) { + is PrimaryScreen, + is SecondaryScreen -> { + contentNavigator.goTo(screen) + } + else -> { + eventSink(ContentEvent.OnNavEvent(NavEvent.GoTo(screen))) + true + } + } + } + } + } + Scaffold( + modifier = modifier.testTag(ContentTags.TAG_SCAFFOLD).fillMaxSize(), + bottomBar = { + BottomTabRow( + tabs = state.tabs, + rootScreen = backStack.rootRecord?.screen, + onNavEvent = { interceptingNavigator.onNavEvent(it) }, + ) + }, + ) { innerPadding -> + val transforms = + requireNotNull(LocalCircuit.current) { "Need a circuit in a circuit" } + .animatedScreenTransforms + NavigableCircuitContent( + navigator = interceptingNavigator, + backStack = backStack, + modifier = Modifier.padding(innerPadding).fillMaxSize(), + decoration = + remember(transforms, interceptingNavigator) { + AdaptiveNavDecoration(transforms, interceptingNavigator::pop) + }, + ) + } +} + +@Composable +private fun BottomTabRow( + tabs: ImmutableList, + rootScreen: Screen?, + onNavEvent: (NavEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Bottom)), + ) { + tabs.forEach { tab -> + val selected = tab == rootScreen + Text( + text = tab.label, + color = if (selected) MaterialTheme.colorScheme.onSecondary else Color.Unspecified, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = + Modifier.testTag(ContentTags.TAG_TAB) + .semantics { this.selected = selected } + .weight(1f) + .height(IntrinsicSize.Max) + .clickable { + onNavEvent(NavEvent.ResetRoot(tab, saveState = true, restoreState = true)) + } + .background(if (selected) MaterialTheme.colorScheme.secondary else Color.Unspecified) + .padding(horizontal = 8.dp, vertical = 20.dp), + ) + } + Icon( + Icons.Default.Info, + contentDescription = "Info", + modifier = + Modifier.clickable { onNavEvent(NavEvent.GoTo(InfoScreen)) } + .height(IntrinsicSize.Max) + .minimumInteractiveComponentSize() + .padding(horizontal = 8.dp, vertical = 20.dp), + ) + } +} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt index db1507548a..0faa525e69 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable @@ -45,46 +44,44 @@ sealed interface DetailEvent : CircuitUiEvent { @Composable fun DetailUi(state: DetailState, modifier: Modifier = Modifier) { - Scaffold(modifier = modifier) { padding -> - Card(modifier = Modifier.padding(padding).padding(8.dp).fillMaxSize()) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = state.label, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = 16.dp).weight(1f), - ) - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Close", - tint = MaterialTheme.colorScheme.onSurface, - modifier = - Modifier.padding(top = 8.dp) - .clickable( - interactionSource = null, - indication = ripple(bounded = false, radius = 24.dp), - ) { - state.eventSink(DetailEvent.Close) - } - .padding(8.dp), - ) - } - Card( - modifier = Modifier.fillMaxSize().padding(8.dp), - colors = CardDefaults.outlinedCardColors(), - ) { - Text( - text = state.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - .verticalScroll(rememberScrollState()), - ) - } + Card(modifier = modifier.padding(8.dp).fillMaxSize()) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp).weight(1f), + ) + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier.padding(top = 8.dp) + .clickable( + interactionSource = null, + indication = ripple(bounded = false, radius = 24.dp), + ) { + state.eventSink(DetailEvent.Close) + } + .padding(8.dp), + ) + } + Card( + modifier = Modifier.fillMaxSize().padding(8.dp), + colors = CardDefaults.outlinedCardColors(), + ) { + Text( + text = state.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + .verticalScroll(rememberScrollState()), + ) } } } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index a93daa90b5..e98db81399 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -36,12 +36,12 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Parcelize -sealed interface TabScreen : Screen { +sealed interface TabScreen : Screen, PrimaryScreen { val label: String @Parcelize data class Root(override val label: String = "Root") : TabScreen - @Parcelize data class Screen1(override val label: String = "Screen 1") : TabScreen, PrimaryScreen + @Parcelize data class Screen1(override val label: String = "Screen 1") : TabScreen @Parcelize data class Screen2(override val label: String = "Screen 2") : TabScreen @@ -81,7 +81,7 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat Presenter { @Composable override fun present(): TabScreenCircuit.State { - return TabScreenCircuit.State(label = screen.label, hasDetails = screen is PrimaryScreen) { + return TabScreenCircuit.State(label = screen.label, hasDetails = screen is TabScreen.Screen1) { event -> when (event) { TabScreenCircuit.Event.Next -> navigator.goTo(screen.next()) diff --git a/samples/bottom-navigation/src/jvmMain/kotlin/com/slack/circuit/sample/navigation/main.kt b/samples/bottom-navigation/src/jvmMain/kotlin/com/slack/circuit/sample/navigation/main.kt index 982cd2e87a..508a609213 100644 --- a/samples/bottom-navigation/src/jvmMain/kotlin/com/slack/circuit/sample/navigation/main.kt +++ b/samples/bottom-navigation/src/jvmMain/kotlin/com/slack/circuit/sample/navigation/main.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals +import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator import com.slack.circuit.sharedelements.SharedElementTransitionLayout import com.slack.circuitx.navigation.intercepting.rememberInterceptingNavigator @@ -25,7 +26,7 @@ fun main() { application { Window(title = "Navigation Sample", onCloseRequest = ::exitApplication) { MaterialTheme { - val backStack = rememberSaveableBackStack(tabs.first()) + val backStack = rememberSaveableBackStack(ContentScreen(tabs)) val navigator = rememberCircuitNavigator(backStack) { exitApplication() } // CircuitX Navigation val uriHandler = LocalUriHandler.current @@ -34,7 +35,11 @@ fun main() { rememberInterceptingNavigator(navigator = navigator, interceptors = interceptors) CircuitCompositionLocals(circuit) { SharedElementTransitionLayout { - ContentScaffold(backStack, interceptingNavigator, tabs, Modifier.fillMaxSize()) + NavigableCircuitContent( + navigator = interceptingNavigator, + backStack = backStack, + modifier = Modifier.fillMaxSize(), + ) } } } From 500a45d64eb3859a19de009d1f0d25b7b20e54ae Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 5 Aug 2025 20:14:59 -0700 Subject: [PATCH 03/44] Fix root reset --- .../commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt index 5f39c0886a..084ed02551 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt @@ -16,7 +16,7 @@ public fun Navigator.onNavEvent(event: NavEvent) { when (event) { is NavEvent.Pop -> pop(event.result) is NavEvent.GoTo -> goTo(event.screen) - is NavEvent.ResetRoot -> resetRoot(event.newRoot) + is NavEvent.ResetRoot -> resetRoot(event.newRoot, event.saveState, event.restoreState) } } From 303c8d87a0114942779fef126baf6f4b1185ccf1 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Mon, 8 Sep 2025 19:37:17 -0700 Subject: [PATCH 04/44] Get it mostly working with `ListDetailPaneScaffold` --- gradle/libs.versions.toml | 1 + samples/bottom-navigation/build.gradle.kts | 2 + .../navigation/AdaptiveNavDecoration.kt | 187 +++++++++++++----- .../sample/navigation/ContentScreen.kt | 30 +-- .../circuit/sample/navigation/DetailScreen.kt | 93 +++++---- .../circuit/sample/navigation/TabScreen.kt | 81 ++++---- .../slack/circuit/sample/navigation/main.kt | 13 +- 7 files changed, 262 insertions(+), 145 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc44653045..a0c6612308 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -181,6 +181,7 @@ compose-material-iconsExtended = { module = "org.jetbrains.compose.material:mate compose-material-material = { module = "org.jetbrains.compose.material:material", version.ref = "compose-jb" } compose-material-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-jb" } compose-material-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "compose-jb-adaptive" } +compose-material-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "compose-jb-adaptive" } coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } diff --git a/samples/bottom-navigation/build.gradle.kts b/samples/bottom-navigation/build.gradle.kts index 600fdb0472..690bb82ef3 100644 --- a/samples/bottom-navigation/build.gradle.kts +++ b/samples/bottom-navigation/build.gradle.kts @@ -41,7 +41,9 @@ kotlin { implementation(libs.compose.foundation) implementation(libs.compose.material.material3) implementation(libs.compose.material.material3.adaptive) + implementation(libs.compose.material.material3.adaptive.layout) implementation(libs.compose.material.icons) + implementation(libs.compose.ui.backhandler) implementation(projects.circuitFoundation) implementation(projects.circuitx.gestureNavigation) implementation(projects.circuitx.navigation) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt index c1322b97e7..330dc4663c 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt @@ -2,59 +2,69 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.MutableThreePaneScaffoldState +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.PredictiveBackHandler +import androidx.compose.ui.graphics.Color import androidx.window.core.layout.WindowWidthSizeClass import com.slack.circuit.backstack.NavArgument import com.slack.circuit.backstack.NavDecoration -import com.slack.circuit.foundation.NavigatorDefaults import com.slack.circuit.foundation.animation.AnimatedNavDecoration +import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.screen.Screen -import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory import kotlin.reflect.KClass -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap +import kotlinx.coroutines.CancellationException interface PrimaryScreen : Screen interface SecondaryScreen : Screen // todo Generalize this +// - Allow for styling etc @OptIn(ExperimentalCircuitApi::class) class AdaptiveNavDecoration( - screenTransforms: ImmutableMap, AnimatedScreenTransform>, - onBackInvoked: () -> Unit, + screenTransforms: Map, AnimatedScreenTransform>, + private val decoratorFactory: AnimatedNavDecorator.Factory, + private val backgroundColor: @Composable () -> Color = { Color.Unspecified }, + private val onPop: () -> Unit = {}, ) : NavDecoration { - // Use GestureNavigationDecorator for the stacked case, normal cross-fade for multipane private val delegate = AnimatedNavDecoration( animatedScreenTransforms = screenTransforms, - decoratorFactory = CrossFadeNavDecoratorFactory(), + decoratorFactory = decoratorFactory, ) - private val delegateStacked = + private val sideBySideDelegate = AnimatedNavDecoration( - animatedScreenTransforms = screenTransforms, - decoratorFactory = - GestureNavigationDecorationFactory( - fallback = NavigatorDefaults.DefaultDecoratorFactory, - onBackInvoked = onBackInvoked, - ), + animatedScreenTransforms = emptyMap(), + decoratorFactory = decoratorFactory, ) @Composable override fun DecoratedContent( - args: ImmutableList, + args: List, modifier: Modifier, content: @Composable (T) -> Unit, ) { @@ -70,43 +80,120 @@ class AdaptiveNavDecoration( else -> false } if (layoutSideBySide) { - Row(modifier = modifier) { - // todo Make this smarter - val (primary, stack) = - remember(args) { - val index = args.indexOfFirst { it.screen is PrimaryScreen } - val hasSecondaryStack = - index > 0 && args.subList(0, index).all { it.screen is SecondaryScreen } - println("adaptive: $index $args") - if (hasSecondaryStack) { - args[index] to args.subList(0, index) - } else { - args.first() to null + SideBySideContent(args, windowInfo, modifier, content) + } else { + delegate.DecoratedContent(args, modifier, content) + } + } + + @OptIn( + ExperimentalSharedTransitionApi::class, + ExperimentalMaterial3AdaptiveApi::class, + ExperimentalComposeUiApi::class, + ) + @Composable + private fun SideBySideContent( + args: List, + windowInfo: WindowAdaptiveInfo, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit, + ) { + val (primaryArgs, secondaryLookup) = rememberSideBySideNavArguments(args) + val topPrimary by rememberUpdatedState(primaryArgs.first()) + val secondaryTransition = + updateTransition(secondaryLookup[topPrimary]?.takeUnless { it.isEmpty() }) + sideBySideDelegate.DecoratedContent(primaryArgs, modifier) { primary -> + val secondary = + if (primary == topPrimary) { + secondaryTransition.currentState ?: secondaryTransition.targetState + } else { + null + } + val hasSecondary = !secondary.isNullOrEmpty() + val scaffoldValue = if (hasSecondary) PrimarySecondary else Primary + val scaffoldState = remember { MutableThreePaneScaffoldState(scaffoldValue) } + val directive = remember(windowInfo) { calculatePaneScaffoldDirective(windowInfo) } + + val paneExpansionState = rememberPaneExpansionState(key = scaffoldValue.paneExpansionStateKey) + LaunchedEffect(scaffoldValue) { scaffoldState.animateTo(scaffoldValue) } + ListDetailPaneScaffold( + modifier = Modifier.fillMaxSize().background(backgroundColor()), + directive = directive, + scaffoldState = scaffoldState, + paneExpansionState = paneExpansionState, + paneExpansionDragHandle = { + // VerticalDragHandle() + }, + listPane = { AnimatedPane(modifier = Modifier) { content(primary) } }, + detailPane = { + AnimatedPane { + // Stack everything that is not the primary screen + if (hasSecondary) { + delegate.DecoratedContent(secondary, Modifier, content) } } + }, + ) - // Always show the primary screen - Box(modifier = Modifier.weight(1f)) { content(primary) } - // Show around the hinge - Box( - modifier = - Modifier.weight(1f) - .fillMaxSize() - .background( - if (stack == null) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surface - ) - ) { - if (stack != null) { - // Stack everything that is not the root screen - delegate.DecoratedContent(stack, Modifier, content) + // Prevent the decorator from handling the back press + PredictiveBackHandler(enabled = secondary?.size == 1) { progress -> + try { + progress.collect { backEvent -> + scaffoldState.seekTo(backEvent.progress, Primary, isPredictiveBackInProgress = true) } - // todo Show a primary screen empty state here - // else if (rootScreen is PrimaryScreen) {} + onPop() + } catch (_: CancellationException) { + scaffoldState.snapTo(PrimarySecondary) } } - } else { - delegateStacked.DecoratedContent(args, modifier, content) } } } + +@Composable +private fun rememberSideBySideNavArguments( + args: List +): Pair, Map>> = + remember(args) { + val primary = mutableListOf() + val secondaryLookup = mutableMapOf>() + val secondary = mutableListOf() + for (arg in args) { + val screen = arg.screen + when { + screen is SecondaryScreen -> { + secondary += arg + } + else -> { + primary += arg + secondaryLookup[arg] = secondary.toList() + secondary.clear() + } + } + } + if (primary.isEmpty()) { + secondary.toList() to emptyMap() + } else { + primary.toList() to secondaryLookup.toMap() + } + } + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private val PrimarySecondary = + ThreePaneScaffoldValue( + // List + secondary = PaneAdaptedValue.Expanded, + // Detail + primary = PaneAdaptedValue.Expanded, + tertiary = PaneAdaptedValue.Hidden, + ) + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private val Primary = + ThreePaneScaffoldValue( + // List + secondary = PaneAdaptedValue.Expanded, + // Detail + primary = PaneAdaptedValue.Hidden, + tertiary = PaneAdaptedValue.Hidden, + ) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 699ee24f84..9cff046798 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.IntrinsicSize @@ -50,12 +51,13 @@ import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui -import kotlinx.collections.immutable.ImmutableList +import com.slack.circuit.sharedelements.SharedElementTransitionLayout +import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory -@Parcelize data class ContentScreen(val tabs: ImmutableList) : Screen +@Parcelize data class ContentScreen(val tabs: List) : Screen data class ContentState( - val tabs: ImmutableList, + val tabs: List, val rootScreen: Screen, val eventSink: (ContentEvent) -> Unit, ) : CircuitUiState @@ -100,9 +102,9 @@ object ContentUiFactory : Ui.Factory { } } -@OptIn(ExperimentalCircuitApi::class) +@OptIn(ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class) @Composable -fun ContentUi(state: ContentState, modifier: Modifier = Modifier) { +fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElementTransitionLayout { val eventSink by rememberUpdatedState(state.eventSink) val backStack = rememberSaveableBackStack(state.rootScreen) val contentNavigator = @@ -115,7 +117,7 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) { newRoot: Screen, saveState: Boolean, restoreState: Boolean, - ): ImmutableList { + ): List { return when (newRoot) { is PrimaryScreen, is SecondaryScreen -> { @@ -154,16 +156,20 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) { ) }, ) { innerPadding -> - val transforms = - requireNotNull(LocalCircuit.current) { "Need a circuit in a circuit" } - .animatedScreenTransforms + val circuit = requireNotNull(LocalCircuit.current) { "Need a circuit in a circuit" } NavigableCircuitContent( navigator = interceptingNavigator, backStack = backStack, modifier = Modifier.padding(innerPadding).fillMaxSize(), decoration = - remember(transforms, interceptingNavigator) { - AdaptiveNavDecoration(transforms, interceptingNavigator::pop) + remember(circuit.animatedScreenTransforms, interceptingNavigator) { + AdaptiveNavDecoration( + screenTransforms = circuit.animatedScreenTransforms, + decoratorFactory = + GestureNavigationDecorationFactory(onBackInvoked = { interceptingNavigator.pop() }), + backgroundColor = { MaterialTheme.colorScheme.background }, + onPop = { interceptingNavigator.pop() }, + ) }, ) } @@ -171,7 +177,7 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) { @Composable private fun BottomTabRow( - tabs: ImmutableList, + tabs: List, rootScreen: Screen?, onNavEvent: (NavEvent) -> Unit, modifier: Modifier = Modifier, diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt index 0faa525e69..871da68fe8 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -29,6 +30,8 @@ import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui +import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation @Parcelize data class DetailScreen(val primary: TabScreen) : SecondaryScreen @@ -42,49 +45,61 @@ sealed interface DetailEvent : CircuitUiEvent { data object Close : DetailEvent } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable -fun DetailUi(state: DetailState, modifier: Modifier = Modifier) { - Card(modifier = modifier.padding(8.dp).fillMaxSize()) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = state.label, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = 16.dp).weight(1f), - ) - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Close", - tint = MaterialTheme.colorScheme.onSurface, - modifier = - Modifier.padding(top = 8.dp) - .clickable( - interactionSource = null, - indication = ripple(bounded = false, radius = 24.dp), - ) { - state.eventSink(DetailEvent.Close) - } - .padding(8.dp), - ) - } +fun DetailUi(state: DetailState, screen: DetailScreen, modifier: Modifier = Modifier) = + SharedElementTransitionScope { Card( - modifier = Modifier.fillMaxSize().padding(8.dp), - colors = CardDefaults.outlinedCardColors(), + modifier = + modifier + .padding(8.dp) + .fillMaxSize() + // todo Only do this when content is not side by si +// .sharedBounds( +// sharedContentState = rememberSharedContentState(key = "${screen.primary}-details"), +// animatedVisibilityScope = requireAnimatedScope(Navigation), +// ) ) { - Text( - text = state.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - .verticalScroll(rememberScrollState()), - ) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp).weight(1f), + ) + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier.padding(top = 8.dp) + .clickable( + interactionSource = null, + indication = ripple(bounded = false, radius = 24.dp), + ) { + state.eventSink(DetailEvent.Close) + } + .padding(8.dp), + ) + } + Card( + modifier = Modifier.fillMaxSize().padding(8.dp), + colors = CardDefaults.outlinedCardColors(), + ) { + Text( + text = state.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + .verticalScroll(rememberScrollState()), + ) + } } } -} class DetailPresenter(private val screen: DetailScreen, private val navigator: Navigator) : Presenter { @@ -116,7 +131,7 @@ class DetailPresenter(private val screen: DetailScreen, private val navigator: N object DetailUiFactory : Ui.Factory { override fun create(screen: Screen, context: CircuitContext): Ui<*>? { return if (screen is DetailScreen) { - ui { state, modifier -> DetailUi(state, modifier) } + ui { state, modifier -> DetailUi(state, screen, modifier) } } else { null } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index 7a18061c66..fddedbe87f 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -31,6 +32,8 @@ import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui +import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation import kotlin.reflect.KClass @Parcelize @@ -71,7 +74,7 @@ object TabScreenCircuit { sealed interface Event : CircuitUiEvent { data object Next : Event - data object Details : Event + data class Details(val screen: TabScreen) : Event } } @@ -83,7 +86,7 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat event -> when (event) { TabScreenCircuit.Event.Next -> navigator.goTo(screen.next()) - TabScreenCircuit.Event.Details -> navigator.goTo(DetailScreen(screen)) + is TabScreenCircuit.Event.Details -> navigator.goTo(DetailScreen(event.screen)) } } } @@ -103,51 +106,57 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat } } -@OptIn(DelicateCircuitFoundationApi::class) +@OptIn(DelicateCircuitFoundationApi::class, ExperimentalSharedTransitionApi::class) @Composable -fun TabUI(state: TabScreenCircuit.State, modifier: Modifier = Modifier) { - val backStack = LocalBackStack.current?.toList().orEmpty() - Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.Bottom, - ) { - Text( - text = state.label, - style = MaterialTheme.typography.headlineMedium, - modifier = - Modifier.testTag(ContentTags.TAG_LABEL).weight(1f).padding(top = 24.dp, bottom = 8.dp), - ) - if (state.hasDetails) { - Button( - colors = ButtonDefaults.outlinedButtonColors(), - onClick = { state.eventSink(TabScreenCircuit.Event.Details) }, - ) { - Text(text = "View details") +fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = Modifier) = + SharedElementTransitionScope { + val backStack = LocalBackStack.current?.toList().orEmpty() + Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = state.label, + style = MaterialTheme.typography.headlineMedium, + modifier = + Modifier.testTag(ContentTags.TAG_LABEL).weight(1f).padding(top = 24.dp, bottom = 8.dp), + ) + if (state.hasDetails) { + Button( + colors = ButtonDefaults.outlinedButtonColors(), + onClick = { state.eventSink(TabScreenCircuit.Event.Details(screen)) }, + modifier = + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(key = "${screen}-details"), + animatedVisibilityScope = requireAnimatedScope(Navigation), + ), + ) { + Text(text = "View details") + } } } - } - LazyColumn( - modifier = - Modifier.fillMaxSize().testTag(ContentTags.TAG_CONTENT).clickable { - state.eventSink(TabScreenCircuit.Event.Next) + LazyColumn( + modifier = + Modifier.fillMaxSize().testTag(ContentTags.TAG_CONTENT).clickable { + state.eventSink(TabScreenCircuit.Event.Next) + } + ) { + itemsIndexed(backStack) { i, item -> + Text( + text = "$i: ${item.screen}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) } - ) { - itemsIndexed(backStack) { i, item -> - Text( - text = "$i: ${item.screen}", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - ) } } } -} class TabUiFactory(private val tabClass: KClass) : Ui.Factory { override fun create(screen: Screen, context: CircuitContext): Ui<*>? { return if (tabClass.isInstance(screen)) { - ui { state, modifier -> TabUI(state, modifier) } + ui { state, modifier -> TabUI(state, screen as TabScreen, modifier) } } else { null } diff --git a/samples/bottom-navigation/src/jvmMain/kotlin/com/slack/circuit/sample/navigation/main.kt b/samples/bottom-navigation/src/jvmMain/kotlin/com/slack/circuit/sample/navigation/main.kt index 02f1412ed8..2b2ef56258 100644 --- a/samples/bottom-navigation/src/jvmMain/kotlin/com/slack/circuit/sample/navigation/main.kt +++ b/samples/bottom-navigation/src/jvmMain/kotlin/com/slack/circuit/sample/navigation/main.kt @@ -14,7 +14,6 @@ import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator -import com.slack.circuit.sharedelements.SharedElementTransitionLayout import com.slack.circuitx.navigation.intercepting.rememberInterceptingNavigator @OptIn(ExperimentalSharedTransitionApi::class) @@ -33,13 +32,11 @@ fun main() { val interceptingNavigator = rememberInterceptingNavigator(navigator = navigator, interceptors = interceptors) CircuitCompositionLocals(circuit) { - SharedElementTransitionLayout { - NavigableCircuitContent( - navigator = interceptingNavigator, - backStack = backStack, - modifier = Modifier.fillMaxSize(), - ) - } + NavigableCircuitContent( + navigator = interceptingNavigator, + backStack = backStack, + modifier = Modifier.fillMaxSize(), + ) } } } From 18c54758fb72a40974ab13fdde937531ff5d50b9 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 12 Sep 2025 18:16:07 -0700 Subject: [PATCH 05/44] wip swipe to restore thing, trying a backstack record stash thing --- .../com/slack/circuit/backstack/BackStack.kt | 2 + .../circuit/backstack/SaveableBackStack.kt | 27 +- .../foundation/NavigableCircuitContent.kt | 28 +- .../navigation/AdaptiveNavDecoration.kt | 276 +++++++++++++++--- .../sample/navigation/ContentScreen.kt | 1 + .../circuit/sample/navigation/TabScreen.kt | 6 +- 6 files changed, 285 insertions(+), 55 deletions(-) diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt index 7fcdb0dd78..276f1d7f13 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt @@ -117,6 +117,8 @@ public interface BackStack : Iterable { */ public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean + public fun stashTogether(primary: R, secondary: R) + @Stable public interface Record { /** diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index d02b0ef835..4d78ed4f93 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -80,6 +80,7 @@ internal constructor( // Both visible for testing internal val entryList = mutableStateListOf() internal val stateStore = mutableMapOf>() + internal val stashed = mutableMapOf() init { nullableRootRecord?.let(::push) @@ -101,7 +102,10 @@ internal constructor( } public fun push(screen: Screen, args: Map, resultKey: String?): Boolean { - return push(Record(screen, args), resultKey) + // Consume any stashed record. + val stashedRecord = stashed.remove(topRecord) + val record = stashedRecord?.copy(args = args) ?: Record(screen, args) + return push(record, resultKey) } public override fun push(record: Record, resultKey: String?): Boolean { @@ -155,6 +159,9 @@ internal constructor( if (record in stored) return true } } + if (includeSaved && stashed.isNotEmpty()) { + if (record in stashed.values) return true + } return false } @@ -167,15 +174,25 @@ internal constructor( // If includeSaved, check saved backstack states too if (includeSaved && stateStore.isNotEmpty()) { val storedValues = stateStore.values - for ((i, stored) in storedValues.withIndex()) { - if (i >= depth) break - // stored can mutate, so safely get the record. - if (stored.getOrNull(i)?.key == key) return true + for (storedRecords in storedValues) { + for (i in 0 until min(depth, storedRecords.size)) { + // stored can mutate, so safely get the record. + if (storedRecords.getOrNull(i)?.key == key) return true + } + } + } + if (includeSaved && stashed.isNotEmpty()) { + for (stored in stashed.values) { + if (stored.key == key) return true } } return false } + override fun stashTogether(primary: Record, secondary: Record) { + stashed[primary] = secondary + } + public data class Record( override val screen: Screen, val args: Map = emptyMap(), diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 0fa3f601b5..4a1a2d98c4 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -143,20 +143,22 @@ public fun NavigableCircuitContent( val activeContentProviders = buildCircuitContentProviders(backStack = backStack) val circuitProvidedValues = providedValuesForBackStack(backStack, circuit.backStackLocalProviders) - navDecoration.DecoratedContent(activeContentProviders, modifier) { provider -> - val record = provider.record - - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val circuitProvidedValues = - remember(record) { circuitProvidedValues[record] }?.provideValues() - val providedLocals = - remember(values, circuitProvidedValues) { - (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() + CompositionLocalProvider(LocalBackStack provides backStack) { + navDecoration.DecoratedContent(activeContentProviders, modifier) { provider -> + val record = provider.record + + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val circuitProvidedValues = + remember(record) { circuitProvidedValues[record] }?.provideValues() + val providedLocals = + remember(values, circuitProvidedValues) { + (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() + } + CompositionLocalProvider(*providedLocals) { + provider.content(record, contentProviderState) } - CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { - provider.content(record, contentProviderState) } } } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt index 330dc4663c..2748108234 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt @@ -2,9 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.WindowAdaptiveInfo @@ -18,21 +28,37 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.PredictiveBackHandler import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onLayoutRectChanged +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import androidx.window.core.layout.WindowWidthSizeClass +import com.slack.circuit.backstack.BackStack import com.slack.circuit.backstack.NavArgument import com.slack.circuit.backstack.NavDecoration +import com.slack.circuit.foundation.DelicateCircuitFoundationApi +import com.slack.circuit.foundation.LocalBackStack +import com.slack.circuit.foundation.RecordContentProvider import com.slack.circuit.foundation.animation.AnimatedNavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform +import com.slack.circuit.retained.rememberRetainedSaveable import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope import kotlin.reflect.KClass import kotlinx.coroutines.CancellationException @@ -40,13 +66,34 @@ interface PrimaryScreen : Screen interface SecondaryScreen : Screen +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private val PrimarySecondary = + ThreePaneScaffoldValue( + // List + secondary = PaneAdaptedValue.Expanded, + // Detail + primary = PaneAdaptedValue.Expanded, + tertiary = PaneAdaptedValue.Hidden, + ) + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private val Primary = + ThreePaneScaffoldValue( + // List + secondary = PaneAdaptedValue.Expanded, + // Detail + primary = PaneAdaptedValue.Hidden, + tertiary = PaneAdaptedValue.Hidden, + ) + // todo Generalize this // - Allow for styling etc -@OptIn(ExperimentalCircuitApi::class) +@OptIn(ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class) class AdaptiveNavDecoration( screenTransforms: Map, AnimatedScreenTransform>, private val decoratorFactory: AnimatedNavDecorator.Factory, private val backgroundColor: @Composable () -> Color = { Color.Unspecified }, + private val onGoTo: (Screen) -> Unit = {}, private val onPop: () -> Unit = {}, ) : NavDecoration { @@ -62,6 +109,7 @@ class AdaptiveNavDecoration( decoratorFactory = decoratorFactory, ) + @OptIn(DelicateCircuitFoundationApi::class) @Composable override fun DecoratedContent( args: List, @@ -82,7 +130,85 @@ class AdaptiveNavDecoration( if (layoutSideBySide) { SideBySideContent(args, windowInfo, modifier, content) } else { - delegate.DecoratedContent(args, modifier, content) + // todo Backstack won't save this... + val backStack = LocalBackStack.current as? BackStack + var standbyArgument by rememberRetainedSaveable { mutableStateOf(null) } + val argsTransition = updateTransition(args, label = "AdaptiveNavDecoration") + val currentArgs = argsTransition.currentState + val targetArgs = argsTransition.targetState + val topArg = targetArgs.first() + when { + // Root change + currentArgs.last() != targetArgs.last() -> standbyArgument = null + // Forward + currentArgs.size < targetArgs.size -> standbyArgument = null + // Back on last + currentArgs.size > 1 && targetArgs.size == 1 -> { + remember(targetArgs) { + standbyArgument = + currentArgs + .first() + .takeIf { it.screen is SecondaryScreen } + ?.also { + val primaryRecord = (topArg as? RecordContentProvider<*>)?.record + val secondaryRecord = (it as? RecordContentProvider<*>)?.record + if (backStack != null && primaryRecord != null && secondaryRecord != null) { + backStack.stashTogether(primaryRecord, secondaryRecord) + } + } + } + } + } + val standby = standbyArgument + + // todo This needs to merge with the decoration pop + if ( + standby != null && currentArgs == targetArgs + ) { // || args.size == 2 && args.get(1) is SecondaryScreen + // Allow a swipe to restore the previous screen + AnimatedContent( + standby, + transitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, + ) { + ProvideAnimatedTransitionScope( + SharedElementTransitionScope.AnimatedScope.Navigation, + this, + ) { + + // todo Layout + val state = + rememberSwipeState( + standby, + onRestored = { onGoTo(standby.screen) }, + onDismissed = { onPop() }, + ) + Box( + modifier + .onLayoutRectChanged { + state.maxWidth = it.width.toFloat() + state.offset = it.width.toFloat() + } + .draggable( + state = state.draggableState, + orientation = Orientation.Horizontal, + enabled = true, + reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, + onDragStopped = { velocity -> state.onDragStopped(velocity) }, + ) + ) { + content(topArg) + OptionalLayout( + shouldLayout = { state.offset != state.maxWidth }, + modifier = Modifier, + ) { + content(standby) + } + } + } + } + } else { + delegate.DecoratedContent(args, modifier, content) + } } } @@ -99,17 +225,13 @@ class AdaptiveNavDecoration( content: @Composable (T) -> Unit, ) { val (primaryArgs, secondaryLookup) = rememberSideBySideNavArguments(args) - val topPrimary by rememberUpdatedState(primaryArgs.first()) - val secondaryTransition = - updateTransition(secondaryLookup[topPrimary]?.takeUnless { it.isEmpty() }) + val secondaryLookupTransition = updateTransition(secondaryLookup) sideBySideDelegate.DecoratedContent(primaryArgs, modifier) { primary -> - val secondary = - if (primary == topPrimary) { - secondaryTransition.currentState ?: secondaryTransition.targetState - } else { - null - } - val hasSecondary = !secondary.isNullOrEmpty() + val secondaryArgs = + with(secondaryLookupTransition) { currentState[primary] ?: targetState[primary] } + + val hasSecondary = !secondaryArgs.isNullOrEmpty() + val singleSecondary = hasSecondary && secondaryArgs.size == 1 val scaffoldValue = if (hasSecondary) PrimarySecondary else Primary val scaffoldState = remember { MutableThreePaneScaffoldState(scaffoldValue) } val directive = remember(windowInfo) { calculatePaneScaffoldDirective(windowInfo) } @@ -127,16 +249,22 @@ class AdaptiveNavDecoration( listPane = { AnimatedPane(modifier = Modifier) { content(primary) } }, detailPane = { AnimatedPane { - // Stack everything that is not the primary screen - if (hasSecondary) { - delegate.DecoratedContent(secondary, Modifier, content) + when { + singleSecondary -> { + // ScaffoldState animates the single case + content(secondaryArgs.single()) + } + hasSecondary -> { + // Stack multiple with the normal decoration + delegate.DecoratedContent(secondaryArgs, Modifier, content) + } } } }, ) // Prevent the decorator from handling the back press - PredictiveBackHandler(enabled = secondary?.size == 1) { progress -> + PredictiveBackHandler(enabled = singleSecondary) { progress -> try { progress.collect { backEvent -> scaffoldState.seekTo(backEvent.progress, Primary, isPredictiveBackInProgress = true) @@ -178,22 +306,100 @@ private fun rememberSideBySideNavArguments( } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -private val PrimarySecondary = - ThreePaneScaffoldValue( - // List - secondary = PaneAdaptedValue.Expanded, - // Detail - primary = PaneAdaptedValue.Expanded, - tertiary = PaneAdaptedValue.Hidden, - ) +/** + * A composable which optionally lays out the given [content], depending on what [shouldLayout] + * returns, whilst still keeping [content] attached to composition. + * + * This is useful when used in conjunction with `movableContentOf`, as that will destroy the + * content's state 'very soon' after the content is detached from composition. By using this + * composable, the content can remain attached to composition, but can avoid being measured, laid + * out or drawn, allowing us to reduce wasted work. + */ +@Composable +internal fun OptionalLayout( + shouldLayout: () -> Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier + // If we're not visible, don't measure, layout (or draw) + .let { if (shouldLayout()) it else it.emptyLayout() } + // Content in the back stack should not be interactive until they're on top + .pointerInput(Unit) {} + ) { + content() + } +} -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -private val Primary = - ThreePaneScaffoldValue( - // List - secondary = PaneAdaptedValue.Expanded, - // Detail - primary = PaneAdaptedValue.Hidden, - tertiary = PaneAdaptedValue.Hidden, - ) +/** This no-ops measure + layout (and thus draw) for child content. */ +private fun Modifier.emptyLayout(): Modifier = layout { _, constraints -> + layout(constraints.minWidth, constraints.minHeight) {} +} + +@Composable +private fun rememberSwipeState( + key: Any?, + onRestored: () -> Unit, + onDismissed: () -> Unit, + swipeThreshold: Float = 0.5f, +): SwipeState { + return remember(key, swipeThreshold) { SwipeState(swipeThreshold) } + .apply { + this.onRestored = onRestored + this.onDismissed = onDismissed + } +} + +@Stable +private class SwipeState(private val swipeThreshold: Float) { + var offset by mutableFloatStateOf(0f) + var maxWidth by mutableFloatStateOf(0f) + + var onRestored: () -> Unit = {} + var onDismissed: () -> Unit = {} + + val progress: Float by derivedStateOf { if (maxWidth == 0f) 0f else offset / maxWidth } + + val draggableState = DraggableState { delta -> + val newOffset = (offset + delta).coerceIn(0f, maxWidth) + val resistance = calculateResistance(newOffset) + offset = newOffset * resistance + } + + fun performDrag(delta: Float): Float { + val previousOffset = offset + val newOffset = (offset + delta).coerceIn(0f, maxWidth) + val resistance = calculateResistance(newOffset) + offset = newOffset * resistance + return offset - previousOffset + } + + suspend fun onDragStopped(velocity: Float) { + val thresholdValue = swipeThreshold * maxWidth + + val shouldDismiss = offset >= thresholdValue || velocity > 1000f + val shouldRestore = offset <= thresholdValue || velocity > 1000f + val targetOffset = if (shouldDismiss) maxWidth else 0f + + draggableState.drag(MutatePriority.PreventUserInput) { + Animatable(offset).animateTo(targetOffset) { dragBy(value - offset) } + } + if (shouldRestore && targetOffset == 0f) { + onRestored() + } else if (shouldDismiss && targetOffset == maxWidth) { + onDismissed() + } + } + + private fun calculateResistance(offset: Float): Float { + return if (offset > maxWidth) { + val overshoot = offset - maxWidth + val resistanceFactor = 0.1f + val resistance = overshoot * resistanceFactor + (maxWidth + resistance) / offset + } else { + 1f + } + } +} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 9cff046798..8abbeab152 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -168,6 +168,7 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen decoratorFactory = GestureNavigationDecorationFactory(onBackInvoked = { interceptingNavigator.pop() }), backgroundColor = { MaterialTheme.colorScheme.background }, + onGoTo = { screen -> interceptingNavigator.goTo(screen) }, onPop = { interceptingNavigator.pop() }, ) }, diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index fddedbe87f..3a6ead7d34 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -82,8 +82,10 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat Presenter { @Composable override fun present(): TabScreenCircuit.State { - return TabScreenCircuit.State(label = screen.label, hasDetails = screen is TabScreen.Screen1) { - event -> + return TabScreenCircuit.State( + label = screen.label, + hasDetails = screen !is TabScreen.Screen2, + ) { event -> when (event) { TabScreenCircuit.Event.Next -> navigator.goTo(screen.next()) is TabScreenCircuit.Event.Details -> navigator.goTo(DetailScreen(event.screen)) From 5693ede748871d538bfaea9d9a85c9130ede46e3 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 16 Sep 2025 10:37:04 -0700 Subject: [PATCH 06/44] WIP - Better approach by preventing the backstack pop - Need to have the item/detail layout handle the "closed" two item layout directly, allows for resizing animation - Provide a "decorator" shared element scope --- .../com/slack/circuit/backstack/BackStack.kt | 2 - .../circuit/backstack/SaveableBackStack.kt | 27 +- .../foundation/NavigableCircuitContent.kt | 28 +- .../GestureNavTransitionHolder.kt | 2 +- .../PredictiveBackNavigationDecorator.kt | 7 +- .../navigation/AdaptiveNavDecoration.kt | 266 ++++-------------- .../sample/navigation/ContentScreen.kt | 91 +++--- .../SwipeableGestureNavigationDecorator.kt | 262 +++++++++++++++++ 8 files changed, 395 insertions(+), 290 deletions(-) create mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt index 276f1d7f13..7fcdb0dd78 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt @@ -117,8 +117,6 @@ public interface BackStack : Iterable { */ public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean - public fun stashTogether(primary: R, secondary: R) - @Stable public interface Record { /** diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index 4d78ed4f93..d02b0ef835 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -80,7 +80,6 @@ internal constructor( // Both visible for testing internal val entryList = mutableStateListOf() internal val stateStore = mutableMapOf>() - internal val stashed = mutableMapOf() init { nullableRootRecord?.let(::push) @@ -102,10 +101,7 @@ internal constructor( } public fun push(screen: Screen, args: Map, resultKey: String?): Boolean { - // Consume any stashed record. - val stashedRecord = stashed.remove(topRecord) - val record = stashedRecord?.copy(args = args) ?: Record(screen, args) - return push(record, resultKey) + return push(Record(screen, args), resultKey) } public override fun push(record: Record, resultKey: String?): Boolean { @@ -159,9 +155,6 @@ internal constructor( if (record in stored) return true } } - if (includeSaved && stashed.isNotEmpty()) { - if (record in stashed.values) return true - } return false } @@ -174,25 +167,15 @@ internal constructor( // If includeSaved, check saved backstack states too if (includeSaved && stateStore.isNotEmpty()) { val storedValues = stateStore.values - for (storedRecords in storedValues) { - for (i in 0 until min(depth, storedRecords.size)) { - // stored can mutate, so safely get the record. - if (storedRecords.getOrNull(i)?.key == key) return true - } - } - } - if (includeSaved && stashed.isNotEmpty()) { - for (stored in stashed.values) { - if (stored.key == key) return true + for ((i, stored) in storedValues.withIndex()) { + if (i >= depth) break + // stored can mutate, so safely get the record. + if (stored.getOrNull(i)?.key == key) return true } } return false } - override fun stashTogether(primary: Record, secondary: Record) { - stashed[primary] = secondary - } - public data class Record( override val screen: Screen, val args: Map = emptyMap(), diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 4a1a2d98c4..0fa3f601b5 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -143,22 +143,20 @@ public fun NavigableCircuitContent( val activeContentProviders = buildCircuitContentProviders(backStack = backStack) val circuitProvidedValues = providedValuesForBackStack(backStack, circuit.backStackLocalProviders) - CompositionLocalProvider(LocalBackStack provides backStack) { - navDecoration.DecoratedContent(activeContentProviders, modifier) { provider -> - val record = provider.record - - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val circuitProvidedValues = - remember(record) { circuitProvidedValues[record] }?.provideValues() - val providedLocals = - remember(values, circuitProvidedValues) { - (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() - } - CompositionLocalProvider(*providedLocals) { - provider.content(record, contentProviderState) + navDecoration.DecoratedContent(activeContentProviders, modifier) { provider -> + val record = provider.record + + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val circuitProvidedValues = + remember(record) { circuitProvidedValues[record] }?.provideValues() + val providedLocals = + remember(values, circuitProvidedValues) { + (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() } + CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { + provider.content(record, contentProviderState) } } } diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt index 05120eb310..214f6caf61 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt @@ -11,7 +11,7 @@ import com.slack.circuit.foundation.animation.AnimatedNavState * the necessary information as an argument, which is optimal for `AnimatedContent`. */ @Immutable -internal data class GestureNavTransitionHolder(val args: List) : +public data class GestureNavTransitionHolder(val args: List) : AnimatedNavState { override val backStack: List = args } diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt index cc12217c3b..a88c9678f6 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt @@ -24,7 +24,7 @@ import kotlin.math.abs import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch -internal abstract class PredictiveBackNavigationDecorator( +public abstract class PredictiveBackNavigationDecorator( private val onBackInvoked: () -> Unit ) : AnimatedNavDecorator> { @@ -33,14 +33,11 @@ internal abstract class PredictiveBackNavigationDecorator( private set protected var showPrevious: Boolean by mutableStateOf(false) - private set + protected var swipeProgress: Float by mutableFloatStateOf(0f) protected var isSeeking: Boolean by mutableStateOf(false) private set - protected var swipeProgress: Float by mutableFloatStateOf(0f) - private set - protected var swipeOffset: Offset by mutableStateOf(Offset.Zero) private set diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt index 2748108234..92deb8d6e3 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt @@ -2,19 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.DraggableState -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.WindowAdaptiveInfo @@ -28,37 +18,33 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.PredictiveBackHandler import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onLayoutRectChanged -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.LayoutDirection import androidx.window.core.layout.WindowWidthSizeClass -import com.slack.circuit.backstack.BackStack import com.slack.circuit.backstack.NavArgument import com.slack.circuit.backstack.NavDecoration import com.slack.circuit.foundation.DelicateCircuitFoundationApi -import com.slack.circuit.foundation.LocalBackStack -import com.slack.circuit.foundation.RecordContentProvider +import com.slack.circuit.foundation.NavigatorDefaults import com.slack.circuit.foundation.animation.AnimatedNavDecoration -import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform import com.slack.circuit.retained.rememberRetainedSaveable import com.slack.circuit.runtime.ExperimentalCircuitApi +import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen -import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope -import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult +import com.slack.circuitx.navigation.intercepting.InterceptedPopResult +import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.Skipped +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.SuccessConsumed import kotlin.reflect.KClass import kotlinx.coroutines.CancellationException @@ -86,27 +72,73 @@ private val Primary = tertiary = PaneAdaptedValue.Hidden, ) +@Composable +fun rememberAdaptiveNavState(): AdaptiveNavState { + return rememberRetainedSaveable(saver = AdaptiveNavState.Saver) { AdaptiveNavState() } +} + +class AdaptiveNavState : NavigationInterceptor { + + var isOpen by mutableStateOf(false) + private set + + fun close() { + isOpen = false + } + + override fun resetRoot( + newRoot: Screen, + saveState: Boolean, + restoreState: Boolean, + ): InterceptedResetRootResult { + isOpen = false + return Skipped + } + + override fun goTo(screen: Screen): InterceptedGoToResult { + isOpen = false + return Skipped + } + + override fun pop(peekBackStack: List, result: PopResult?): InterceptedPopResult { + return if (!isOpen && peekBackStack.size == 2 && peekBackStack.first() is SecondaryScreen) { + isOpen = true + SuccessConsumed + } else Skipped + } + + object Saver : androidx.compose.runtime.saveable.Saver { + override fun SaverScope.save(value: AdaptiveNavState): Boolean { + return value.isOpen + } + + override fun restore(value: Boolean): AdaptiveNavState { + return AdaptiveNavState().apply { isOpen = value } + } + } +} + // todo Generalize this // - Allow for styling etc @OptIn(ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class) class AdaptiveNavDecoration( screenTransforms: Map, AnimatedScreenTransform>, - private val decoratorFactory: AnimatedNavDecorator.Factory, + private val adaptiveNavState: AdaptiveNavState, private val backgroundColor: @Composable () -> Color = { Color.Unspecified }, - private val onGoTo: (Screen) -> Unit = {}, private val onPop: () -> Unit = {}, ) : NavDecoration { private val delegate = AnimatedNavDecoration( animatedScreenTransforms = screenTransforms, - decoratorFactory = decoratorFactory, + decoratorFactory = + SwipeableGestureNavigationDecorator.Factory(adaptiveNavState, onBackInvoked = { onPop() }), ) private val sideBySideDelegate = AnimatedNavDecoration( animatedScreenTransforms = emptyMap(), - decoratorFactory = decoratorFactory, + decoratorFactory = NavigatorDefaults.DefaultDecoratorFactory, ) @OptIn(DelicateCircuitFoundationApi::class) @@ -130,85 +162,7 @@ class AdaptiveNavDecoration( if (layoutSideBySide) { SideBySideContent(args, windowInfo, modifier, content) } else { - // todo Backstack won't save this... - val backStack = LocalBackStack.current as? BackStack - var standbyArgument by rememberRetainedSaveable { mutableStateOf(null) } - val argsTransition = updateTransition(args, label = "AdaptiveNavDecoration") - val currentArgs = argsTransition.currentState - val targetArgs = argsTransition.targetState - val topArg = targetArgs.first() - when { - // Root change - currentArgs.last() != targetArgs.last() -> standbyArgument = null - // Forward - currentArgs.size < targetArgs.size -> standbyArgument = null - // Back on last - currentArgs.size > 1 && targetArgs.size == 1 -> { - remember(targetArgs) { - standbyArgument = - currentArgs - .first() - .takeIf { it.screen is SecondaryScreen } - ?.also { - val primaryRecord = (topArg as? RecordContentProvider<*>)?.record - val secondaryRecord = (it as? RecordContentProvider<*>)?.record - if (backStack != null && primaryRecord != null && secondaryRecord != null) { - backStack.stashTogether(primaryRecord, secondaryRecord) - } - } - } - } - } - val standby = standbyArgument - - // todo This needs to merge with the decoration pop - if ( - standby != null && currentArgs == targetArgs - ) { // || args.size == 2 && args.get(1) is SecondaryScreen - // Allow a swipe to restore the previous screen - AnimatedContent( - standby, - transitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, - ) { - ProvideAnimatedTransitionScope( - SharedElementTransitionScope.AnimatedScope.Navigation, - this, - ) { - - // todo Layout - val state = - rememberSwipeState( - standby, - onRestored = { onGoTo(standby.screen) }, - onDismissed = { onPop() }, - ) - Box( - modifier - .onLayoutRectChanged { - state.maxWidth = it.width.toFloat() - state.offset = it.width.toFloat() - } - .draggable( - state = state.draggableState, - orientation = Orientation.Horizontal, - enabled = true, - reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, - onDragStopped = { velocity -> state.onDragStopped(velocity) }, - ) - ) { - content(topArg) - OptionalLayout( - shouldLayout = { state.offset != state.maxWidth }, - modifier = Modifier, - ) { - content(standby) - } - } - } - } - } else { - delegate.DecoratedContent(args, modifier, content) - } + delegate.DecoratedContent(args, modifier, content) } } @@ -305,101 +259,3 @@ private fun rememberSideBySideNavArguments( primary.toList() to secondaryLookup.toMap() } } - -/** - * A composable which optionally lays out the given [content], depending on what [shouldLayout] - * returns, whilst still keeping [content] attached to composition. - * - * This is useful when used in conjunction with `movableContentOf`, as that will destroy the - * content's state 'very soon' after the content is detached from composition. By using this - * composable, the content can remain attached to composition, but can avoid being measured, laid - * out or drawn, allowing us to reduce wasted work. - */ -@Composable -internal fun OptionalLayout( - shouldLayout: () -> Boolean, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - Box( - modifier - // If we're not visible, don't measure, layout (or draw) - .let { if (shouldLayout()) it else it.emptyLayout() } - // Content in the back stack should not be interactive until they're on top - .pointerInput(Unit) {} - ) { - content() - } -} - -/** This no-ops measure + layout (and thus draw) for child content. */ -private fun Modifier.emptyLayout(): Modifier = layout { _, constraints -> - layout(constraints.minWidth, constraints.minHeight) {} -} - -@Composable -private fun rememberSwipeState( - key: Any?, - onRestored: () -> Unit, - onDismissed: () -> Unit, - swipeThreshold: Float = 0.5f, -): SwipeState { - return remember(key, swipeThreshold) { SwipeState(swipeThreshold) } - .apply { - this.onRestored = onRestored - this.onDismissed = onDismissed - } -} - -@Stable -private class SwipeState(private val swipeThreshold: Float) { - var offset by mutableFloatStateOf(0f) - var maxWidth by mutableFloatStateOf(0f) - - var onRestored: () -> Unit = {} - var onDismissed: () -> Unit = {} - - val progress: Float by derivedStateOf { if (maxWidth == 0f) 0f else offset / maxWidth } - - val draggableState = DraggableState { delta -> - val newOffset = (offset + delta).coerceIn(0f, maxWidth) - val resistance = calculateResistance(newOffset) - offset = newOffset * resistance - } - - fun performDrag(delta: Float): Float { - val previousOffset = offset - val newOffset = (offset + delta).coerceIn(0f, maxWidth) - val resistance = calculateResistance(newOffset) - offset = newOffset * resistance - return offset - previousOffset - } - - suspend fun onDragStopped(velocity: Float) { - val thresholdValue = swipeThreshold * maxWidth - - val shouldDismiss = offset >= thresholdValue || velocity > 1000f - val shouldRestore = offset <= thresholdValue || velocity > 1000f - val targetOffset = if (shouldDismiss) maxWidth else 0f - - draggableState.drag(MutatePriority.PreventUserInput) { - Animatable(offset).animateTo(targetOffset) { dragBy(value - offset) } - } - if (shouldRestore && targetOffset == 0f) { - onRestored() - } else if (shouldDismiss && targetOffset == maxWidth) { - onDismissed() - } - } - - private fun calculateResistance(offset: Float): Float { - return if (offset > maxWidth) { - val overshoot = offset - maxWidth - val resistanceFactor = 0.1f - val resistance = overshoot * resistanceFactor - (maxWidth + resistance) / offset - } else { - 1f - } - } -} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 8abbeab152..d00078eeac 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -52,7 +53,12 @@ import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionLayout -import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory +import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult +import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.Skipped +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.SuccessConsumed +import com.slack.circuitx.navigation.intercepting.rememberInterceptingNavigator @Parcelize data class ContentScreen(val tabs: List) : Screen @@ -102,50 +108,57 @@ object ContentUiFactory : Ui.Factory { } } +private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> Unit>) : + NavigationInterceptor { + override fun resetRoot( + newRoot: Screen, + saveState: Boolean, + restoreState: Boolean, + ): InterceptedResetRootResult { + return when (newRoot) { + is PrimaryScreen, + is SecondaryScreen -> { + Skipped + } + else -> { + eventSink.value( + ContentEvent.OnNavEvent(NavEvent.ResetRoot(newRoot, saveState, restoreState)) + ) + SuccessConsumed + } + } + } + + override fun goTo(screen: Screen): InterceptedGoToResult { + return when (screen) { + is PrimaryScreen, + is SecondaryScreen -> { + Skipped + } + else -> { + eventSink.value(ContentEvent.OnNavEvent(NavEvent.GoTo(screen))) + SuccessConsumed + } + } + } +} + @OptIn(ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class) @Composable fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElementTransitionLayout { - val eventSink by rememberUpdatedState(state.eventSink) + val eventSink = rememberUpdatedState(state.eventSink) val backStack = rememberSaveableBackStack(state.rootScreen) + val adaptiveNavState = rememberAdaptiveNavState() + val contentInterceptor = remember { ContentInterceptor(eventSink) } val contentNavigator = rememberCircuitNavigator(backStack, onRootPop = {}, enableBackHandler = true) + val interceptingNavigator = - remember(contentNavigator) { - object : Navigator by contentNavigator { - // todo Does root pop need to hand the result up? - override fun resetRoot( - newRoot: Screen, - saveState: Boolean, - restoreState: Boolean, - ): List { - return when (newRoot) { - is PrimaryScreen, - is SecondaryScreen -> { - contentNavigator.resetRoot(newRoot, saveState, restoreState) - } - else -> { - eventSink( - ContentEvent.OnNavEvent(NavEvent.ResetRoot(newRoot, saveState, restoreState)) - ) - peekBackStack() - } - } - } + rememberInterceptingNavigator( + navigator = contentNavigator, + interceptors = listOf(adaptiveNavState, contentInterceptor), + ) - override fun goTo(screen: Screen): Boolean { - return when (screen) { - is PrimaryScreen, - is SecondaryScreen -> { - contentNavigator.goTo(screen) - } - else -> { - eventSink(ContentEvent.OnNavEvent(NavEvent.GoTo(screen))) - true - } - } - } - } - } Scaffold( modifier = modifier.testTag(ContentTags.TAG_SCAFFOLD).fillMaxSize(), bottomBar = { @@ -165,10 +178,8 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen remember(circuit.animatedScreenTransforms, interceptingNavigator) { AdaptiveNavDecoration( screenTransforms = circuit.animatedScreenTransforms, - decoratorFactory = - GestureNavigationDecorationFactory(onBackInvoked = { interceptingNavigator.pop() }), + adaptiveNavState = adaptiveNavState, backgroundColor = { MaterialTheme.colorScheme.background }, - onGoTo = { screen -> interceptingNavigator.goTo(screen) }, onPop = { interceptingNavigator.pop() }, ) }, diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt new file mode 100644 index 0000000000..19af10e95e --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt @@ -0,0 +1,262 @@ +package com.slack.circuit.sample.navigation + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import com.slack.circuit.backstack.NavArgument +import com.slack.circuit.foundation.animation.AnimatedNavDecorator +import com.slack.circuit.foundation.animation.AnimatedNavEvent +import com.slack.circuit.foundation.animation.AnimatedNavState +import com.slack.circuitx.gesturenavigation.GestureNavTransitionHolder +import com.slack.circuitx.gesturenavigation.PredictiveBackNavigationDecorator +import kotlin.math.roundToInt + +class SwipeableGestureNavigationDecorator( + private val adaptiveNavState: AdaptiveNavState, + private val enterOffsetFraction: Float = 0.25f, + private val swipeThreshold: Float = 0.4f, + private val onBackInvoked: () -> Unit, +) : PredictiveBackNavigationDecorator(onBackInvoked) { + + // Track popped zIndex so screens are layered correctly + private var zIndexDepth = 0f + + override fun AnimatedContentTransitionScope.transitionSpec( + animatedNavEvent: AnimatedNavEvent + ): ContentTransform { + + return when (animatedNavEvent) { + AnimatedNavEvent.GoTo -> { + slideInHorizontally(initialOffsetX = End) + .togetherWith( + slideOutHorizontally { width -> -(enterOffsetFraction * width).roundToInt() } + ) + } + AnimatedNavEvent.Pop -> { + slideInHorizontally { width -> -(enterOffsetFraction * width).roundToInt() } + .togetherWith( + if (adaptiveNavState.isOpen || showPrevious) ExitTransition.None + else slideOutHorizontally(targetOffsetX = End) + ) + .apply { targetContentZIndex = --zIndexDepth } + } + AnimatedNavEvent.RootReset -> { + zIndexDepth = 0f + // Simple Crossfade on reset + fadeIn() togetherWith fadeOut() + } + } + } + + @Composable + override fun AnimatedContentScope.Decoration( + targetState: GestureNavTransitionHolder, + innerContent: @Composable ((T) -> Unit), + ) { + + val isOpenedScreen = + targetState.backStackDepth == 2 && + targetState.top.screen is SecondaryScreen && + adaptiveNavState.isOpen + + val canRestoreOpenScreen = targetState.backStackDepth == 1 && adaptiveNavState.isOpen + val swipeEnabled = targetState.backStackDepth > 1 || canRestoreOpenScreen + + val dismissState = + rememberSwipeDismissState( + targetState.args.first(), + target = if (isOpenedScreen) Target.Close else Target.Open, + swipeThreshold = swipeThreshold, + onRestore = { + adaptiveNavState.close() + showPrevious = false + swipeProgress = 0f + }, + onDismissed = onBackInvoked, + ) + + LaunchedEffect(isOpenedScreen) { + if (isOpenedScreen) { + dismissState.offset = dismissState.maxWidth + swipeProgress = 1f + showPrevious = true + } + } + + if (swipeEnabled) { + LaunchedEffect(dismissState) { + snapshotFlow { dismissState.progress } + .collect { progress -> + showPrevious = progress > 0f + swipeProgress = if (showPrevious) progress else 0f + } + } + } + DraggableContent( + state = dismissState, + swipeEnabled = swipeEnabled, + content = { innerContent(targetState.args.first()) }, + modifier = Modifier.run { + if (isOpenedScreen) { + pointerInput(Unit) {} + } else this + } + ) + } + + class Factory( + private val adaptiveNavState: AdaptiveNavState, + private val enterOffsetFraction: Float = 0.25f, + private val swipeThreshold: Float = 0.4f, + private val onBackInvoked: () -> Unit, + ) : AnimatedNavDecorator.Factory { + override fun create(): AnimatedNavDecorator { + return SwipeableGestureNavigationDecorator( + adaptiveNavState = adaptiveNavState, + enterOffsetFraction = enterOffsetFraction, + swipeThreshold = swipeThreshold, + onBackInvoked = onBackInvoked, + ) + } + } +} + +private val End: (Int) -> Int = { it } + +/** Draggable content for gesture navigation. */ +@Composable +private fun DraggableContent( + state: SwipeDismissState, + modifier: Modifier = Modifier, + swipeEnabled: Boolean = true, + content: @Composable () -> Unit, +) { + Box( + modifier = + modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + state.maxWidth = constraints.maxWidth.toFloat() + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + .draggable( + state = state.draggableState, + orientation = Orientation.Horizontal, + enabled = swipeEnabled, + reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, + onDragStopped = { velocity -> state.onDragStopped(velocity) }, + ) + ) { + Box(modifier = Modifier.offset { IntOffset(x = state.offset.roundToInt(), y = 0) }) { + content() + } + } +} + +@Composable +private fun rememberSwipeDismissState( + key: Any?, + target: Target, + swipeThreshold: Float, + onRestore: () -> Unit, + onDismissed: () -> Unit, +): SwipeDismissState { + return remember(key, target, swipeThreshold) { SwipeDismissState(target, swipeThreshold) } + .apply { + this.onRestore = onRestore + this.onDismissed = onDismissed + } +} + +enum class Target { + Open, + Close, +} + +@Stable +private class SwipeDismissState(private val target: Target, private val swipeThreshold: Float) { + + var offset by mutableFloatStateOf(0f) + var maxWidth by mutableFloatStateOf(0f) + + var onRestore: () -> Unit = {} + var onDismissed: () -> Unit = {} + + val progress: Float by derivedStateOf { if (maxWidth == 0f) 0f else offset / maxWidth } + + val draggableState = DraggableState { delta -> + val newOffset = (offset + delta).coerceIn(0f, maxWidth) + val resistance = calculateResistance(newOffset) + offset = newOffset * resistance + } + + suspend fun onDragStopped(velocity: Float) { + when (target) { + Target.Open -> { + val thresholdValue = swipeThreshold * maxWidth + val shouldDismiss = offset >= thresholdValue || velocity > 1000f + val targetOffset = if (shouldDismiss) maxWidth else 0f + + draggableState.drag(MutatePriority.PreventUserInput) { + Animatable(offset).animateTo(targetOffset) { dragBy(value - offset) } + } + // Only trigger dismiss callback after animation completes + if (shouldDismiss && targetOffset == maxWidth) { + onDismissed() + } + } + Target.Close -> { + val thresholdValue = (1 - swipeThreshold) * maxWidth + val shouldClose = offset <= thresholdValue || velocity > 1000f + val targetOffset = if (shouldClose) 0f else maxWidth + + draggableState.drag(MutatePriority.PreventUserInput) { + Animatable(offset).animateTo(targetOffset) { dragBy(value - offset) } + } + // Only trigger restore callback after animation completes + if (shouldClose && targetOffset == 0f) { + onRestore() + } + } + } + } + + private fun calculateResistance(offset: Float): Float { + return if (offset > maxWidth) { + val overshoot = offset - maxWidth + val resistanceFactor = 0.1f + val resistance = overshoot * resistanceFactor + (maxWidth + resistance) / offset + } else { + 1f + } + } +} From 5634d63a13e4b7ca8e8ae600b4e5da4b858ed7b6 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 16 Oct 2025 18:24:24 -0700 Subject: [PATCH 07/44] merge fixes --- .../sample/navigation/AdaptiveNavDecoration.kt | 7 ++----- .../circuit/sample/navigation/ContentScreen.kt | 5 ++--- .../circuit/sample/navigation/DetailScreen.kt | 15 ++++++--------- .../SwipeableGestureNavigationDecorator.kt | 13 ++++++++----- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt index 92deb8d6e3..e6fd7faabb 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt @@ -37,6 +37,7 @@ import com.slack.circuit.foundation.animation.AnimatedNavDecoration import com.slack.circuit.foundation.animation.AnimatedScreenTransform import com.slack.circuit.retained.rememberRetainedSaveable import com.slack.circuit.runtime.ExperimentalCircuitApi +import com.slack.circuit.runtime.Navigator.StateOptions import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult @@ -86,11 +87,7 @@ class AdaptiveNavState : NavigationInterceptor { isOpen = false } - override fun resetRoot( - newRoot: Screen, - saveState: Boolean, - restoreState: Boolean, - ): InterceptedResetRootResult { + override fun resetRoot(newRoot: Screen, options: StateOptions): InterceptedResetRootResult { isOpen = false return Skipped } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index d00078eeac..440fcfa77c 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -112,8 +112,7 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> NavigationInterceptor { override fun resetRoot( newRoot: Screen, - saveState: Boolean, - restoreState: Boolean, + options: Navigator.StateOptions ): InterceptedResetRootResult { return when (newRoot) { is PrimaryScreen, @@ -122,7 +121,7 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> } else -> { eventSink.value( - ContentEvent.OnNavEvent(NavEvent.ResetRoot(newRoot, saveState, restoreState)) + ContentEvent.OnNavEvent(NavEvent.ResetRoot(newRoot, options)) ) SuccessConsumed } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt index 871da68fe8..613ade8568 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt @@ -31,7 +31,6 @@ import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionScope -import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation @Parcelize data class DetailScreen(val primary: TabScreen) : SecondaryScreen @@ -50,15 +49,13 @@ sealed interface DetailEvent : CircuitUiEvent { fun DetailUi(state: DetailState, screen: DetailScreen, modifier: Modifier = Modifier) = SharedElementTransitionScope { Card( - modifier = - modifier - .padding(8.dp) - .fillMaxSize() + modifier = modifier.padding(8.dp).fillMaxSize() // todo Only do this when content is not side by si -// .sharedBounds( -// sharedContentState = rememberSharedContentState(key = "${screen.primary}-details"), -// animatedVisibilityScope = requireAnimatedScope(Navigation), -// ) + // .sharedBounds( + // sharedContentState = rememberSharedContentState(key = + // "${screen.primary}-details"), + // animatedVisibilityScope = requireAnimatedScope(Navigation), + // ) ) { Row( modifier = Modifier.padding(horizontal = 16.dp), diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt index 19af10e95e..4aff92624a 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt @@ -1,3 +1,5 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation import androidx.compose.animation.AnimatedContentScope @@ -124,11 +126,12 @@ class SwipeableGestureNavigationDecorator( state = dismissState, swipeEnabled = swipeEnabled, content = { innerContent(targetState.args.first()) }, - modifier = Modifier.run { - if (isOpenedScreen) { - pointerInput(Unit) {} - } else this - } + modifier = + Modifier.run { + if (isOpenedScreen) { + pointerInput(Unit) {} + } else this + }, ) } From e2f58bdb45999868a1dcd78b5b4e7cb859478aba Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 21 Oct 2025 18:42:03 -0700 Subject: [PATCH 08/44] Add navigator as a param on the AnimatedNavDecorator.Factory create --- .../com/slack/circuit/foundation/Circuit.kt | 1 - .../slack/circuit/foundation/NavDecoration.kt | 4 +- .../foundation/NavigableCircuitContent.kt | 41 +++++++++++-------- .../animation/AnimatedNavDecoration.kt | 15 ++++--- .../animation/AnimatedNavDecorator.kt | 7 ++-- ...droidPredictiveBackNavigationDecoration.kt | 16 ++++---- .../GestureNavTransitionHolder.kt | 2 +- .../GestureNavigationDecoration.kt | 5 +-- .../PredictiveBackNavigationDecorator.kt | 2 +- .../iOSPredictiveBackNavigationDecoration.kt | 19 ++++----- .../GestureNavigationDecoration.jvm.kt | 3 +- .../GestureNavigationDecoration.web.kt | 3 +- .../circuit/sample/navigation/MainActivity.kt | 4 +- 13 files changed, 60 insertions(+), 62 deletions(-) rename backstack/src/commonMain/kotlin/com/slack/circuit/backstack/Navigation.kt => circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt (91%) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt index f3f82d0484..bc3578b0bc 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.TextStyle import com.slack.circuit.backstack.BackStack import com.slack.circuit.backstack.BackStackRecordLocalProvider -import com.slack.circuit.backstack.NavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/Navigation.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt similarity index 91% rename from backstack/src/commonMain/kotlin/com/slack/circuit/backstack/Navigation.kt rename to circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt index 40a817a8a3..bdb8676a13 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/Navigation.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.slack.circuit.backstack +package com.slack.circuit.foundation import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier +import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen /** Presentation logic for currently visible routes of a navigable UI. */ @@ -26,6 +27,7 @@ public interface NavDecoration { @Composable public fun DecoratedContent( args: List, + navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, ) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index c056552b9f..de957d56d1 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -42,8 +42,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.slack.circuit.backstack.BackStack import com.slack.circuit.backstack.BackStack.Record -import com.slack.circuit.backstack.NavArgument -import com.slack.circuit.backstack.NavDecoration import com.slack.circuit.backstack.ProvidedValues import com.slack.circuit.backstack.isEmpty import com.slack.circuit.backstack.providedValuesForBackStack @@ -144,22 +142,27 @@ public fun NavigableCircuitContent( val activeContentProviders = buildCircuitContentProviders(backStack = backStack) val circuitProvidedValues = providedValuesForBackStack(backStack, circuit.backStackLocalProviders) - navDecoration.DecoratedContent(activeContentProviders, modifier) { provider -> - val record = provider.record - - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val circuitProvidedValues = - remember(record) { circuitProvidedValues[record] }?.provideValues() - val providedLocals = - remember(values, circuitProvidedValues) { - (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() + navDecoration.DecoratedContent( + args = activeContentProviders, + navigator = navigator, + modifier = modifier, + { provider -> + val record = provider.record + + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val circuitProvidedValues = + remember(record) { circuitProvidedValues[record] }?.provideValues() + val providedLocals = + remember(values, circuitProvidedValues) { + (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() + } + CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { + provider.content(record, contentProviderState) } - CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { - provider.content(record, contentProviderState) - } - } + }, + ) } } @@ -321,7 +324,8 @@ public object NavigatorDefaults { private const val NORMAL_DURATION = 450 * DEBUG_MULTIPLIER public object DefaultDecoratorFactory : AnimatedNavDecorator.Factory { - override fun create(): AnimatedNavDecorator = DefaultDecorator() + override fun create(navigator: Navigator): AnimatedNavDecorator = + DefaultDecorator() } /** @@ -437,6 +441,7 @@ public object NavigatorDefaults { @Composable override fun DecoratedContent( args: List, + navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, ) { diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt index e8eb47eaf7..f2f4d7965b 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt @@ -14,9 +14,10 @@ import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.slack.circuit.backstack.NavArgument -import com.slack.circuit.backstack.NavDecoration +import com.slack.circuit.foundation.NavArgument +import com.slack.circuit.foundation.NavDecoration import com.slack.circuit.runtime.ExperimentalCircuitApi +import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope import com.slack.circuit.sharedelements.SharedElementTransitionScope @@ -98,13 +99,15 @@ public class AnimatedNavDecoration( @Composable public override fun DecoratedContent( args: List, + navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, ) { - val decorator = remember { - @Suppress("UNCHECKED_CAST") - decoratorFactory.create() as AnimatedNavDecorator - } + val decorator = + remember(navigator) { + @Suppress("UNCHECKED_CAST") + decoratorFactory.create(navigator) as AnimatedNavDecorator + } with(decorator) { val transition = updateTransition(args) transition.AnimatedContent( diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt index 422a0ed968..400dea695c 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt @@ -9,8 +9,9 @@ import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.Transition import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import com.slack.circuit.backstack.NavArgument -import com.slack.circuit.backstack.NavDecoration +import com.slack.circuit.foundation.NavArgument +import com.slack.circuit.foundation.NavDecoration +import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen /** @@ -115,7 +116,7 @@ public interface AnimatedNavDecorator { @Stable public interface Factory { - public fun create(): AnimatedNavDecorator + public fun create(navigator: Navigator): AnimatedNavDecorator } } diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index 14b4853da2..b944e6edb9 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -17,8 +17,6 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.GraphicsLayerScope @@ -27,21 +25,21 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp -import com.slack.circuit.backstack.NavArgument +import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavigatorDefaults import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedNavEvent import com.slack.circuit.foundation.animation.AnimatedNavState import com.slack.circuit.runtime.InternalCircuitApi +import com.slack.circuit.runtime.Navigator import com.slack.circuit.sharedelements.SharedElementTransitionScope import kotlin.math.absoluteValue public actual fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory, - onBackInvoked: () -> Unit, + fallback: AnimatedNavDecorator.Factory ): AnimatedNavDecorator.Factory { return when { - Build.VERSION.SDK_INT >= 34 -> AndroidPredictiveBackNavDecorator.Factory(onBackInvoked) + Build.VERSION.SDK_INT >= 34 -> AndroidPredictiveBackNavDecorator.Factory else -> fallback } } @@ -99,9 +97,9 @@ internal class AndroidPredictiveBackNavDecorator(onBackInvoked: } } - class Factory(private val onBackInvoked: () -> Unit) : AnimatedNavDecorator.Factory { - override fun create(): AnimatedNavDecorator { - return AndroidPredictiveBackNavDecorator(onBackInvoked = onBackInvoked) + object Factory : AnimatedNavDecorator.Factory { + override fun create(navigator: Navigator): AnimatedNavDecorator { + return AndroidPredictiveBackNavDecorator(onBackInvoked = { navigator.pop() }) } } } diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt index 214f6caf61..fbb65474d3 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt @@ -3,7 +3,7 @@ package com.slack.circuitx.gesturenavigation import androidx.compose.runtime.Immutable -import com.slack.circuit.backstack.NavArgument +import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavState /** diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt index a629622e17..5ca7a77d65 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt @@ -15,10 +15,7 @@ import com.slack.circuit.foundation.animation.AnimatedNavDecorator * * @param fallback The [AnimatedNavDecorator.Factory] which should be used when running on platforms * which [GestureNavigationDecorationFactory] does not support. - * @param onBackInvoked A lambda which will be called when the user has invoked a 'back' gesture. - * Typically this should call `Navigator.pop()`. */ public expect fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory = NavigatorDefaults.DefaultDecoratorFactory, - onBackInvoked: () -> Unit, + fallback: AnimatedNavDecorator.Factory = NavigatorDefaults.DefaultDecoratorFactory ): AnimatedNavDecorator.Factory diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt index a88c9678f6..f84e792577 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt @@ -17,7 +17,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.backhandler.PredictiveBackHandler import androidx.compose.ui.geometry.Offset -import com.slack.circuit.backstack.NavArgument +import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.runtime.internal.rememberStableCoroutineScope import kotlin.math.abs diff --git a/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt index badbd711e1..a149ec8870 100644 --- a/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt @@ -28,10 +28,11 @@ import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.invalidateLayer import androidx.compose.ui.unit.Constraints -import com.slack.circuit.backstack.NavArgument +import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedNavEvent import com.slack.circuit.foundation.animation.AnimatedNavState +import com.slack.circuit.runtime.Navigator import kotlin.math.roundToInt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.collectLatest @@ -44,14 +45,12 @@ private val End: (Int) -> Int = { it } * A factory that creates an [IOSPredictiveBackNavDecorator] for iOS predictive back navigation. * * @param fallback The [AnimatedNavDecorator.Factory] to use when predictive back is not supported. - * @param onBackInvoked A callback to be invoked when a back gesture is performed. * @return An [AnimatedNavDecorator.Factory] that provides iOS predictive back navigation. */ public actual fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory, - onBackInvoked: () -> Unit, + fallback: AnimatedNavDecorator.Factory ): AnimatedNavDecorator.Factory { - return IOSPredictiveBackNavDecorator.Factory(onBackInvoked = onBackInvoked) + return IOSPredictiveBackNavDecorator.Factory() } /** @@ -115,14 +114,12 @@ internal class IOSPredictiveBackNavDecorator( } } - internal class Factory( - private val enterOffsetFraction: Float = 0.25f, - private val onBackInvoked: () -> Unit, - ) : AnimatedNavDecorator.Factory { - override fun create(): AnimatedNavDecorator { + internal class Factory(private val enterOffsetFraction: Float = 0.25f) : + AnimatedNavDecorator.Factory { + override fun create(navigator: Navigator): AnimatedNavDecorator { return IOSPredictiveBackNavDecorator( enterOffsetFraction = enterOffsetFraction, - onBackInvoked = onBackInvoked, + onBackInvoked = { navigator.pop() }, ) } } diff --git a/circuitx/gesture-navigation/src/jvmMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.jvm.kt b/circuitx/gesture-navigation/src/jvmMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.jvm.kt index 424b200281..ffb368e925 100644 --- a/circuitx/gesture-navigation/src/jvmMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.jvm.kt +++ b/circuitx/gesture-navigation/src/jvmMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.jvm.kt @@ -5,6 +5,5 @@ package com.slack.circuitx.gesturenavigation import com.slack.circuit.foundation.animation.AnimatedNavDecorator public actual fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory, - onBackInvoked: () -> Unit, + fallback: AnimatedNavDecorator.Factory ): AnimatedNavDecorator.Factory = fallback diff --git a/circuitx/gesture-navigation/src/webMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.web.kt b/circuitx/gesture-navigation/src/webMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.web.kt index c4f6c2d121..c7ab3ab327 100644 --- a/circuitx/gesture-navigation/src/webMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.web.kt +++ b/circuitx/gesture-navigation/src/webMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.web.kt @@ -5,6 +5,5 @@ package com.slack.circuitx.gesturenavigation import com.slack.circuit.foundation.animation.AnimatedNavDecorator public actual fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory, - onBackInvoked: () -> Unit, + fallback: AnimatedNavDecorator.Factory ): AnimatedNavDecorator.Factory = fallback diff --git a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt index afafef7222..d2c669d739 100644 --- a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt +++ b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt @@ -60,9 +60,7 @@ class MainActivity : AppCompatActivity() { remember(navigator) { buildCircuitForTabs(tabs) .newBuilder() - .setAnimatedNavDecoratorFactory( - GestureNavigationDecorationFactory(onBackInvoked = { interceptingNavigator.pop() }) - ) + .setAnimatedNavDecoratorFactory(GestureNavigationDecorationFactory()) .build() } CircuitCompositionLocals(circuit) { From 59b39fa88bd0697c273c8e8ed9f24fce78b7e95e Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 21 Oct 2025 18:45:19 -0700 Subject: [PATCH 09/44] Make `AdaptiveNavDecoration` just be concerned with the ListDetail layout --- .../navigation/AdaptiveNavDecoration.kt | 187 ++++++++---------- .../sample/navigation/ContentCircuit.kt | 3 +- .../sample/navigation/ContentScreen.kt | 26 ++- .../navigation/CrossFadeNavDecorator.kt | 32 --- .../circuit/sample/navigation/DetailScreen.kt | 2 +- .../navigation/SwipeReturnNavDecoration.kt | 60 ++++++ .../SwipeableGestureNavigationDecorator.kt | 14 +- .../circuit/sample/navigation/TabScreen.kt | 2 +- 8 files changed, 163 insertions(+), 163 deletions(-) delete mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/CrossFadeNavDecorator.kt create mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt index e6fd7faabb..70c65932cd 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt @@ -4,8 +4,10 @@ package com.slack.circuit.sample.navigation import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.VerticalDragHandle import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -13,46 +15,31 @@ import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.MutableThreePaneScaffoldState import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.PredictiveBackHandler -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowWidthSizeClass -import com.slack.circuit.backstack.NavArgument -import com.slack.circuit.backstack.NavDecoration import com.slack.circuit.foundation.DelicateCircuitFoundationApi -import com.slack.circuit.foundation.NavigatorDefaults +import com.slack.circuit.foundation.NavArgument +import com.slack.circuit.foundation.NavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecoration +import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform -import com.slack.circuit.retained.rememberRetainedSaveable import com.slack.circuit.runtime.ExperimentalCircuitApi -import com.slack.circuit.runtime.Navigator.StateOptions -import com.slack.circuit.runtime.screen.PopResult +import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen -import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult -import com.slack.circuitx.navigation.intercepting.InterceptedPopResult -import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult -import com.slack.circuitx.navigation.intercepting.NavigationInterceptor -import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.Skipped -import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.SuccessConsumed import kotlin.reflect.KClass import kotlinx.coroutines.CancellationException -interface PrimaryScreen : Screen - -interface SecondaryScreen : Screen - @OptIn(ExperimentalMaterial3AdaptiveApi::class) private val PrimarySecondary = ThreePaneScaffoldValue( @@ -73,75 +60,32 @@ private val Primary = tertiary = PaneAdaptedValue.Hidden, ) -@Composable -fun rememberAdaptiveNavState(): AdaptiveNavState { - return rememberRetainedSaveable(saver = AdaptiveNavState.Saver) { AdaptiveNavState() } -} - -class AdaptiveNavState : NavigationInterceptor { - - var isOpen by mutableStateOf(false) - private set - - fun close() { - isOpen = false - } - - override fun resetRoot(newRoot: Screen, options: StateOptions): InterceptedResetRootResult { - isOpen = false - return Skipped - } - - override fun goTo(screen: Screen): InterceptedGoToResult { - isOpen = false - return Skipped - } - - override fun pop(peekBackStack: List, result: PopResult?): InterceptedPopResult { - return if (!isOpen && peekBackStack.size == 2 && peekBackStack.first() is SecondaryScreen) { - isOpen = true - SuccessConsumed - } else Skipped - } - - object Saver : androidx.compose.runtime.saveable.Saver { - override fun SaverScope.save(value: AdaptiveNavState): Boolean { - return value.isOpen - } - - override fun restore(value: Boolean): AdaptiveNavState { - return AdaptiveNavState().apply { isOpen = value } - } - } -} - -// todo Generalize this -// - Allow for styling etc @OptIn(ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class) class AdaptiveNavDecoration( screenTransforms: Map, AnimatedScreenTransform>, - private val adaptiveNavState: AdaptiveNavState, - private val backgroundColor: @Composable () -> Color = { Color.Unspecified }, - private val onPop: () -> Unit = {}, + normalDecoratorFactory: AnimatedNavDecorator.Factory, + detailPaneDecoratorFactory: AnimatedNavDecorator.Factory, + private val isDetailPane: (NavArgument) -> Boolean, + private val shouldUsePaneLayout: (WindowAdaptiveInfo) -> Boolean = ::layoutSideBySide, ) : NavDecoration { private val delegate = AnimatedNavDecoration( animatedScreenTransforms = screenTransforms, - decoratorFactory = - SwipeableGestureNavigationDecorator.Factory(adaptiveNavState, onBackInvoked = { onPop() }), + decoratorFactory = normalDecoratorFactory, ) - private val sideBySideDelegate = + private val detailPaneDelegate = AnimatedNavDecoration( animatedScreenTransforms = emptyMap(), - decoratorFactory = NavigatorDefaults.DefaultDecoratorFactory, + decoratorFactory = detailPaneDecoratorFactory, ) @OptIn(DelicateCircuitFoundationApi::class) @Composable override fun DecoratedContent( args: List, + navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, ) { @@ -149,17 +93,21 @@ class AdaptiveNavDecoration( // - Wide enough show as two pane // - Otherwise stack normally val windowInfo = currentWindowAdaptiveInfo() - val layoutSideBySide = - when (windowInfo.windowSizeClass.windowWidthSizeClass) { - WindowWidthSizeClass.COMPACT -> false - WindowWidthSizeClass.MEDIUM -> true - WindowWidthSizeClass.EXPANDED -> true - else -> false - } - if (layoutSideBySide) { - SideBySideContent(args, windowInfo, modifier, content) + if (shouldUsePaneLayout(windowInfo)) { + PaneContent( + args = args, + windowInfo = windowInfo, + navigator = navigator, + modifier = modifier, + content = content, + ) } else { - delegate.DecoratedContent(args, modifier, content) + delegate.DecoratedContent( + args = args, + navigator = navigator, + modifier = modifier, + content = content, + ) } } @@ -169,15 +117,17 @@ class AdaptiveNavDecoration( ExperimentalComposeUiApi::class, ) @Composable - private fun SideBySideContent( + private fun PaneContent( args: List, windowInfo: WindowAdaptiveInfo, + navigator: Navigator, modifier: Modifier = Modifier, content: @Composable (T) -> Unit, ) { - val (primaryArgs, secondaryLookup) = rememberSideBySideNavArguments(args) + val directive = remember(windowInfo) { calculatePaneScaffoldDirective(windowInfo) } + val (primaryArgs, secondaryLookup) = rememberListDetailNavArguments(args, isDetailPane) val secondaryLookupTransition = updateTransition(secondaryLookup) - sideBySideDelegate.DecoratedContent(primaryArgs, modifier) { primary -> + delegate.DecoratedContent(primaryArgs, navigator, modifier) { primary -> val secondaryArgs = with(secondaryLookupTransition) { currentState[primary] ?: targetState[primary] } @@ -185,42 +135,57 @@ class AdaptiveNavDecoration( val singleSecondary = hasSecondary && secondaryArgs.size == 1 val scaffoldValue = if (hasSecondary) PrimarySecondary else Primary val scaffoldState = remember { MutableThreePaneScaffoldState(scaffoldValue) } - val directive = remember(windowInfo) { calculatePaneScaffoldDirective(windowInfo) } - val paneExpansionState = rememberPaneExpansionState(key = scaffoldValue.paneExpansionStateKey) + // todo Anchors vs Resizeable with minimums + val minPaneSize = 240.dp + val paneExpansionState = + rememberPaneExpansionState( + key = scaffoldValue.paneExpansionStateKey, + anchors = + listOf( + PaneExpansionAnchor.Offset.fromStart(minPaneSize), + PaneExpansionAnchor.Offset.fromStart(directive.defaultPanePreferredWidth), + PaneExpansionAnchor.Offset.fromEnd(directive.defaultPanePreferredWidth), + PaneExpansionAnchor.Offset.fromEnd(minPaneSize), + ), + ) LaunchedEffect(scaffoldValue) { scaffoldState.animateTo(scaffoldValue) } ListDetailPaneScaffold( - modifier = Modifier.fillMaxSize().background(backgroundColor()), + modifier = Modifier.fillMaxSize(), directive = directive, scaffoldState = scaffoldState, paneExpansionState = paneExpansionState, - paneExpansionDragHandle = { - // VerticalDragHandle() + paneExpansionDragHandle = { state -> + val interactionSource = remember { MutableInteractionSource() } + VerticalDragHandle( + modifier = + Modifier.paneExpansionDraggable( + state = state, + minTouchTargetSize = LocalMinimumInteractiveComponentSize.current, + interactionSource = interactionSource, + semanticsProperties = state.defaultDragHandleSemantics(), + ), + interactionSource = interactionSource, + ) }, listPane = { AnimatedPane(modifier = Modifier) { content(primary) } }, detailPane = { AnimatedPane { - when { - singleSecondary -> { - // ScaffoldState animates the single case - content(secondaryArgs.single()) - } - hasSecondary -> { - // Stack multiple with the normal decoration - delegate.DecoratedContent(secondaryArgs, Modifier, content) - } + if (hasSecondary) { + // Stack multiple with a normal decoration + detailPaneDelegate.DecoratedContent(secondaryArgs, navigator, Modifier, content) } } }, ) - // Prevent the decorator from handling the back press + // Prevent the detailPaneDelegate from handling the back press PredictiveBackHandler(enabled = singleSecondary) { progress -> try { progress.collect { backEvent -> scaffoldState.seekTo(backEvent.progress, Primary, isPredictiveBackInProgress = true) } - onPop() + navigator.pop() } catch (_: CancellationException) { scaffoldState.snapTo(PrimarySecondary) } @@ -229,18 +194,26 @@ class AdaptiveNavDecoration( } } +private fun layoutSideBySide(adaptiveInfo: WindowAdaptiveInfo): Boolean = + when (adaptiveInfo.windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> false + WindowWidthSizeClass.MEDIUM -> true + WindowWidthSizeClass.EXPANDED -> true + else -> false + } + @Composable -private fun rememberSideBySideNavArguments( - args: List +private fun rememberListDetailNavArguments( + args: List, + isDetailPane: (T) -> Boolean, ): Pair, Map>> = remember(args) { val primary = mutableListOf() val secondaryLookup = mutableMapOf>() val secondary = mutableListOf() for (arg in args) { - val screen = arg.screen when { - screen is SecondaryScreen -> { + isDetailPane(arg) -> { secondary += arg } else -> { diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt index 4238900265..144052abeb 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt @@ -3,6 +3,7 @@ package com.slack.circuit.sample.navigation import com.slack.circuit.foundation.Circuit +import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory fun buildCircuitForTabs(tabs: Collection): Circuit { return Circuit.Builder() @@ -16,6 +17,6 @@ fun buildCircuitForTabs(tabs: Collection): Circuit { addPresenterFactory(DetailPresenter.Factory) addUiFactory(DetailUiFactory) } - .setAnimatedNavDecoratorFactory(CrossFadeNavDecoratorFactory()) + .setAnimatedNavDecoratorFactory(GestureNavigationDecorationFactory()) .build() } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 440fcfa77c..caf6d9f041 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.State -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment @@ -53,6 +52,7 @@ import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionLayout +import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult import com.slack.circuitx.navigation.intercepting.NavigationInterceptor @@ -112,17 +112,15 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> NavigationInterceptor { override fun resetRoot( newRoot: Screen, - options: Navigator.StateOptions + options: Navigator.StateOptions, ): InterceptedResetRootResult { return when (newRoot) { - is PrimaryScreen, - is SecondaryScreen -> { + is TabScreen, + is DetailScreen -> { Skipped } else -> { - eventSink.value( - ContentEvent.OnNavEvent(NavEvent.ResetRoot(newRoot, options)) - ) + eventSink.value(ContentEvent.OnNavEvent(NavEvent.ResetRoot(newRoot, options))) SuccessConsumed } } @@ -130,8 +128,8 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> override fun goTo(screen: Screen): InterceptedGoToResult { return when (screen) { - is PrimaryScreen, - is SecondaryScreen -> { + is TabScreen, + is DetailScreen -> { Skipped } else -> { @@ -155,7 +153,7 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen val interceptingNavigator = rememberInterceptingNavigator( navigator = contentNavigator, - interceptors = listOf(adaptiveNavState, contentInterceptor), + interceptors = listOf(contentInterceptor), ) Scaffold( @@ -174,12 +172,12 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen backStack = backStack, modifier = Modifier.padding(innerPadding).fillMaxSize(), decoration = - remember(circuit.animatedScreenTransforms, interceptingNavigator) { + remember(circuit.animatedScreenTransforms, circuit.animatedNavDecoratorFactory) { AdaptiveNavDecoration( screenTransforms = circuit.animatedScreenTransforms, - adaptiveNavState = adaptiveNavState, - backgroundColor = { MaterialTheme.colorScheme.background }, - onPop = { interceptingNavigator.pop() }, + normalDecoratorFactory = circuit.animatedNavDecoratorFactory, + detailPaneDecoratorFactory = GestureNavigationDecorationFactory(), + isDetailPane = { it.screen is DetailScreen }, ) }, ) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/CrossFadeNavDecorator.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/CrossFadeNavDecorator.kt deleted file mode 100644 index 668eb902bf..0000000000 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/CrossFadeNavDecorator.kt +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (C) 2025 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.sample.navigation - -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import com.slack.circuit.backstack.NavArgument -import com.slack.circuit.foundation.NavigatorDefaults -import com.slack.circuit.foundation.NavigatorDefaults.DefaultDecorator.DefaultAnimatedState -import com.slack.circuit.foundation.animation.AnimatedNavDecorator -import com.slack.circuit.foundation.animation.AnimatedNavEvent -import com.slack.circuit.foundation.animation.AnimatedNavState - -data class CrossFadeNavDecoratorFactory(val durationMillis: Int = 300) : - AnimatedNavDecorator.Factory { - override fun create(): AnimatedNavDecorator = - CrossFadeNavDecorator(durationMillis) -} - -class CrossFadeNavDecorator(private val durationMillis: Int) : - AnimatedNavDecorator> by NavigatorDefaults.DefaultDecorator() { - - override fun AnimatedContentTransitionScope.transitionSpec( - animatedNavEvent: AnimatedNavEvent - ): ContentTransform { - return fadeIn(tween(durationMillis)) togetherWith fadeOut(tween(durationMillis)) - } -} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt index 613ade8568..df008a3c5c 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt @@ -32,7 +32,7 @@ import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionScope -@Parcelize data class DetailScreen(val primary: TabScreen) : SecondaryScreen +@Parcelize data class DetailScreen(val primary: TabScreen) : Screen data class DetailState( val label: String, diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt new file mode 100644 index 0000000000..b77e1dbdde --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt @@ -0,0 +1,60 @@ +package com.slack.circuit.sample.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.setValue +import com.slack.circuit.retained.rememberRetainedSaveable +import com.slack.circuit.runtime.Navigator.StateOptions +import com.slack.circuit.runtime.screen.PopResult +import com.slack.circuit.runtime.screen.Screen +import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult +import com.slack.circuitx.navigation.intercepting.InterceptedPopResult +import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.Skipped +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.SuccessConsumed + +@Composable +fun rememberAdaptiveNavState(): AdaptiveNavState { + return rememberRetainedSaveable(saver = AdaptiveNavState.Saver) { AdaptiveNavState() } +} + +class AdaptiveNavState : NavigationInterceptor { + + var isOpen by mutableStateOf(false) + private set + + fun close() { + isOpen = false + } + + override fun resetRoot(newRoot: Screen, options: StateOptions): InterceptedResetRootResult { + isOpen = false + return Skipped + } + + override fun goTo(screen: Screen): InterceptedGoToResult { + isOpen = false + return Skipped + } + + override fun pop(peekBackStack: List, result: PopResult?): InterceptedPopResult { + // && peekBackStack.first() is SecondaryScreen) { + return if (!isOpen && peekBackStack.size == 2) { + isOpen = true + SuccessConsumed + } else Skipped + } + + object Saver : androidx.compose.runtime.saveable.Saver { + override fun SaverScope.save(value: AdaptiveNavState): Boolean { + return value.isOpen + } + + override fun restore(value: Boolean): AdaptiveNavState { + return AdaptiveNavState().apply { isOpen = value } + } + } +} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt index 4aff92624a..3d3ccf60d9 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt @@ -33,10 +33,11 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection -import com.slack.circuit.backstack.NavArgument +import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedNavEvent import com.slack.circuit.foundation.animation.AnimatedNavState +import com.slack.circuit.runtime.Navigator import com.slack.circuitx.gesturenavigation.GestureNavTransitionHolder import com.slack.circuitx.gesturenavigation.PredictiveBackNavigationDecorator import kotlin.math.roundToInt @@ -45,6 +46,7 @@ class SwipeableGestureNavigationDecorator( private val adaptiveNavState: AdaptiveNavState, private val enterOffsetFraction: Float = 0.25f, private val swipeThreshold: Float = 0.4f, + private val isDetailPane: (NavArgument) -> Boolean, private val onBackInvoked: () -> Unit, ) : PredictiveBackNavigationDecorator(onBackInvoked) { @@ -85,9 +87,7 @@ class SwipeableGestureNavigationDecorator( ) { val isOpenedScreen = - targetState.backStackDepth == 2 && - targetState.top.screen is SecondaryScreen && - adaptiveNavState.isOpen + targetState.backStackDepth == 2 && isDetailPane(targetState.top) && adaptiveNavState.isOpen val canRestoreOpenScreen = targetState.backStackDepth == 1 && adaptiveNavState.isOpen val swipeEnabled = targetState.backStackDepth > 1 || canRestoreOpenScreen @@ -139,14 +139,14 @@ class SwipeableGestureNavigationDecorator( private val adaptiveNavState: AdaptiveNavState, private val enterOffsetFraction: Float = 0.25f, private val swipeThreshold: Float = 0.4f, - private val onBackInvoked: () -> Unit, ) : AnimatedNavDecorator.Factory { - override fun create(): AnimatedNavDecorator { + override fun create(navigator: Navigator): AnimatedNavDecorator { return SwipeableGestureNavigationDecorator( adaptiveNavState = adaptiveNavState, enterOffsetFraction = enterOffsetFraction, swipeThreshold = swipeThreshold, - onBackInvoked = onBackInvoked, + onBackInvoked = { navigator.pop() }, + isDetailPane = { it.screen is DetailScreen }, ) } } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index 3a6ead7d34..f3f3219c97 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -37,7 +37,7 @@ import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedSco import kotlin.reflect.KClass @Parcelize -sealed interface TabScreen : Screen, PrimaryScreen { +sealed interface TabScreen : Screen { val label: String @Parcelize data class Root(override val label: String = "Root") : TabScreen From 8df817e6dc8d8af8d7fe198e3f50f82c48632db0 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 21 Oct 2025 18:45:34 -0700 Subject: [PATCH 10/44] gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 8b50663db7..204d911de0 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,7 @@ fastlane/report.xml # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project # .swiftpm + +# AI Stuff +/.claude/settings.local.json +/CLAUDE.local.md \ No newline at end of file From e2fd5f0e09deb4fe4907cbe979cf8d5844ceff85 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Wed, 22 Oct 2025 18:48:59 -0700 Subject: [PATCH 11/44] retain pane position --- .../slack/circuit/foundation/NavDecoration.kt | 1 + .../foundation/NavigableCircuitContent.kt | 35 +++++---- .../navigation/AdaptiveNavDecoration.kt | 77 ++++++++++++------- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt index bdb8676a13..b09fa23d07 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -36,4 +36,5 @@ public interface NavDecoration { /** Argument provided to [NavDecoration] that exposes the underlying [Screen]. */ public interface NavArgument { public val screen: Screen + public val key: String } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index de957d56d1..e2c4bf9550 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -146,23 +146,21 @@ public fun NavigableCircuitContent( args = activeContentProviders, navigator = navigator, modifier = modifier, - { provider -> - val record = provider.record - - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val circuitProvidedValues = - remember(record) { circuitProvidedValues[record] }?.provideValues() - val providedLocals = - remember(values, circuitProvidedValues) { - (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() - } - CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { - provider.content(record, contentProviderState) + ) { provider -> + val record = provider.record + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val circuitProvidedValues = + remember(record) { circuitProvidedValues[record] }?.provideValues() + val providedLocals = + remember(values, circuitProvidedValues) { + (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() } - }, - ) + CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { + provider.content(record, contentProviderState) + } + } } } @@ -173,6 +171,9 @@ public class RecordContentProvider( internal val content: @Composable (R, ContentProviderState) -> Unit, ) : NavArgument { + override val key: String + get() = record.key + override val screen: Screen get() = record.screen @@ -309,7 +310,7 @@ private fun createRecordContent(onActive: () -> Unit, onDispose: () } /** The maximum radix available for conversion to and from strings. */ -private const val MaxSupportedRadix = 36 +public const val MaxSupportedRadix: Int = 36 private val Record.registryKey: String get() = "_registry_${key}" diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt index 70c65932cd..79c31748ed 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt @@ -21,8 +21,12 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.PredictiveBackHandler @@ -34,6 +38,8 @@ import com.slack.circuit.foundation.NavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.retained.rememberRetainedStateHolder import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen @@ -89,25 +95,28 @@ class AdaptiveNavDecoration( modifier: Modifier, content: @Composable (T) -> Unit, ) { - // Decorate the content layout based on the window size. - // - Wide enough show as two pane - // - Otherwise stack normally - val windowInfo = currentWindowAdaptiveInfo() - if (shouldUsePaneLayout(windowInfo)) { - PaneContent( - args = args, - windowInfo = windowInfo, - navigator = navigator, - modifier = modifier, - content = content, - ) - } else { - delegate.DecoratedContent( - args = args, - navigator = navigator, - modifier = modifier, - content = content, - ) + val retainedStateHolder = rememberRetainedStateHolder() + retainedStateHolder.RetainedStateProvider("adaptive-pane-${args.first().key}") { // per record? + // Decorate the content layout based on the window size. + // - Wide enough show as two pane + // - Otherwise stack normally + val windowInfo = currentWindowAdaptiveInfo() + if (shouldUsePaneLayout(windowInfo)) { + PaneContent( + args = args, + windowInfo = windowInfo, + navigator = navigator, + modifier = modifier, + content = content, + ) + } else { + delegate.DecoratedContent( + args = args, + navigator = navigator, + modifier = modifier, + content = content, + ) + } } } @@ -124,10 +133,18 @@ class AdaptiveNavDecoration( modifier: Modifier = Modifier, content: @Composable (T) -> Unit, ) { - val directive = remember(windowInfo) { calculatePaneScaffoldDirective(windowInfo) } val (primaryArgs, secondaryLookup) = rememberListDetailNavArguments(args, isDetailPane) val secondaryLookupTransition = updateTransition(secondaryLookup) delegate.DecoratedContent(primaryArgs, navigator, modifier) { primary -> + val directive = rememberRetained(windowInfo) { calculatePaneScaffoldDirective(windowInfo) } + val minPaneSize = 240.dp + val anchors = + listOf( + PaneExpansionAnchor.Offset.fromStart(minPaneSize), + PaneExpansionAnchor.Offset.fromStart(directive.defaultPanePreferredWidth), + PaneExpansionAnchor.Offset.fromEnd(minPaneSize), + ) + val secondaryArgs = with(secondaryLookupTransition) { currentState[primary] ?: targetState[primary] } @@ -137,18 +154,22 @@ class AdaptiveNavDecoration( val scaffoldState = remember { MutableThreePaneScaffoldState(scaffoldValue) } // todo Anchors vs Resizeable with minimums - val minPaneSize = 240.dp + var initialAnchorIndex by rememberRetained { mutableIntStateOf(-1) } val paneExpansionState = rememberPaneExpansionState( key = scaffoldValue.paneExpansionStateKey, - anchors = - listOf( - PaneExpansionAnchor.Offset.fromStart(minPaneSize), - PaneExpansionAnchor.Offset.fromStart(directive.defaultPanePreferredWidth), - PaneExpansionAnchor.Offset.fromEnd(directive.defaultPanePreferredWidth), - PaneExpansionAnchor.Offset.fromEnd(minPaneSize), - ), + anchors = anchors, + initialAnchoredIndex = initialAnchorIndex, ) + DisposableEffect(paneExpansionState.currentAnchor) { + initialAnchorIndex = anchors.indexOf(paneExpansionState.currentAnchor) + onDispose {} + } + LaunchedEffect(Unit) { + if (initialAnchorIndex != -1) { + paneExpansionState.animateTo(anchors[initialAnchorIndex]) + } + } LaunchedEffect(scaffoldValue) { scaffoldState.animateTo(scaffoldValue) } ListDetailPaneScaffold( modifier = Modifier.fillMaxSize(), From 0406398ef0521a444d24f600c3812882bfb1e041 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 24 Oct 2025 12:06:42 -0700 Subject: [PATCH 12/44] tidy up --- .../sample/navigation/ContentScreen.kt | 11 +- ...coration.kt => ListDetailNavDecoration.kt} | 222 +++++++++++++----- .../navigation/SwipeReturnNavDecoration.kt | 2 + 3 files changed, 172 insertions(+), 63 deletions(-) rename samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/{AdaptiveNavDecoration.kt => ListDetailNavDecoration.kt} (52%) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index caf6d9f041..031960070f 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -140,7 +141,11 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> } } -@OptIn(ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class) +@OptIn( + ExperimentalCircuitApi::class, + ExperimentalSharedTransitionApi::class, + ExperimentalMaterial3AdaptiveApi::class, +) @Composable fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElementTransitionLayout { val eventSink = rememberUpdatedState(state.eventSink) @@ -173,11 +178,11 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen modifier = Modifier.padding(innerPadding).fillMaxSize(), decoration = remember(circuit.animatedScreenTransforms, circuit.animatedNavDecoratorFactory) { - AdaptiveNavDecoration( + ListDetailNavDecoration( screenTransforms = circuit.animatedScreenTransforms, normalDecoratorFactory = circuit.animatedNavDecoratorFactory, detailPaneDecoratorFactory = GestureNavigationDecorationFactory(), - isDetailPane = { it.screen is DetailScreen }, + showInDetailPane = { it.screen is DetailScreen }, ) }, ) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ListDetailNavDecoration.kt similarity index 52% rename from samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt rename to samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ListDetailNavDecoration.kt index 79c31748ed..e5aeccfd22 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ListDetailNavDecoration.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.interaction.MutableInteractionSource @@ -16,6 +17,9 @@ import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.MutableThreePaneScaffoldState import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor +import androidx.compose.material3.adaptive.layout.PaneExpansionState +import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics @@ -23,6 +27,7 @@ import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -30,7 +35,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.PredictiveBackHandler +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.isUnspecified import androidx.window.core.layout.WindowWidthSizeClass import com.slack.circuit.foundation.DelicateCircuitFoundationApi import com.slack.circuit.foundation.NavArgument @@ -43,6 +53,7 @@ import com.slack.circuit.retained.rememberRetainedStateHolder import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.sample.navigation.ListDetailScaffoldStyle.Companion.defaultListDetailScaffoldStyle import kotlin.reflect.KClass import kotlinx.coroutines.CancellationException @@ -66,13 +77,22 @@ private val Primary = tertiary = PaneAdaptedValue.Hidden, ) +/** + * A [NavDecoration] that displays navigation content in a list-detail layout using Material 3's + * adaptive pane scaffold. + * + * On wide screens, shows a two-pane layout with list and detail panes. On narrow screens, falls + * back to the [normalDecoratorFactory]. + */ +@ExperimentalMaterial3AdaptiveApi @OptIn(ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class) -class AdaptiveNavDecoration( +class ListDetailNavDecoration( screenTransforms: Map, AnimatedScreenTransform>, normalDecoratorFactory: AnimatedNavDecorator.Factory, detailPaneDecoratorFactory: AnimatedNavDecorator.Factory, - private val isDetailPane: (NavArgument) -> Boolean, - private val shouldUsePaneLayout: (WindowAdaptiveInfo) -> Boolean = ::layoutSideBySide, + private val showInDetailPane: (NavArgument) -> Boolean, + private val listDetailScaffoldStyle: (Density, WindowAdaptiveInfo) -> ListDetailScaffoldStyle = + ::defaultListDetailScaffoldStyle, ) : NavDecoration { private val delegate = @@ -95,20 +115,23 @@ class AdaptiveNavDecoration( modifier: Modifier, content: @Composable (T) -> Unit, ) { - val retainedStateHolder = rememberRetainedStateHolder() - retainedStateHolder.RetainedStateProvider("adaptive-pane-${args.first().key}") { // per record? - // Decorate the content layout based on the window size. - // - Wide enough show as two pane - // - Otherwise stack normally - val windowInfo = currentWindowAdaptiveInfo() - if (shouldUsePaneLayout(windowInfo)) { - PaneContent( - args = args, - windowInfo = windowInfo, - navigator = navigator, - modifier = modifier, - content = content, - ) + val paneScaffoldStyle = + listDetailScaffoldStyle(LocalDensity.current, currentWindowAdaptiveInfo()) + // Decorate the content layout based on the window size. + // - Wide enough show as list detail panes + // - Otherwise stack normally + AnimatedContent(paneScaffoldStyle.shouldUsePaneLayout) { shouldUsePaneLayout -> + if (shouldUsePaneLayout) { + // todo I had expected the `PaneContent` retains to work with just the navigable_registry + rememberRetainedStateHolder().RetainedStateProvider("list-detail-${args.first().key}") { + ListDetailContent( + args = args, + navigator = navigator, + listDetailScaffoldStyle = paneScaffoldStyle, + modifier = modifier, + content = content, + ) + } } else { delegate.DecoratedContent( args = args, @@ -121,74 +144,51 @@ class AdaptiveNavDecoration( } @OptIn( - ExperimentalSharedTransitionApi::class, - ExperimentalMaterial3AdaptiveApi::class, - ExperimentalComposeUiApi::class, + ExperimentalMaterial3AdaptiveApi::class, // For ListDetailPaneScaffold + ExperimentalComposeUiApi::class, // For PredictiveBackHandler ) @Composable - private fun PaneContent( + private fun ListDetailContent( args: List, - windowInfo: WindowAdaptiveInfo, navigator: Navigator, + listDetailScaffoldStyle: ListDetailScaffoldStyle, modifier: Modifier = Modifier, content: @Composable (T) -> Unit, ) { - val (primaryArgs, secondaryLookup) = rememberListDetailNavArguments(args, isDetailPane) + val (primaryArgs, secondaryLookup) = rememberListDetailNavArguments(args, showInDetailPane) val secondaryLookupTransition = updateTransition(secondaryLookup) delegate.DecoratedContent(primaryArgs, navigator, modifier) { primary -> - val directive = rememberRetained(windowInfo) { calculatePaneScaffoldDirective(windowInfo) } - val minPaneSize = 240.dp - val anchors = - listOf( - PaneExpansionAnchor.Offset.fromStart(minPaneSize), - PaneExpansionAnchor.Offset.fromStart(directive.defaultPanePreferredWidth), - PaneExpansionAnchor.Offset.fromEnd(minPaneSize), - ) - val secondaryArgs = with(secondaryLookupTransition) { currentState[primary] ?: targetState[primary] } val hasSecondary = !secondaryArgs.isNullOrEmpty() - val singleSecondary = hasSecondary && secondaryArgs.size == 1 val scaffoldValue = if (hasSecondary) PrimarySecondary else Primary val scaffoldState = remember { MutableThreePaneScaffoldState(scaffoldValue) } - // todo Anchors vs Resizeable with minimums var initialAnchorIndex by rememberRetained { mutableIntStateOf(-1) } val paneExpansionState = rememberPaneExpansionState( key = scaffoldValue.paneExpansionStateKey, - anchors = anchors, + anchors = listDetailScaffoldStyle.anchors, initialAnchoredIndex = initialAnchorIndex, ) DisposableEffect(paneExpansionState.currentAnchor) { - initialAnchorIndex = anchors.indexOf(paneExpansionState.currentAnchor) + initialAnchorIndex = + listDetailScaffoldStyle.anchors.indexOf(paneExpansionState.currentAnchor) onDispose {} } LaunchedEffect(Unit) { - if (initialAnchorIndex != -1) { - paneExpansionState.animateTo(anchors[initialAnchorIndex]) + if (listDetailScaffoldStyle.anchors.isNotEmpty() && initialAnchorIndex != -1) { + paneExpansionState.animateTo(listDetailScaffoldStyle.anchors[initialAnchorIndex]) } } LaunchedEffect(scaffoldValue) { scaffoldState.animateTo(scaffoldValue) } ListDetailPaneScaffold( modifier = Modifier.fillMaxSize(), - directive = directive, + directive = listDetailScaffoldStyle.directive, scaffoldState = scaffoldState, paneExpansionState = paneExpansionState, - paneExpansionDragHandle = { state -> - val interactionSource = remember { MutableInteractionSource() } - VerticalDragHandle( - modifier = - Modifier.paneExpansionDraggable( - state = state, - minTouchTargetSize = LocalMinimumInteractiveComponentSize.current, - interactionSource = interactionSource, - semanticsProperties = state.defaultDragHandleSemantics(), - ), - interactionSource = interactionSource, - ) - }, + paneExpansionDragHandle = listDetailScaffoldStyle.dragHandle, listPane = { AnimatedPane(modifier = Modifier) { content(primary) } }, detailPane = { AnimatedPane { @@ -201,6 +201,7 @@ class AdaptiveNavDecoration( ) // Prevent the detailPaneDelegate from handling the back press + val singleSecondary = hasSecondary && secondaryArgs.size == 1 PredictiveBackHandler(enabled = singleSecondary) { progress -> try { progress.collect { backEvent -> @@ -215,18 +216,119 @@ class AdaptiveNavDecoration( } } -private fun layoutSideBySide(adaptiveInfo: WindowAdaptiveInfo): Boolean = - when (adaptiveInfo.windowSizeClass.windowWidthSizeClass) { - WindowWidthSizeClass.COMPACT -> false - WindowWidthSizeClass.MEDIUM -> true - WindowWidthSizeClass.EXPANDED -> true - else -> false +/** + * Configuration for the pane scaffold layout, including layout behavior, directives, resize + * anchors, and drag handle customization. + */ +@Stable +class ListDetailScaffoldStyle +@ExperimentalMaterial3AdaptiveApi +constructor( + val shouldUsePaneLayout: Boolean = false, + val directive: PaneScaffoldDirective = PaneScaffoldDirective.Default, + val anchors: List = emptyList(), + val dragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null, +) { + + companion object { + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + val DefaultDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit) = + { state -> + val interactionSource = remember { MutableInteractionSource() } + VerticalDragHandle( + modifier = + Modifier.paneExpansionDraggable( + state = state, + minTouchTargetSize = LocalMinimumInteractiveComponentSize.current, + interactionSource = interactionSource, + semanticsProperties = state.defaultDragHandleSemantics(), + ), + interactionSource = interactionSource, + ) + } + + /** + * Creates the default [ListDetailScaffoldStyle] based on window size class. Enables pane layout + * for medium and expanded windows, with drag handles and appropriate resize anchors. + */ + @ExperimentalMaterial3AdaptiveApi + @Stable + fun defaultListDetailScaffoldStyle( + density: Density, + windowInfo: WindowAdaptiveInfo, + ): ListDetailScaffoldStyle { + val directive = calculatePaneScaffoldDirective(windowInfo) + + val shouldUsePaneLayout = + when (windowInfo.windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> false + WindowWidthSizeClass.MEDIUM -> true + WindowWidthSizeClass.EXPANDED -> true + else -> false + } + + val minimumPaneSize = + when (windowInfo.windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.MEDIUM -> 240.dp + WindowWidthSizeClass.EXPANDED -> 300.dp + else -> Dp.Unspecified + } + + // todo Anchors vs Resizeable with minimums + val anchors = density.paneExpansionAnchors(windowInfo, directive, minimumPaneSize) + + return ListDetailScaffoldStyle( + shouldUsePaneLayout = shouldUsePaneLayout, + dragHandle = DefaultDragHandle, + directive = directive, + anchors = anchors, + ) + } + } +} + +/** + * Calculates pane expansion anchors for resizing, including minimum size, default width, and hinge + * positions on foldable devices. + */ +@ExperimentalMaterial3AdaptiveApi +private fun Density.paneExpansionAnchors( + windowInfo: WindowAdaptiveInfo, + directive: PaneScaffoldDirective, + minimumPaneSize: Dp, +): List = buildList { + if (minimumPaneSize.isSpecified) { + add(PaneExpansionAnchor.Offset.fromStart(minimumPaneSize)) + } + if (minimumPaneSize.isUnspecified || directive.defaultPanePreferredWidth > minimumPaneSize) { + add(PaneExpansionAnchor.Offset.fromStart(directive.defaultPanePreferredWidth)) + } + + windowInfo.windowPosture.hingeList.forEach { info -> + if (info.isVertical && info.isSeparating) { + val hingeOffset = info.bounds.center.x.toDp() + if (hingeOffset != minimumPaneSize || hingeOffset != directive.defaultPanePreferredWidth) { + add(PaneExpansionAnchor.Offset.fromStart(hingeOffset)) + } + } + } + if (minimumPaneSize.isSpecified) { + add(PaneExpansionAnchor.Offset.fromEnd(minimumPaneSize)) } + // Don't lock this to a single anchor + if (size == 1) { + clear() + } +} +/** + * Partitions navigation arguments into primary (list) and secondary (detail) panes based on the + * [showInDetailPane] predicate. + */ @Composable private fun rememberListDetailNavArguments( args: List, - isDetailPane: (T) -> Boolean, + showInDetailPane: (T) -> Boolean, ): Pair, Map>> = remember(args) { val primary = mutableListOf() @@ -234,7 +336,7 @@ private fun rememberListDetailNavArguments( val secondary = mutableListOf() for (arg in args) { when { - isDetailPane(arg) -> { + showInDetailPane(arg) -> { secondary += arg } else -> { diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt index b77e1dbdde..739f260ed5 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt @@ -1,3 +1,5 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation import androidx.compose.runtime.Composable From d7a70159c2acb59d0fc39dbd78e0e5337165b256 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 24 Oct 2025 18:19:23 -0700 Subject: [PATCH 13/44] figure out some slide over stuff --- ....kt => AdaptiveListDetailNavDecoration.kt} | 34 ++- .../sample/navigation/ContentScreen.kt | 23 +- .../SlideOverNavigationDecorator.kt | 289 ++++++++++++++++++ .../navigation/SwipeReturnNavDecoration.kt | 62 ---- .../SwipeableGestureNavigationDecorator.kt | 265 ---------------- 5 files changed, 324 insertions(+), 349 deletions(-) rename samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/{ListDetailNavDecoration.kt => AdaptiveListDetailNavDecoration.kt} (93%) create mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SlideOverNavigationDecorator.kt delete mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt delete mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ListDetailNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt similarity index 93% rename from samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ListDetailNavDecoration.kt rename to samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt index e5aeccfd22..811b679b64 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ListDetailNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt @@ -54,6 +54,8 @@ import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.sample.navigation.ListDetailScaffoldStyle.Companion.defaultListDetailScaffoldStyle +import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope import kotlin.reflect.KClass import kotlinx.coroutines.CancellationException @@ -77,6 +79,8 @@ private val Primary = tertiary = PaneAdaptedValue.Hidden, ) +object AdaptiveListDetailAnimatedScope : AnimatedScope + /** * A [NavDecoration] that displays navigation content in a list-detail layout using Material 3's * adaptive pane scaffold. @@ -86,7 +90,7 @@ private val Primary = */ @ExperimentalMaterial3AdaptiveApi @OptIn(ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class) -class ListDetailNavDecoration( +class AdaptiveListDetailNavDecoration( screenTransforms: Map, AnimatedScreenTransform>, normalDecoratorFactory: AnimatedNavDecorator.Factory, detailPaneDecoratorFactory: AnimatedNavDecorator.Factory, @@ -121,24 +125,26 @@ class ListDetailNavDecoration( // - Wide enough show as list detail panes // - Otherwise stack normally AnimatedContent(paneScaffoldStyle.shouldUsePaneLayout) { shouldUsePaneLayout -> - if (shouldUsePaneLayout) { - // todo I had expected the `PaneContent` retains to work with just the navigable_registry - rememberRetainedStateHolder().RetainedStateProvider("list-detail-${args.first().key}") { - ListDetailContent( + ProvideAnimatedTransitionScope(AdaptiveListDetailAnimatedScope, this) { + if (shouldUsePaneLayout) { + // todo I had expected the `PaneContent` retains to work with just the navigable_registry + rememberRetainedStateHolder().RetainedStateProvider("list-detail-${args.first().key}") { + ListDetailContent( + args = args, + navigator = navigator, + listDetailScaffoldStyle = paneScaffoldStyle, + modifier = modifier, + content = content, + ) + } + } else { + delegate.DecoratedContent( args = args, navigator = navigator, - listDetailScaffoldStyle = paneScaffoldStyle, modifier = modifier, content = content, ) } - } else { - delegate.DecoratedContent( - args = args, - navigator = navigator, - modifier = modifier, - content = content, - ) } } } @@ -326,7 +332,7 @@ private fun Density.paneExpansionAnchors( * [showInDetailPane] predicate. */ @Composable -private fun rememberListDetailNavArguments( +internal fun rememberListDetailNavArguments( args: List, showInDetailPane: (T) -> Boolean, ): Pair, Map>> = diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 031960070f..46bc6ab67d 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -53,7 +53,6 @@ import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionLayout -import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult import com.slack.circuitx.navigation.intercepting.NavigationInterceptor @@ -150,7 +149,6 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElementTransitionLayout { val eventSink = rememberUpdatedState(state.eventSink) val backStack = rememberSaveableBackStack(state.rootScreen) - val adaptiveNavState = rememberAdaptiveNavState() val contentInterceptor = remember { ContentInterceptor(eventSink) } val contentNavigator = rememberCircuitNavigator(backStack, onRootPop = {}, enableBackHandler = true) @@ -160,30 +158,39 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen navigator = contentNavigator, interceptors = listOf(contentInterceptor), ) - + val slideOverNavigator = rememberSlideOverNavState({ it is DetailScreen }, interceptingNavigator) Scaffold( modifier = modifier.testTag(ContentTags.TAG_SCAFFOLD).fillMaxSize(), bottomBar = { BottomTabRow( tabs = state.tabs, rootScreen = backStack.rootRecord?.screen, - onNavEvent = { interceptingNavigator.onNavEvent(it) }, + onNavEvent = { slideOverNavigator.onNavEvent(it) }, ) }, ) { innerPadding -> val circuit = requireNotNull(LocalCircuit.current) { "Need a circuit in a circuit" } NavigableCircuitContent( - navigator = interceptingNavigator, + navigator = slideOverNavigator, backStack = backStack, modifier = Modifier.padding(innerPadding).fillMaxSize(), decoration = remember(circuit.animatedScreenTransforms, circuit.animatedNavDecoratorFactory) { - ListDetailNavDecoration( + SlideOverNavDecoration( screenTransforms = circuit.animatedScreenTransforms, - normalDecoratorFactory = circuit.animatedNavDecoratorFactory, - detailPaneDecoratorFactory = GestureNavigationDecorationFactory(), + decoratorFactory = circuit.animatedNavDecoratorFactory, + slideOverNavState = slideOverNavigator, showInDetailPane = { it.screen is DetailScreen }, + backgroundColor = { MaterialTheme.colorScheme.background }, + scrimColor = { MaterialTheme.colorScheme.scrim }, ) + + // AdaptiveListDetailNavDecoration( + // screenTransforms = circuit.animatedScreenTransforms, + // normalDecoratorFactory = circuit.animatedNavDecoratorFactory, + // detailPaneDecoratorFactory = circuit.animatedNavDecoratorFactory, + // showInDetailPane = { it.screen is DetailScreen }, + // ) }, ) } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SlideOverNavigationDecorator.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SlideOverNavigationDecorator.kt new file mode 100644 index 0000000000..5aff124cb4 --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SlideOverNavigationDecorator.kt @@ -0,0 +1,289 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sample.navigation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.PredictiveBackHandler +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onLayoutRectChanged +import com.slack.circuit.foundation.NavArgument +import com.slack.circuit.foundation.NavDecoration +import com.slack.circuit.foundation.NavigatorDefaults +import com.slack.circuit.foundation.animation.AnimatedNavDecoration +import com.slack.circuit.foundation.animation.AnimatedNavDecorator +import com.slack.circuit.foundation.animation.AnimatedScreenTransform +import com.slack.circuit.runtime.ExperimentalCircuitApi +import com.slack.circuit.runtime.InternalCircuitApi +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.Navigator.StateOptions +import com.slack.circuit.runtime.screen.PopResult +import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation +import kotlin.coroutines.cancellation.CancellationException +import kotlin.reflect.KClass +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCircuitApi::class) +class SlideOverNavDecoration( + screenTransforms: Map, AnimatedScreenTransform>, + private val decoratorFactory: AnimatedNavDecorator.Factory, + private val slideOverNavState: SlideOverNavState, + private val showInDetailPane: (NavArgument) -> Boolean, + private val scrimColor: @Composable () -> Color, + private val backgroundColor: @Composable () -> Color, +) : NavDecoration { + + private val delegate = + AnimatedNavDecoration( + animatedScreenTransforms = screenTransforms, + decoratorFactory = decoratorFactory, + ) + + @OptIn( + ExperimentalComposeUiApi::class, + ExperimentalSharedTransitionApi::class, + InternalCircuitApi::class, + ) + @Composable + override fun DecoratedContent( + args: List, + navigator: Navigator, + modifier: Modifier, + content: @Composable ((T) -> Unit), + ) { + DisposableEffect(Unit) { + slideOverNavState.isDecorating = true + onDispose { slideOverNavState.isDecorating = false } + } + val (primaryArgs, secondaryLookup) = rememberListDetailNavArguments(args, showInDetailPane) + + val primary = primaryArgs.first() + val secondary = secondaryLookup[primary]?.singleOrNull() + val detailLookupTransition = updateTransition(primary to secondary) + val delegateArgs = if (secondary != null) primaryArgs else args + + delegate.DecoratedContent(delegateArgs, navigator, modifier) { base -> + val detail = + with(detailLookupTransition) { + val (currentBase, currentDetail) = currentState + val (targetBase, targetDetail) = targetState + when { + // todo Fix composition timings + currentBase == base -> currentDetail + targetBase == base -> targetDetail + else -> null + } + } + + // TODO State and somehow use the decorator animations + var maxOffset by remember { mutableFloatStateOf(0f) } + val offset = remember(detail?.key) { Animatable(0f) } + val scope = rememberCoroutineScope() + // todo Real drag handling with velocity etc + val dragState = rememberDraggableState { drag -> + scope.launch { offset.snapTo((offset.value + drag).coerceIn(0f, maxOffset)) } + } + var isDragging by remember { mutableStateOf(false) } + val isOffScreen = slideOverNavState.isOffScreen(detail?.screen) + var wasOffScreen by remember { mutableStateOf(isOffScreen) } + LaunchedEffect(isOffScreen) { + when { + isOffScreen -> offset.snapTo(maxOffset) + wasOffScreen -> offset.animateTo(0f) + else -> offset.snapTo(0f) + } + wasOffScreen = isOffScreen + } + + Box( + modifier = + Modifier.onLayoutRectChanged { maxOffset = it.width.toFloat() } + .draggable( + state = dragState, + enabled = isOffScreen, + orientation = Orientation.Horizontal, + onDragStarted = { isDragging = true }, + onDragStopped = { + if (it > maxOffset / 0.4f) { // swipeThreshold: Float = 0.4f, + offset.animateTo(maxOffset) + } else { + offset.animateTo(0f) + slideOverNavState.setOnScreen(detail?.screen) + } + isDragging = false + }, + ) + ) { + + // Base + val scrimColor = scrimColor() + Box( + modifier = + Modifier.graphicsLayer { + if (detail != null) { + val percentage = offset.value / maxOffset + val max = + (maxOffset * .25f).coerceAtLeast(0f) // enterOffsetFraction: Float = 0.25f, + translationX = -max + (percentage * max).coerceIn(0f, max) + } else { + translationX = 0f + } + } + .drawWithContent { + drawContent() + if (detail != null) { + val max = maxOffset * .6f // swipeThreshold: Float = 0.4f, + val percentage = (offset.value / max).coerceIn(0f, 1f) + val alpha = scrimColor.alpha - (scrimColor.alpha * percentage) + drawRect( + color = scrimColor, + size = size.copy(width = size.width * 1.25f), + alpha = alpha, + ) + } + } + .run { + if (detail != null && maxOffset == 0f) { + pointerInput(Unit) {} + } else this + } + ) { + content(base) + } + + // SlideOver + AnimatedVisibility( + visible = detail != null, + modifier = Modifier.fillMaxSize(), + enter = + if (isOffScreen) EnterTransition.None else NavigatorDefaults.forward.targetContentEnter, + exit = + if (isOffScreen) ExitTransition.None else NavigatorDefaults.backward.initialContentExit, + ) { + if (detail != null) { + ProvideAnimatedTransitionScope(Navigation, this) { + val backgroundColor = backgroundColor() + Box( + modifier = + Modifier.graphicsLayer { translationX = offset.value } + .background(backgroundColor) + .run { + if (maxOffset != 0f) { + pointerInput(Unit) {} + } else this + } + ) { + content(detail) + } + PredictiveBackHandler(enabled = !isOffScreen) { progress -> + try { + progress.collect { backEvent -> + val progress = backEvent.progress * maxOffset + offset.snapTo(progress) + } + navigator.pop() + } catch (_: CancellationException) { + offset.animateTo(0f) + } + } + } + } + } + } + } + } +} + +@Composable +fun rememberSlideOverNavState( + showInDetailPane: (Screen) -> Boolean, + delegate: Navigator, +): SlideOverNavState { + // todo saveable + return remember(delegate) { SlideOverNavState(showInDetailPane, delegate) } + .apply { this.showInDetailPane = showInDetailPane } +} + +class SlideOverNavState(var showInDetailPane: (Screen) -> Boolean, val delegate: Navigator) : + Navigator by delegate { + + var isDecorating by mutableStateOf(false) + internal set + + var isActive by mutableStateOf(false) + internal set + + private val isOffScreen = mutableStateMapOf() + + fun isOffScreen(screen: Screen?): Boolean { + return screen != null && isOffScreen[screen] == true + } + + fun setOnScreen(screen: Screen?) { + isOffScreen.remove(screen) + } + + override fun goTo(screen: Screen): Boolean { + val peekBackStack = peekBackStack() + val topScreen = peekBackStack.first() + return Snapshot.withMutableSnapshot { + if (isDecorating && isOffScreen(topScreen)) { + // Pop off the top one to replace it as we're not navigating from the detail screen + delegate.pop() + } + isOffScreen.remove(topScreen) + // Then go to the new screen + delegate.goTo(screen) + } + } + + override fun pop(result: PopResult?): Screen? { + val peekBackStack = peekBackStack() + val topScreen = peekBackStack.first() + val nextScreen = peekBackStack.getOrNull(1) + if (isDecorating && nextScreen != null) { + // Slide over the detail screen + if (!isOffScreen(topScreen) && showInDetailPane(topScreen) && !showInDetailPane(nextScreen)) { + isOffScreen[topScreen] = true + return null + } + setOnScreen(nextScreen) + } + setOnScreen(topScreen) + return delegate.pop(result) + } + + override fun resetRoot(newRoot: Screen, options: StateOptions): List { + setOnScreen(newRoot) + // todo restore is open state here? + return delegate.resetRoot(newRoot, options) + } +} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt deleted file mode 100644 index 739f260ed5..0000000000 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeReturnNavDecoration.kt +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (C) 2025 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.sample.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.runtime.setValue -import com.slack.circuit.retained.rememberRetainedSaveable -import com.slack.circuit.runtime.Navigator.StateOptions -import com.slack.circuit.runtime.screen.PopResult -import com.slack.circuit.runtime.screen.Screen -import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult -import com.slack.circuitx.navigation.intercepting.InterceptedPopResult -import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult -import com.slack.circuitx.navigation.intercepting.NavigationInterceptor -import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.Skipped -import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.SuccessConsumed - -@Composable -fun rememberAdaptiveNavState(): AdaptiveNavState { - return rememberRetainedSaveable(saver = AdaptiveNavState.Saver) { AdaptiveNavState() } -} - -class AdaptiveNavState : NavigationInterceptor { - - var isOpen by mutableStateOf(false) - private set - - fun close() { - isOpen = false - } - - override fun resetRoot(newRoot: Screen, options: StateOptions): InterceptedResetRootResult { - isOpen = false - return Skipped - } - - override fun goTo(screen: Screen): InterceptedGoToResult { - isOpen = false - return Skipped - } - - override fun pop(peekBackStack: List, result: PopResult?): InterceptedPopResult { - // && peekBackStack.first() is SecondaryScreen) { - return if (!isOpen && peekBackStack.size == 2) { - isOpen = true - SuccessConsumed - } else Skipped - } - - object Saver : androidx.compose.runtime.saveable.Saver { - override fun SaverScope.save(value: AdaptiveNavState): Boolean { - return value.isOpen - } - - override fun restore(value: Boolean): AdaptiveNavState { - return AdaptiveNavState().apply { isOpen = value } - } - } -} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt deleted file mode 100644 index 3d3ccf60d9..0000000000 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SwipeableGestureNavigationDecorator.kt +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright (C) 2025 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.sample.navigation - -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.gestures.DraggableState -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.layout -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.LayoutDirection -import com.slack.circuit.foundation.NavArgument -import com.slack.circuit.foundation.animation.AnimatedNavDecorator -import com.slack.circuit.foundation.animation.AnimatedNavEvent -import com.slack.circuit.foundation.animation.AnimatedNavState -import com.slack.circuit.runtime.Navigator -import com.slack.circuitx.gesturenavigation.GestureNavTransitionHolder -import com.slack.circuitx.gesturenavigation.PredictiveBackNavigationDecorator -import kotlin.math.roundToInt - -class SwipeableGestureNavigationDecorator( - private val adaptiveNavState: AdaptiveNavState, - private val enterOffsetFraction: Float = 0.25f, - private val swipeThreshold: Float = 0.4f, - private val isDetailPane: (NavArgument) -> Boolean, - private val onBackInvoked: () -> Unit, -) : PredictiveBackNavigationDecorator(onBackInvoked) { - - // Track popped zIndex so screens are layered correctly - private var zIndexDepth = 0f - - override fun AnimatedContentTransitionScope.transitionSpec( - animatedNavEvent: AnimatedNavEvent - ): ContentTransform { - - return when (animatedNavEvent) { - AnimatedNavEvent.GoTo -> { - slideInHorizontally(initialOffsetX = End) - .togetherWith( - slideOutHorizontally { width -> -(enterOffsetFraction * width).roundToInt() } - ) - } - AnimatedNavEvent.Pop -> { - slideInHorizontally { width -> -(enterOffsetFraction * width).roundToInt() } - .togetherWith( - if (adaptiveNavState.isOpen || showPrevious) ExitTransition.None - else slideOutHorizontally(targetOffsetX = End) - ) - .apply { targetContentZIndex = --zIndexDepth } - } - AnimatedNavEvent.RootReset -> { - zIndexDepth = 0f - // Simple Crossfade on reset - fadeIn() togetherWith fadeOut() - } - } - } - - @Composable - override fun AnimatedContentScope.Decoration( - targetState: GestureNavTransitionHolder, - innerContent: @Composable ((T) -> Unit), - ) { - - val isOpenedScreen = - targetState.backStackDepth == 2 && isDetailPane(targetState.top) && adaptiveNavState.isOpen - - val canRestoreOpenScreen = targetState.backStackDepth == 1 && adaptiveNavState.isOpen - val swipeEnabled = targetState.backStackDepth > 1 || canRestoreOpenScreen - - val dismissState = - rememberSwipeDismissState( - targetState.args.first(), - target = if (isOpenedScreen) Target.Close else Target.Open, - swipeThreshold = swipeThreshold, - onRestore = { - adaptiveNavState.close() - showPrevious = false - swipeProgress = 0f - }, - onDismissed = onBackInvoked, - ) - - LaunchedEffect(isOpenedScreen) { - if (isOpenedScreen) { - dismissState.offset = dismissState.maxWidth - swipeProgress = 1f - showPrevious = true - } - } - - if (swipeEnabled) { - LaunchedEffect(dismissState) { - snapshotFlow { dismissState.progress } - .collect { progress -> - showPrevious = progress > 0f - swipeProgress = if (showPrevious) progress else 0f - } - } - } - DraggableContent( - state = dismissState, - swipeEnabled = swipeEnabled, - content = { innerContent(targetState.args.first()) }, - modifier = - Modifier.run { - if (isOpenedScreen) { - pointerInput(Unit) {} - } else this - }, - ) - } - - class Factory( - private val adaptiveNavState: AdaptiveNavState, - private val enterOffsetFraction: Float = 0.25f, - private val swipeThreshold: Float = 0.4f, - ) : AnimatedNavDecorator.Factory { - override fun create(navigator: Navigator): AnimatedNavDecorator { - return SwipeableGestureNavigationDecorator( - adaptiveNavState = adaptiveNavState, - enterOffsetFraction = enterOffsetFraction, - swipeThreshold = swipeThreshold, - onBackInvoked = { navigator.pop() }, - isDetailPane = { it.screen is DetailScreen }, - ) - } - } -} - -private val End: (Int) -> Int = { it } - -/** Draggable content for gesture navigation. */ -@Composable -private fun DraggableContent( - state: SwipeDismissState, - modifier: Modifier = Modifier, - swipeEnabled: Boolean = true, - content: @Composable () -> Unit, -) { - Box( - modifier = - modifier - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - state.maxWidth = constraints.maxWidth.toFloat() - layout(placeable.width, placeable.height) { placeable.place(0, 0) } - } - .draggable( - state = state.draggableState, - orientation = Orientation.Horizontal, - enabled = swipeEnabled, - reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, - onDragStopped = { velocity -> state.onDragStopped(velocity) }, - ) - ) { - Box(modifier = Modifier.offset { IntOffset(x = state.offset.roundToInt(), y = 0) }) { - content() - } - } -} - -@Composable -private fun rememberSwipeDismissState( - key: Any?, - target: Target, - swipeThreshold: Float, - onRestore: () -> Unit, - onDismissed: () -> Unit, -): SwipeDismissState { - return remember(key, target, swipeThreshold) { SwipeDismissState(target, swipeThreshold) } - .apply { - this.onRestore = onRestore - this.onDismissed = onDismissed - } -} - -enum class Target { - Open, - Close, -} - -@Stable -private class SwipeDismissState(private val target: Target, private val swipeThreshold: Float) { - - var offset by mutableFloatStateOf(0f) - var maxWidth by mutableFloatStateOf(0f) - - var onRestore: () -> Unit = {} - var onDismissed: () -> Unit = {} - - val progress: Float by derivedStateOf { if (maxWidth == 0f) 0f else offset / maxWidth } - - val draggableState = DraggableState { delta -> - val newOffset = (offset + delta).coerceIn(0f, maxWidth) - val resistance = calculateResistance(newOffset) - offset = newOffset * resistance - } - - suspend fun onDragStopped(velocity: Float) { - when (target) { - Target.Open -> { - val thresholdValue = swipeThreshold * maxWidth - val shouldDismiss = offset >= thresholdValue || velocity > 1000f - val targetOffset = if (shouldDismiss) maxWidth else 0f - - draggableState.drag(MutatePriority.PreventUserInput) { - Animatable(offset).animateTo(targetOffset) { dragBy(value - offset) } - } - // Only trigger dismiss callback after animation completes - if (shouldDismiss && targetOffset == maxWidth) { - onDismissed() - } - } - Target.Close -> { - val thresholdValue = (1 - swipeThreshold) * maxWidth - val shouldClose = offset <= thresholdValue || velocity > 1000f - val targetOffset = if (shouldClose) 0f else maxWidth - - draggableState.drag(MutatePriority.PreventUserInput) { - Animatable(offset).animateTo(targetOffset) { dragBy(value - offset) } - } - // Only trigger restore callback after animation completes - if (shouldClose && targetOffset == 0f) { - onRestore() - } - } - } - } - - private fun calculateResistance(offset: Float): Float { - return if (offset > maxWidth) { - val overshoot = offset - maxWidth - val resistanceFactor = 0.1f - val resistance = overshoot * resistanceFactor - (maxWidth + resistance) / offset - } else { - 1f - } - } -} From 03855a7ea5de97cc16a81c7e0ca653f4ec235a5b Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 10:44:50 -0700 Subject: [PATCH 14/44] compose-jb `1.10.0-alpha03` --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 230f12ceea..0eb83d2039 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,8 +10,8 @@ atomicfu = "0.29.0" benchmark = "1.4.1" coil = "3.3.0" compose-hotReload = "1.0.0-rc02" -compose-jb = "1.9.1" -compose-jb-material3 = "1.9.0" +compose-jb = "1.10.0-alpha03" +compose-jb-material3 = "1.10.0-alpha03" compose-jb-material-icons-core = "1.7.3" dagger = "2.57.2" datastore = "1.1.7" From 0936ef82fdcea98251c8642e6318bcedf464c827 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 11:05:05 -0700 Subject: [PATCH 15/44] shared element changes --- .../slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt | 4 ++-- .../kotlin/com/slack/circuit/star/petlist/PetListScreen.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt index 2a2198c05c..6a10fc9d8e 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt @@ -4,7 +4,7 @@ package com.slack.circuit.star.petdetail import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.animatedSize +import androidx.compose.animation.SharedTransitionScope.PlaceholderSize.Companion.AnimatedSize import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.RemeasureToBounds import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.AnimationConstants @@ -228,7 +228,7 @@ private fun PhotoPager( Modifier.sharedBounds( sharedContentState = rememberSharedContentState(key = PetImageBoundsKey(id, page)), animatedVisibilityScope = animatedVisibilityScope, - placeHolderSize = animatedSize, + placeholderSize = AnimatedSize, resizeMode = RemeasureToBounds, enter = fadeIn(animationSpec = tween(durationMillis = 20, easing = EaseInExpo)), exit = fadeOut(animationSpec = tween(durationMillis = 20, easing = EaseOutExpo)), diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petlist/PetListScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petlist/PetListScreen.kt index 9510d9a819..bca7b6071b 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petlist/PetListScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petlist/PetListScreen.kt @@ -3,7 +3,7 @@ package com.slack.circuit.star.petlist import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.animatedSize +import androidx.compose.animation.SharedTransitionScope.PlaceholderSize.Companion.AnimatedSize import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.RemeasureToBounds import androidx.compose.animation.core.AnimationConstants import androidx.compose.animation.core.EaseInCubic @@ -460,7 +460,7 @@ private fun PetListGridItem( Modifier.sharedBounds( sharedContentState = rememberSharedContentState(key = PetImageBoundsKey(animal.id, 0)), animatedVisibilityScope = animatedScope, - placeHolderSize = animatedSize, + placeholderSize = AnimatedSize, resizeMode = RemeasureToBounds, enter = fadeIn(animationSpec = tween(durationMillis = 80, easing = EaseInExpo)), exit = fadeOut(animationSpec = tween(durationMillis = 80, easing = EaseOutExpo)), From 4db0e3d83a04619b13bcca5cd3eb49ac70bd84fd Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 11:05:21 -0700 Subject: [PATCH 16/44] use the common `@Preview` --- .../circuit/sample/counter/desktop/DesktopCounterCircuit.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/counter/apps/src/jvmMain/kotlin/com/slack/circuit/sample/counter/desktop/DesktopCounterCircuit.kt b/samples/counter/apps/src/jvmMain/kotlin/com/slack/circuit/sample/counter/desktop/DesktopCounterCircuit.kt index db0b68cdc0..ff535df963 100644 --- a/samples/counter/apps/src/jvmMain/kotlin/com/slack/circuit/sample/counter/desktop/DesktopCounterCircuit.kt +++ b/samples/counter/apps/src/jvmMain/kotlin/com/slack/circuit/sample/counter/desktop/DesktopCounterCircuit.kt @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.counter.desktop -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,6 +29,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowState From 37701b8eb8b489232a793c7d9e6710afb7afd412 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 11:10:54 -0700 Subject: [PATCH 17/44] Add `navigationevent` --- gradle/libs.versions.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0eb83d2039..ed82780047 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,8 +11,9 @@ benchmark = "1.4.1" coil = "3.3.0" compose-hotReload = "1.0.0-rc02" compose-jb = "1.10.0-alpha03" -compose-jb-material3 = "1.10.0-alpha03" compose-jb-material-icons-core = "1.7.3" +compose-jb-material3 = "1.10.0-alpha03" +compose-navigationevent-jb = "1.0.0-alpha02" dagger = "2.57.2" datastore = "1.1.7" detekt = "1.23.8" @@ -120,6 +121,7 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v compose-material-icons = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "compose-jb-material-icons-core" } compose-material-material = { module = "org.jetbrains.compose.material:material", version.ref = "compose-jb" } compose-material-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-jb-material3" } +compose-navigationevent = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "compose-navigationevent-jb" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-jb" } compose-runtime-saveable = { module = "org.jetbrains.compose.runtime:runtime-saveable", version.ref = "compose-jb" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-jb" } From 48ff1049ab821adb2cda5209626684b532211fc4 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 13:41:00 -0700 Subject: [PATCH 18/44] Make deprecated `BackHandler` work for now --- .../foundation/NavigatorBackHandlerTest.kt | 9 ++- .../slack/circuit/foundation/NavigatorImpl.kt | 1 + .../PredictiveBackNavigationDecorator.kt | 2 +- .../gesturenavigation/BackNavigationTest.kt | 2 + circuitx/navigation/build.gradle.kts | 3 +- .../intercepting/InterceptingNavigator.kt | 1 + .../intercepting/NavigatorBackHandlerTest.kt | 60 ++++++++++++------- .../circuitx/overlays/BottomSheetOverlay.kt | 1 + .../circuitx/overlays/FullScreenOverlay.kt | 1 + 9 files changed, 54 insertions(+), 26 deletions(-) diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt index 5b5a61dfeb..5e1fc68226 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt @@ -6,6 +6,9 @@ import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.test.assertTextEquals @@ -76,10 +79,11 @@ class NavigatorBackHandlerTest { @Test fun nestedAndroidNavigatorRootPopBackHandler() { val circuit = createTestCircuit(rememberType = TestCountPresenter.RememberType.Standard) - var outerBackCount = 0 + var outerBackCount by mutableStateOf(0) lateinit var navigator: Navigator composeTestRule.setContent { CircuitCompositionLocals(circuit) { + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) navigator = rememberCircuitNavigator(backStack = backStack) @@ -104,10 +108,11 @@ class NavigatorBackHandlerTest { @Test fun nestedAndroidNavigatorRootDispatchedBackHandler() { val circuit = createTestCircuit(rememberType = TestCountPresenter.RememberType.Standard) - var outerBackCount = 0 + var outerBackCount by mutableStateOf(0) lateinit var navigator: Navigator composeTestRule.setContent { CircuitCompositionLocals(circuit) { + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) navigator = rememberCircuitNavigator(backStack = backStack) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt index 6a37fd67d3..8e341cbf19 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt @@ -74,6 +74,7 @@ public fun rememberCircuitNavigator( } var hasPendingRootPop by remember(hasScreenChanged) { mutableStateOf(false) } var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler( enabled = enableBackHandler && enableRootBackHandler && backStack.size > 1, onBack = { diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt index cc12217c3b..0f92b9e3a8 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt @@ -114,7 +114,7 @@ private fun BackHandler( val lastOnBackProgress by rememberUpdatedState(onBackProgress) val lastOnBackCancelled by rememberUpdatedState(onBackCancelled) val lastOnBackInvoked by rememberUpdatedState(onBackInvoked) - + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler PredictiveBackHandler( enabled = true, onBack = { progress -> diff --git a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt index 86c4a57238..26d01024cb 100644 --- a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt +++ b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt @@ -90,6 +90,7 @@ class BackNavigationTest { ) } else { navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler { navigator.pop() } } NavigableCircuitContent( @@ -118,6 +119,7 @@ fun createTestBackCircuit( fun TestBackContent(state: TestState, modifier: Modifier = Modifier) { TestContent(state, modifier) if (state.label.contains("root", true)) { + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler {} } } diff --git a/circuitx/navigation/build.gradle.kts b/circuitx/navigation/build.gradle.kts index d88f8a5615..aa4b0fc506 100644 --- a/circuitx/navigation/build.gradle.kts +++ b/circuitx/navigation/build.gradle.kts @@ -42,9 +42,10 @@ kotlin { } commonTest { dependencies { - implementation(libs.lifecycle.runtime.compose) + implementation(libs.compose.navigationevent) implementation(libs.coroutines.test) implementation(libs.kotlin.test) + implementation(libs.lifecycle.runtime.compose) implementation(libs.molecule.runtime) implementation(libs.testing.assertk) implementation(libs.turbine) diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt index 43aa0d0d22..048ef0694a 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt @@ -66,6 +66,7 @@ public fun rememberInterceptingNavigator( } var hasPendingRootPop by remember(hasScreenChanged) { mutableStateOf(false) } var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enableRootBackHandler) { // Root pop check to prevent an infinite loop if this is used with the Android variant of // rememberCircuitNavigator as that calls `OnBackPressedDispatcher.onBackPressed`. We need to diff --git a/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt b/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt index 01d006ced1..3a3d59d548 100644 --- a/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt +++ b/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt @@ -3,10 +3,13 @@ package com.slack.circuitx.navigation.intercepting import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.backhandler.BackGestureDispatcher +import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.backhandler.BackHandler -import androidx.compose.ui.backhandler.LocalBackGestureDispatcher +import androidx.compose.ui.backhandler.LocalCompatNavigationEventDispatcherOwner import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -15,6 +18,11 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigationevent.DirectNavigationEventInput +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEvent.Companion.EDGE_LEFT +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent @@ -34,25 +42,25 @@ class NavigatorBackHandlerTest { @get:Rule val composeTestRule = createComposeRule() - @OptIn(ExperimentalComposeUiApi::class) + @OptIn(ExperimentalComposeUiApi::class, InternalComposeUiApi::class) @Test fun nestedNavigatorRootPopBackHandler() { + val dispatcher = BackDispatcher() val circuit = createTestCircuit(rememberType = TestCountPresenter.RememberType.Standard) - var outerBackCount = 0 + var outerBackCount by mutableStateOf(0) lateinit var navigator: Navigator composeTestRule.setContent { - @Suppress("DEPRECATION") CompositionLocalProvider( - LocalBackGestureDispatcher provides BackDispatcher, + LocalCompatNavigationEventDispatcherOwner provides dispatcher, LocalLifecycleOwner provides StartedLifecycleOwner, ) { CircuitCompositionLocals(circuit) { - BackHandler(enabled = true) { outerBackCount++ } + @Suppress("DEPRECATION") BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val circuitNavigator = rememberCircuitNavigator( backStack = backStack, - onRootPop = { BackDispatcher.onBack() }, + onRootPop = { dispatcher.onBack() }, enableBackHandler = true, ) navigator = rememberInterceptingNavigator(circuitNavigator) @@ -74,24 +82,26 @@ class NavigatorBackHandlerTest { } } - @OptIn(ExperimentalComposeUiApi::class) + @OptIn(ExperimentalComposeUiApi::class, InternalComposeUiApi::class) @Test fun nestedNavigatorRootDispatchedBackHandler() { + val dispatcher = BackDispatcher() val circuit = createTestCircuit(rememberType = TestCountPresenter.RememberType.Standard) - var outerBackCount = 0 + var outerBackCount by mutableStateOf(0) lateinit var navigator: Navigator composeTestRule.setContent { CompositionLocalProvider( - LocalBackGestureDispatcher provides BackDispatcher, + LocalCompatNavigationEventDispatcherOwner provides dispatcher, LocalLifecycleOwner provides StartedLifecycleOwner, ) { CircuitCompositionLocals(circuit) { + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val circuitNavigator = rememberCircuitNavigator( backStack = backStack, - onRootPop = { BackDispatcher.onBack() }, + onRootPop = { dispatcher.onBack() }, enableBackHandler = true, ) navigator = rememberInterceptingNavigator(circuitNavigator) @@ -107,27 +117,33 @@ class NavigatorBackHandlerTest { onNodeWithTag(TAG_GO_NEXT).performClick() onNodeWithTag(TAG_LABEL).assertTextEquals("C") // Back through the onRootPop into the back handler - BackDispatcher.onBack() + dispatcher.onBack() onNodeWithTag(TAG_LABEL).assertTextEquals("B") - BackDispatcher.onBack() + dispatcher.onBack() onNodeWithTag(TAG_LABEL).assertTextEquals("A") - BackDispatcher.onBack() + dispatcher.onBack() waitForIdle() assertEquals(1, outerBackCount) } } } -@OptIn(ExperimentalComposeUiApi::class) -private val BackDispatcher = - object : BackGestureDispatcher() { +private class BackDispatcher : NavigationEventDispatcherOwner { - fun onBack() { - activeListener?.onStarted() - activeListener?.onCompleted() - } + private val input = DirectNavigationEventInput() + override val navigationEventDispatcher: NavigationEventDispatcher = NavigationEventDispatcher() + + init { + navigationEventDispatcher.addInput(input) } + fun onBack() { + input.backStarted(NavigationEvent(swipeEdge = EDGE_LEFT)) + input.backProgressed(NavigationEvent(swipeEdge = EDGE_LEFT, progress = 0.5f)) + input.backCompleted() + } +} + private val StartedLifecycleOwner = object : Lifecycle(), LifecycleOwner { override val lifecycle: Lifecycle = this diff --git a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt index 4c5700c9b2..5cbcd0acde 100644 --- a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt +++ b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt @@ -147,6 +147,7 @@ private constructor( ModalBottomSheet( content = { val coroutineScope = rememberStableCoroutineScope() + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enabled = sheetState.isVisible) { coroutineScope .launch { sheetState.hide() } diff --git a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt index a7a2491d91..584b9c47c2 100644 --- a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt +++ b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt @@ -63,6 +63,7 @@ internal class FullScreenOverlay( val dispatchingNavigator = remember { DispatchingOverlayNavigator(screen, navigator, callbacks::onFinish) } + @Suppress("DEPRECATION") // Migrate to NavigationEventHandler PredictiveBackHandler(enabled = true) { progress -> try { progress.collect { transitionController.seek(it.progress) } From e9eb27589b37aa67f92b071372c4b2d63b94b929 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 15:37:21 -0700 Subject: [PATCH 19/44] baseline --- .../androidReleaseRuntimeClasspath.txt | 16 ++++++++++++++++ backstack/dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 16 ++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 16 ++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 2 ++ .../dependencies/jvmRuntimeClasspath.txt | 3 +++ .../androidReleaseRuntimeClasspath.txt | 16 ++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 16 ++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 16 ++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 16 ++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 18 ++++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 16 ++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 7 +++++++ .../androidReleaseRuntimeClasspath.txt | 18 ++++++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 9 +++++++++ 22 files changed, 241 insertions(+) diff --git a/backstack/dependencies/androidReleaseRuntimeClasspath.txt b/backstack/dependencies/androidReleaseRuntimeClasspath.txt index 4ff4474ae2..1844778022 100644 --- a/backstack/dependencies/androidReleaseRuntimeClasspath.txt +++ b/backstack/dependencies/androidReleaseRuntimeClasspath.txt @@ -12,6 +12,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -32,12 +34,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -50,6 +57,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -58,13 +68,19 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.annotation-internal:annotation org.jetbrains.compose.collection-internal:collection org.jetbrains.compose.runtime:runtime-saveable diff --git a/backstack/dependencies/jvmRuntimeClasspath.txt b/backstack/dependencies/jvmRuntimeClasspath.txt index b65e693913..d6c4da4604 100644 --- a/backstack/dependencies/jvmRuntimeClasspath.txt +++ b/backstack/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -29,6 +33,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.annotation-internal:annotation org.jetbrains.compose.collection-internal:collection org.jetbrains.compose.runtime:runtime-desktop diff --git a/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt index 09686a5963..0b682655ba 100644 --- a/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt @@ -21,6 +21,8 @@ androidx.compose.foundation:foundation androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -41,12 +43,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -61,6 +68,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -69,7 +79,11 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose @@ -77,6 +91,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation org.jetbrains.compose.annotation-internal:annotation diff --git a/circuit-foundation/dependencies/jvmRuntimeClasspath.txt b/circuit-foundation/dependencies/jvmRuntimeClasspath.txt index d631f301e5..6250a9477b 100644 --- a/circuit-foundation/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-foundation/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -31,6 +35,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core-desktop org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation-desktop diff --git a/circuit-overlay/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-overlay/dependencies/androidReleaseRuntimeClasspath.txt index 3cc3485cd1..eb7074c38b 100644 --- a/circuit-overlay/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-overlay/dependencies/androidReleaseRuntimeClasspath.txt @@ -20,6 +20,8 @@ androidx.compose.foundation:foundation androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -40,12 +42,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -58,6 +65,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -66,13 +76,19 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation org.jetbrains.compose.annotation-internal:annotation diff --git a/circuit-overlay/dependencies/jvmRuntimeClasspath.txt b/circuit-overlay/dependencies/jvmRuntimeClasspath.txt index 3b7a61a903..3fbe52703f 100644 --- a/circuit-overlay/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-overlay/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -29,6 +33,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core-desktop org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation-desktop diff --git a/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt index 916eb6d420..72783c84c5 100644 --- a/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt @@ -68,6 +68,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.annotation-internal:annotation org.jetbrains.compose.collection-internal:collection org.jetbrains.compose.runtime:runtime-saveable diff --git a/circuit-retained/dependencies/jvmRuntimeClasspath.txt b/circuit-retained/dependencies/jvmRuntimeClasspath.txt index 782fe2e50e..d96ac0790c 100644 --- a/circuit-retained/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-retained/dependencies/jvmRuntimeClasspath.txt @@ -31,6 +31,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.annotation-internal:annotation org.jetbrains.compose.collection-internal:collection org.jetbrains.compose.runtime:runtime-desktop diff --git a/circuit-runtime-ui/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-runtime-ui/dependencies/androidReleaseRuntimeClasspath.txt index b81df0ef91..60d85c84df 100644 --- a/circuit-runtime-ui/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-runtime-ui/dependencies/androidReleaseRuntimeClasspath.txt @@ -12,6 +12,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -32,12 +34,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -50,6 +57,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -58,13 +68,19 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.annotation-internal:annotation org.jetbrains.compose.collection-internal:collection org.jetbrains.compose.runtime:runtime-saveable diff --git a/circuit-runtime-ui/dependencies/jvmRuntimeClasspath.txt b/circuit-runtime-ui/dependencies/jvmRuntimeClasspath.txt index b65e693913..d6c4da4604 100644 --- a/circuit-runtime-ui/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-runtime-ui/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -29,6 +33,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.annotation-internal:annotation org.jetbrains.compose.collection-internal:collection org.jetbrains.compose.runtime:runtime-desktop diff --git a/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt index 3cc3485cd1..eb7074c38b 100644 --- a/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt @@ -20,6 +20,8 @@ androidx.compose.foundation:foundation androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -40,12 +42,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -58,6 +65,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -66,13 +76,19 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation org.jetbrains.compose.annotation-internal:annotation diff --git a/circuit-shared-elements/dependencies/jvmRuntimeClasspath.txt b/circuit-shared-elements/dependencies/jvmRuntimeClasspath.txt index 3b7a61a903..3fbe52703f 100644 --- a/circuit-shared-elements/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-shared-elements/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -29,6 +33,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core-desktop org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation-desktop diff --git a/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt index 95e62f50da..8daf0cec1f 100644 --- a/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt @@ -21,6 +21,8 @@ androidx.compose.foundation:foundation androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -41,12 +43,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -61,6 +68,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -69,7 +79,11 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window app.cash.molecule:molecule-runtime-android app.cash.molecule:molecule-runtime app.cash.turbine:turbine-jvm @@ -81,6 +95,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation org.jetbrains.compose.annotation-internal:annotation diff --git a/circuit-test/dependencies/jvmRuntimeClasspath.txt b/circuit-test/dependencies/jvmRuntimeClasspath.txt index a0a4a22876..690ecb178c 100644 --- a/circuit-test/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-test/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -35,6 +39,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core-desktop org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation-desktop diff --git a/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt index 6f7eada97e..8f0bbc905b 100644 --- a/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt @@ -21,6 +21,8 @@ androidx.compose.foundation:foundation androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -41,12 +43,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -61,6 +68,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -69,7 +79,11 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose @@ -77,6 +91,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation org.jetbrains.compose.annotation-internal:annotation diff --git a/circuitx/effects/dependencies/jvmRuntimeClasspath.txt b/circuitx/effects/dependencies/jvmRuntimeClasspath.txt index d631f301e5..6250a9477b 100644 --- a/circuitx/effects/dependencies/jvmRuntimeClasspath.txt +++ b/circuitx/effects/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -31,6 +35,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core-desktop org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation-desktop diff --git a/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt index 0fb0f1fc5b..cc205e62e3 100644 --- a/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt @@ -25,6 +25,8 @@ androidx.compose.material:material-ripple androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -45,13 +47,20 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path +androidx.graphics:graphics-shapes-android +androidx.graphics:graphics-shapes androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-java8 androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -66,6 +75,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -74,7 +86,11 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose @@ -82,6 +98,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation org.jetbrains.compose.annotation-internal:annotation diff --git a/circuitx/gesture-navigation/dependencies/jvmRuntimeClasspath.txt b/circuitx/gesture-navigation/dependencies/jvmRuntimeClasspath.txt index d631f301e5..6250a9477b 100644 --- a/circuitx/gesture-navigation/dependencies/jvmRuntimeClasspath.txt +++ b/circuitx/gesture-navigation/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -31,6 +35,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core-desktop org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation-desktop diff --git a/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt index 6f7eada97e..8f0bbc905b 100644 --- a/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt @@ -21,6 +21,8 @@ androidx.compose.foundation:foundation androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -41,12 +43,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -61,6 +68,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -69,7 +79,11 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose @@ -77,6 +91,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation org.jetbrains.compose.annotation-internal:annotation diff --git a/circuitx/navigation/dependencies/jvmRuntimeClasspath.txt b/circuitx/navigation/dependencies/jvmRuntimeClasspath.txt index d631f301e5..6250a9477b 100644 --- a/circuitx/navigation/dependencies/jvmRuntimeClasspath.txt +++ b/circuitx/navigation/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -31,6 +35,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core-desktop org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation-desktop diff --git a/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt index 0fb0f1fc5b..cc205e62e3 100644 --- a/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt @@ -25,6 +25,8 @@ androidx.compose.material:material-ripple androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -45,13 +47,20 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path +androidx.graphics:graphics-shapes-android +androidx.graphics:graphics-shapes androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-java8 androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -66,6 +75,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -74,7 +86,11 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose @@ -82,6 +98,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation org.jetbrains.compose.annotation-internal:annotation diff --git a/circuitx/overlays/dependencies/jvmRuntimeClasspath.txt b/circuitx/overlays/dependencies/jvmRuntimeClasspath.txt index 2b289a7c57..b6e40ceaf1 100644 --- a/circuitx/overlays/dependencies/jvmRuntimeClasspath.txt +++ b/circuitx/overlays/dependencies/jvmRuntimeClasspath.txt @@ -6,9 +6,13 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime +androidx.graphics:graphics-shapes-desktop +androidx.graphics:graphics-shapes androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common androidx.lifecycle:lifecycle-runtime-compose-desktop @@ -19,6 +23,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -31,6 +37,9 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.savedstate:savedstate-compose-desktop +org.jetbrains.androidx.savedstate:savedstate-compose +org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core-desktop org.jetbrains.compose.animation:animation-core org.jetbrains.compose.animation:animation-desktop From 198fd5c666d42f91d9849327f06ac13ad4f4f549 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 15:45:51 -0700 Subject: [PATCH 20/44] lock files --- kotlin-js-store/package-lock.json | 10 ++++++++-- kotlin-js-store/wasm/package-lock.json | 20 ++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/kotlin-js-store/package-lock.json b/kotlin-js-store/package-lock.json index 88dff29601..3b837382ff 100644 --- a/kotlin-js-store/package-lock.json +++ b/kotlin-js-store/package-lock.json @@ -44,7 +44,7 @@ "packages/circuitx-overlays-test", "packages/counterbrowser", "packages/counterbrowser-test", - "packages_imported/skiko-js/0.9.22-2", + "packages_imported/skiko-js/0.9.30", "packages_imported/Kotlin-DateTime-library-kotlinx-datetime/0.7.1" ], "devDependencies": {} @@ -3245,7 +3245,7 @@ } }, "node_modules/skiko-js": { - "resolved": "packages_imported/skiko-js/0.9.22-2", + "resolved": "packages_imported/skiko-js/0.9.30", "link": true }, "node_modules/socket.io": { @@ -4091,6 +4091,12 @@ "packages_imported/skiko-js/0.9.22-2": { "name": "skiko-js", "version": "0.9.22-2", + "extraneous": true, + "devDependencies": {} + }, + "packages_imported/skiko-js/0.9.30": { + "name": "skiko-js", + "version": "0.9.30", "devDependencies": {} }, "packages_imported/skiko-js/0.9.4-2": { diff --git a/kotlin-js-store/wasm/package-lock.json b/kotlin-js-store/wasm/package-lock.json index 92acae436a..e4c5a1e6f2 100644 --- a/kotlin-js-store/wasm/package-lock.json +++ b/kotlin-js-store/wasm/package-lock.json @@ -46,8 +46,8 @@ "packages/counterbrowser-test", "packages/counterApp", "packages/counterApp-test", - "packages_imported/skiko-wasm-js/0.9.22-2", - "packages_imported/skiko-js-wasm-runtime/0.9.22-2", + "packages_imported/skiko-wasm-js/0.9.30", + "packages_imported/skiko-js-wasm-runtime/0.9.30", "packages_imported/Kotlin-DateTime-library-kotlinx-datetime-wasm-js/0.7.1" ], "devDependencies": {} @@ -214,11 +214,11 @@ "link": true }, "node_modules/skiko-js-wasm-runtime": { - "resolved": "packages_imported/skiko-js-wasm-runtime/0.9.22-2", + "resolved": "packages_imported/skiko-js-wasm-runtime/0.9.30", "link": true }, "node_modules/skiko-wasm-js": { - "resolved": "packages_imported/skiko-wasm-js/0.9.22-2", + "resolved": "packages_imported/skiko-wasm-js/0.9.30", "link": true }, "packages_imported/Kotlin-DateTime-library-kotlinx-datetime-wasm-js/0.6.0": { @@ -248,6 +248,12 @@ "packages_imported/skiko-js-wasm-runtime/0.9.22-2": { "name": "skiko-js-wasm-runtime", "version": "0.9.22-2", + "extraneous": true, + "devDependencies": {} + }, + "packages_imported/skiko-js-wasm-runtime/0.9.30": { + "name": "skiko-js-wasm-runtime", + "version": "0.9.30", "devDependencies": {} }, "packages_imported/skiko-js-wasm-runtime/0.9.4-2": { @@ -265,6 +271,12 @@ "packages_imported/skiko-wasm-js/0.9.22-2": { "name": "skiko-wasm-js", "version": "0.9.22-2", + "extraneous": true, + "devDependencies": {} + }, + "packages_imported/skiko-wasm-js/0.9.30": { + "name": "skiko-wasm-js", + "version": "0.9.30", "devDependencies": {} }, "packages_imported/skiko-wasm-js/0.9.4-2": { From da56fd829c952a98f560208dd7af94027e793e60 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 18:40:27 -0700 Subject: [PATCH 21/44] lifecycle-jb `2.10.0-alpha03` --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed82780047..d56a7d45a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ ksp = "2.2.20-2.0.4" ktfmt = "0.58" ktor = "3.3.1" leakcanary = "2.14" -lifecycle-jb = "2.9.5" +lifecycle-jb = "2.10.0-alpha03" mavenPublish = "0.34.0" molecule = "2.2.0" okhttp = "5.2.1" From b5d0d6c0bcb21711d92fe500388bba5fb152ef03 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 18:41:14 -0700 Subject: [PATCH 22/44] Use `withCompositionLocal` --- .../circuit/foundation/RetainedStateHolder.kt | 23 +++++------ .../circuit/foundation/SaveableStateHolder.kt | 4 +- .../internal/WithCompositionLocalProviders.kt | 40 ------------------- 3 files changed, 11 insertions(+), 56 deletions(-) delete mode 100644 circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithCompositionLocalProviders.kt diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt index 5112fd0631..5f5dd4fe12 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.key import androidx.compose.runtime.remember -import com.slack.circuit.foundation.internal.withCompositionLocalProvider +import androidx.compose.runtime.withCompositionLocal import com.slack.circuit.retained.CanRetainChecker import com.slack.circuit.retained.LocalRetainedStateRegistry import com.slack.circuit.retained.RetainedStateRegistry @@ -40,24 +40,19 @@ private class RetainedStateHolderImpl(private var canRetainChecker: CanRetainChe @Composable override fun RetainedStateProvider(key: String, content: @Composable (() -> T)): T { - return withCompositionLocalProvider(LocalRetainedStateRegistry provides registry) { + return withCompositionLocal(LocalRetainedStateRegistry provides registry) { key(key) { val entryCanRetainChecker = remember { EntryCanRetainChecker() } val childRegistry = rememberRetainedStateRegistry(key = key, canRetainChecker = entryCanRetainChecker) - withCompositionLocalProvider( - LocalRetainedStateRegistry provides childRegistry, - content = content, - ) - .also { - DisposableEffect(Unit) { - entryCheckers[key] = entryCanRetainChecker - onDispose { - registry.saveValue(key) - entryCheckers -= key - } - } + DisposableEffect(Unit) { + entryCheckers[key] = entryCanRetainChecker + onDispose { + registry.saveValue(key) + entryCheckers -= key } + } + withCompositionLocal(LocalRetainedStateRegistry provides childRegistry, content = content) } } } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/SaveableStateHolder.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/SaveableStateHolder.kt index 4327d9adcb..9f845bd80a 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/SaveableStateHolder.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/SaveableStateHolder.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.saveable.LocalSaveableStateRegistry import androidx.compose.runtime.saveable.SaveableStateRegistry import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable -import com.slack.circuit.foundation.internal.withCompositionLocalProvider +import androidx.compose.runtime.withCompositionLocal /** * This is a copy of [androidx.compose.runtime.saveable.SaveableStateHolder], tweaked so that @@ -53,7 +53,7 @@ private class SaveableStateHolderImpl( } val result = - withCompositionLocalProvider( + withCompositionLocal( LocalSaveableStateRegistry provides registryHolder.registry, content = content, ) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithCompositionLocalProviders.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithCompositionLocalProviders.kt deleted file mode 100644 index eb25318bd6..0000000000 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithCompositionLocalProviders.kt +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (C) 2024 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.foundation.internal - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.InternalComposeApi -import androidx.compose.runtime.ProvidedValue -import androidx.compose.runtime.currentComposer - -/** - * A slightly more efficient version of [withCompositionLocalProvider] that only accepts a single - * [value]. - */ -@Composable -@OptIn(InternalComposeApi::class) -internal fun withCompositionLocalProvider( - value: ProvidedValue<*>, - content: @Composable () -> R, -): R { - currentComposer.startProvider(value) - return content().also { currentComposer.endProvider() } -} - -/** - * A composable function that provides a [ProvidedValue] to the composition and returns the value - * returned by the [content] composable. This uses internal compose APIs to start and end providers - * around the [content] composable to avoid backward snapshot writes. - * - * @param values The [ProvidedValues][ProvidedValue] to provide. - * @param content The content to provide the value to. - */ -@Composable -@OptIn(InternalComposeApi::class) -internal fun withCompositionLocalProvider( - vararg values: ProvidedValue<*>, - content: @Composable () -> R, -): R { - currentComposer.startProviders(values) - return content().also { currentComposer.endProviders() } -} From 11828efcbdb9c9ebab0541e873db9024acdee8da Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 18:41:25 -0700 Subject: [PATCH 23/44] Use `scheduleFrameEndCallback` --- .../retained/LifecycleRetainedStateRegistry.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/circuit-retained/src/sharedMain/kotlin/com/slack/circuit/retained/LifecycleRetainedStateRegistry.kt b/circuit-retained/src/sharedMain/kotlin/com/slack/circuit/retained/LifecycleRetainedStateRegistry.kt index dee2e4a4a4..9754c028a3 100644 --- a/circuit-retained/src/sharedMain/kotlin/com/slack/circuit/retained/LifecycleRetainedStateRegistry.kt +++ b/circuit-retained/src/sharedMain/kotlin/com/slack/circuit/retained/LifecycleRetainedStateRegistry.kt @@ -5,9 +5,8 @@ package com.slack.circuit.retained import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.currentComposer import androidx.compose.runtime.remember -import androidx.compose.runtime.withFrameNanos import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.LifecycleStartEffect @@ -36,11 +35,15 @@ internal fun viewModelRetainedStateRegistry( vm.update(canRetainChecker) DisposableEffect(vm) { onDispose { vm.update(CanRetainChecker.Never) } } LifecycleStartEffect(vm) { onStopOrDispose { vm.saveAll() } } - LaunchedEffect(vm) { - withFrameNanos {} - // This resumes after the just-composed frame completes drawing. Any unclaimed values at this - // point can be assumed to be no longer used - vm.forgetUnclaimedValues() + val composer = currentComposer + DisposableEffect(vm) { + val cancellationHandle = + composer.scheduleFrameEndCallback { + // This resumes after the just-composed frame completes drawing. Any unclaimed values at + // this point can be assumed to be no longer used + vm.forgetUnclaimedValues() + } + onDispose { cancellationHandle.cancel() } } return vm } From bbeb297ed3ddad7ea5723afb37e7bdcb13efa0b5 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 18:46:58 -0700 Subject: [PATCH 24/44] Fix retained values getting dropped early --- .../com/slack/circuit/foundation/NavigableCircuitContent.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index f056020ec8..521ca5d10d 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.currentCompositeKeyHashCode @@ -401,8 +402,8 @@ public class ContentProviderState( private fun createRecordContent(onActive: () -> Unit, onDispose: () -> Unit) = movableContentOf> { record, contentProviderState -> with(contentProviderState) { - val lifecycle = - remember { MutableRecordLifecycle() }.apply { isActive = lastBackStack.topRecord == record } + val lifecycle = remember { MutableRecordLifecycle() } + SideEffect { lifecycle.isActive = lastBackStack.topRecord == record } saveableStateHolder.SaveableStateProvider(record.registryKey) { // Provides a RetainedStateRegistry that is maintained independently for each record while // the record exists in the back stack. From ced266f343306261a5ab2442de12d7eb6b4d18cb Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 30 Oct 2025 18:48:30 -0700 Subject: [PATCH 25/44] baseline --- .../androidReleaseRuntimeClasspath.txt | 14 ++++++++++++++ .../dependencies/jvmRuntimeClasspath.txt | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt index 72783c84c5..60d1c2ddec 100644 --- a/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt @@ -12,6 +12,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-android androidx.compose.runtime:runtime-annotation-android androidx.compose.runtime:runtime-annotation +androidx.compose.runtime:runtime-retain-android +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-android androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -32,12 +34,17 @@ androidx.core:core-ktx androidx.core:core-viewtree androidx.core:core androidx.customview:customview-poolingcontainer +androidx.documentfile:documentfile +androidx.dynamicanimation:dynamicanimation androidx.emoji2:emoji2 androidx.graphics:graphics-path androidx.interpolator:interpolator +androidx.legacy:legacy-support-core-utils androidx.lifecycle:lifecycle-common-jvm androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core-ktx androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-livedata androidx.lifecycle:lifecycle-process androidx.lifecycle:lifecycle-runtime-android androidx.lifecycle:lifecycle-runtime-compose-android @@ -52,6 +59,9 @@ androidx.lifecycle:lifecycle-viewmodel-ktx androidx.lifecycle:lifecycle-viewmodel-savedstate-android androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.loader:loader +androidx.localbroadcastmanager:localbroadcastmanager +androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android androidx.savedstate:savedstate-compose-android @@ -60,7 +70,11 @@ androidx.savedstate:savedstate-ktx androidx.savedstate:savedstate androidx.startup:startup-runtime androidx.tracing:tracing +androidx.transition:transition androidx.versionedparcelable:versionedparcelable +androidx.window:window-core-android +androidx.window:window-core +androidx.window:window com.google.guava:listenablefuture org.jetbrains.androidx.lifecycle:lifecycle-common org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose diff --git a/circuit-retained/dependencies/jvmRuntimeClasspath.txt b/circuit-retained/dependencies/jvmRuntimeClasspath.txt index d96ac0790c..b0423cf0f3 100644 --- a/circuit-retained/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-retained/dependencies/jvmRuntimeClasspath.txt @@ -6,6 +6,8 @@ androidx.collection:collection androidx.compose.runtime:runtime-annotation-jvm androidx.compose.runtime:runtime-annotation androidx.compose.runtime:runtime-desktop +androidx.compose.runtime:runtime-retain-desktop +androidx.compose.runtime:runtime-retain androidx.compose.runtime:runtime-saveable-desktop androidx.compose.runtime:runtime-saveable androidx.compose.runtime:runtime @@ -19,6 +21,8 @@ androidx.lifecycle:lifecycle-viewmodel-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel +androidx.navigationevent:navigationevent-desktop +androidx.navigationevent:navigationevent androidx.savedstate:savedstate-compose-desktop androidx.savedstate:savedstate-compose androidx.savedstate:savedstate-desktop @@ -66,6 +70,7 @@ org.jetbrains.kotlinx:kotlinx-coroutines-core org.jetbrains.kotlinx:kotlinx-serialization-bom org.jetbrains.kotlinx:kotlinx-serialization-core-jvm org.jetbrains.kotlinx:kotlinx-serialization-core +org.jetbrains.runtime:jbr-api org.jetbrains.skiko:skiko-awt org.jetbrains.skiko:skiko org.jetbrains:annotations From fd4d32aa434249e19b4b2284c0e2a6881a3e9db2 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 4 Nov 2025 10:49:55 -0800 Subject: [PATCH 26/44] Update to compose multi-platform 1.10.0-beta01 - Sorted the jetbrains versions together as they update together --- build.gradle.kts | 4 +--- gradle/libs.versions.toml | 49 +++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 948d8497fa..1788b08fd5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -124,11 +124,9 @@ allprojects { } val jvmTargetVersion = libs.versions.jvmTarget -val publishedJvmTargetVersion = libs.versions.publishedJvmTarget subprojects { - val isPublished = project.hasProperty("POM_ARTIFACT_ID") - val jvmTargetProject = if (isPublished) publishedJvmTargetVersion else jvmTargetVersion + val jvmTargetProject = jvmTargetVersion pluginManager.withPlugin("java") { configure { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d56a7d45a0..a97607e5dd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,27 +1,27 @@ [versions] +agp = "8.13.0" androidx-activity = "1.11.0" androidx-annotation = "1.9.1" androidx-appcompat = "1.7.1" androidx-browser = "1.9.0" androidx-compose = "1.9.4" -agp = "8.13.0" anvil = "0.4.1" atomicfu = "0.29.0" benchmark = "1.4.1" coil = "3.3.0" compose-hotReload = "1.0.0-rc02" -compose-jb = "1.10.0-alpha03" -compose-jb-material-icons-core = "1.7.3" -compose-jb-material3 = "1.10.0-alpha03" -compose-navigationevent-jb = "1.0.0-alpha02" dagger = "2.57.2" datastore = "1.1.7" detekt = "1.23.8" dokka = "2.1.0" eithernet = "2.0.0" +jb-compose = "1.10.0-beta01" +jb-compose-material-icons-core = "1.7.3" +jb-compose-material3 = "1.10.0-alpha04" +jb-compose-navigationevent = "1.0.0-beta01" +jb-lifecycle = "2.10.0-alpha04" jdk = "23" jvmTarget = "11" -publishedJvmTarget = "11" kct = "0.9.0" kotlin = "2.2.21" kotlinInject = "0.8.0" @@ -33,7 +33,6 @@ ksp = "2.2.20-2.0.4" ktfmt = "0.58" ktor = "3.3.1" leakcanary = "2.14" -lifecycle-jb = "2.10.0-alpha03" mavenPublish = "0.34.0" molecule = "2.2.0" okhttp = "5.2.1" @@ -54,7 +53,7 @@ agp-test = { id = "com.android.test", version.ref = "agp" } anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" } binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.18.1" } -compose = { id = "org.jetbrains.compose", version.ref = "compose-jb" } +compose = { id = "org.jetbrains.compose", version.ref = "jb-compose" } compose-hotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hotReload" } dependencyGuard = { id = "com.dropbox.dependency-guard", version = "0.5.0" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } @@ -117,21 +116,21 @@ coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version coil-test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" } # Compose Mutliplatform -compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-jb" } -compose-material-icons = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "compose-jb-material-icons-core" } -compose-material-material = { module = "org.jetbrains.compose.material:material", version.ref = "compose-jb" } -compose-material-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-jb-material3" } -compose-navigationevent = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "compose-navigationevent-jb" } -compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-jb" } -compose-runtime-saveable = { module = "org.jetbrains.compose.runtime:runtime-saveable", version.ref = "compose-jb" } -compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-jb" } -compose-ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-jb" } -compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "compose-jb" } -compose-ui-testing-junit = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose-jb" } -compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-jb" } -compose-ui-tooling-data = { module = "org.jetbrains.compose.ui:ui-tooling-data", version.ref = "compose-jb" } -compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-jb" } -compose-ui-util = { module = "org.jetbrains.compose.ui:ui-util", version.ref = "compose-jb" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "jb-compose" } +compose-material-icons = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "jb-compose-material-icons-core" } +compose-material-material = { module = "org.jetbrains.compose.material:material", version.ref = "jb-compose" } +compose-material-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "jb-compose-material3" } +compose-navigationevent = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "jb-compose-navigationevent" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jb-compose" } +compose-runtime-saveable = { module = "org.jetbrains.compose.runtime:runtime-saveable", version.ref = "jb-compose" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jb-compose" } +compose-ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "jb-compose" } +compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "jb-compose" } +compose-ui-testing-junit = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "jb-compose" } +compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "jb-compose" } +compose-ui-tooling-data = { module = "org.jetbrains.compose.ui:ui-tooling-data", version.ref = "jb-compose" } +compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "jb-compose" } +compose-ui-util = { module = "org.jetbrains.compose.ui:ui-util", version.ref = "jb-compose" } coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } @@ -185,8 +184,8 @@ leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", ve leakcanary-android-instrumentation = { module = "com.squareup.leakcanary:leakcanary-android-instrumentation", version.ref = "leakcanary" } # Lifecycle Mutliplatform -lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle-jb" } -lifecycle-viewModel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-jb" } +lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jb-lifecycle" } +lifecycle-viewModel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jb-lifecycle" } lints-compose = "com.slack.lint.compose:compose-lint-checks:1.4.2" From f921e810096bb371419cd7ed04ab63b6ffcc4af1 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 4 Nov 2025 13:51:45 -0800 Subject: [PATCH 27/44] Specify dependencies via version catalog --- circuitx/effects/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 1 + samples/counter/apps/build.gradle.kts | 6 +++--- samples/star/build.gradle.kts | 4 ++-- samples/star/coil-rule/build.gradle.kts | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/circuitx/effects/build.gradle.kts b/circuitx/effects/build.gradle.kts index 250ed57f26..eea31fa4ef 100644 --- a/circuitx/effects/build.gradle.kts +++ b/circuitx/effects/build.gradle.kts @@ -55,8 +55,8 @@ kotlin { } commonTest { dependencies { - implementation(compose.foundation) - implementation(compose.material3) + implementation(libs.compose.foundation) + implementation(libs.compose.material.material3) implementation(libs.coroutines.test) implementation(libs.kotlin.test) implementation(libs.molecule.runtime) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97607e5dd..42e33bda21 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -116,6 +116,7 @@ coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version coil-test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" } # Compose Mutliplatform +compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jb-compose" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "jb-compose" } compose-material-icons = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "jb-compose-material-icons-core" } compose-material-material = { module = "org.jetbrains.compose.material:material", version.ref = "jb-compose" } diff --git a/samples/counter/apps/build.gradle.kts b/samples/counter/apps/build.gradle.kts index 33abb0a029..e4af6a429c 100644 --- a/samples/counter/apps/build.gradle.kts +++ b/samples/counter/apps/build.gradle.kts @@ -57,9 +57,9 @@ kotlin { } wasmJsMain { dependencies { - implementation(compose.components.resources) - implementation(compose.ui) - implementation(compose.runtime) + implementation(libs.compose.components.resources) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) } } } diff --git a/samples/star/build.gradle.kts b/samples/star/build.gradle.kts index ce5c0c6131..a216c43936 100644 --- a/samples/star/build.gradle.kts +++ b/samples/star/build.gradle.kts @@ -48,6 +48,7 @@ kotlin { implementation(libs.coil) implementation(libs.coil.compose) implementation(libs.coil.network.ktor) + implementation(libs.compose.components.resources) implementation(libs.compose.foundation) implementation(libs.compose.material.material) implementation(libs.compose.material.material3) @@ -58,14 +59,13 @@ kotlin { implementation(libs.coroutines) implementation(libs.ksoup) implementation(libs.ktor.client) - implementation(libs.ktor.client.contentNegotiation) implementation(libs.ktor.client.auth) + implementation(libs.ktor.client.contentNegotiation) implementation(libs.ktor.serialization.json) implementation(libs.okio) implementation(libs.sqldelight.coroutines) implementation(libs.sqldelight.primitiveAdapters) implementation(libs.windowSizeClass) - implementation(compose.components.resources) implementation(projects.circuitCodegenAnnotations) implementation(projects.circuitFoundation) implementation(projects.circuitOverlay) diff --git a/samples/star/coil-rule/build.gradle.kts b/samples/star/coil-rule/build.gradle.kts index ceeceb17f2..485dfd4941 100644 --- a/samples/star/coil-rule/build.gradle.kts +++ b/samples/star/coil-rule/build.gradle.kts @@ -22,8 +22,8 @@ kotlin { api(libs.junit) api(libs.coil) api(libs.coil.test) + implementation(libs.compose.components.resources) implementation(libs.compose.runtime) // Required for the compose compiler - implementation(compose.components.resources) } } androidMain { dependencies { implementation(libs.androidx.test.monitor) } } From 14a6e9c8063a8e10c899d76757dd644e1d571ea9 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 4 Nov 2025 16:05:41 -0800 Subject: [PATCH 28/44] Hot reload is part of the main plugin now --- gradle/libs.versions.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d04c59a5af..1e5d59538e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,6 @@ anvil = "0.4.1" atomicfu = "0.29.0" benchmark = "1.4.1" coil = "3.3.0" -compose-hotReload = "1.0.0-rc02" dagger = "2.57.2" datastore = "1.1.7" detekt = "1.23.8" @@ -54,7 +53,7 @@ anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" } binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.18.1" } compose = { id = "org.jetbrains.compose", version.ref = "jb-compose" } -compose-hotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hotReload" } +compose-hotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "jb-compose" } dependencyGuard = { id = "com.dropbox.dependency-guard", version = "0.5.0" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } From 181e23ee6147fb136498da9392db2f3325a2c923 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 6 Nov 2025 16:48:54 -0800 Subject: [PATCH 29/44] cleanup hot reload --- build.gradle.kts | 2 +- gradle/libs.versions.toml | 1 - samples/star/build.gradle.kts | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d9619562a8..cc002e4e48 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,7 +54,6 @@ plugins { alias(libs.plugins.baselineprofile) apply false alias(libs.plugins.emulatorWtf) apply false alias(libs.plugins.binaryCompatibilityValidator) - alias(libs.plugins.compose.hotReload) apply false } val ktfmtVersion = libs.versions.ktfmt.get() @@ -126,6 +125,7 @@ allprojects { val jvmTargetVersion = libs.versions.jvmTarget subprojects { + val isPublished = project.hasProperty("POM_ARTIFACT_ID") val jvmTargetProject = jvmTargetVersion pluginManager.withPlugin("java") { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57cf4a1e0b..ea514cc809 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,6 @@ anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" } binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.18.1" } compose = { id = "org.jetbrains.compose", version.ref = "jb-compose" } -compose-hotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "jb-compose" } dependencyGuard = { id = "com.dropbox.dependency-guard", version = "0.5.0" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } diff --git a/samples/star/build.gradle.kts b/samples/star/build.gradle.kts index a216c43936..e5f75c8b05 100644 --- a/samples/star/build.gradle.kts +++ b/samples/star/build.gradle.kts @@ -19,7 +19,6 @@ plugins { alias(libs.plugins.sqldelight) alias(libs.plugins.emulatorWtf) alias(libs.plugins.metro) - alias(libs.plugins.compose.hotReload) } kotlin { From af603e6e4e28eebb363a3d446bf5c43410c1bc40 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Thu, 6 Nov 2025 16:49:39 -0800 Subject: [PATCH 30/44] Revert "Make deprecated `BackHandler` work for now" This reverts commit 48ff1049ab821adb2cda5209626684b532211fc4. --- .../foundation/NavigatorBackHandlerTest.kt | 2 - .../slack/circuit/foundation/NavigatorImpl.kt | 1 - .../PredictiveBackNavigationDecorator.kt | 2 +- .../gesturenavigation/BackNavigationTest.kt | 2 - circuitx/navigation/build.gradle.kts | 3 +- .../intercepting/InterceptingNavigator.kt | 1 - .../intercepting/NavigatorBackHandlerTest.kt | 53 +++++++------------ .../circuitx/overlays/BottomSheetOverlay.kt | 1 - .../circuitx/overlays/FullScreenOverlay.kt | 1 - 9 files changed, 22 insertions(+), 44 deletions(-) diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt index 5e1fc68226..b090b4e3be 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt @@ -83,7 +83,6 @@ class NavigatorBackHandlerTest { lateinit var navigator: Navigator composeTestRule.setContent { CircuitCompositionLocals(circuit) { - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) navigator = rememberCircuitNavigator(backStack = backStack) @@ -112,7 +111,6 @@ class NavigatorBackHandlerTest { lateinit var navigator: Navigator composeTestRule.setContent { CircuitCompositionLocals(circuit) { - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) navigator = rememberCircuitNavigator(backStack = backStack) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt index 8e341cbf19..6a37fd67d3 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt @@ -74,7 +74,6 @@ public fun rememberCircuitNavigator( } var hasPendingRootPop by remember(hasScreenChanged) { mutableStateOf(false) } var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler( enabled = enableBackHandler && enableRootBackHandler && backStack.size > 1, onBack = { diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt index 0f92b9e3a8..cc12217c3b 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt @@ -114,7 +114,7 @@ private fun BackHandler( val lastOnBackProgress by rememberUpdatedState(onBackProgress) val lastOnBackCancelled by rememberUpdatedState(onBackCancelled) val lastOnBackInvoked by rememberUpdatedState(onBackInvoked) - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler + PredictiveBackHandler( enabled = true, onBack = { progress -> diff --git a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt index 26d01024cb..86c4a57238 100644 --- a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt +++ b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt @@ -90,7 +90,6 @@ class BackNavigationTest { ) } else { navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler { navigator.pop() } } NavigableCircuitContent( @@ -119,7 +118,6 @@ fun createTestBackCircuit( fun TestBackContent(state: TestState, modifier: Modifier = Modifier) { TestContent(state, modifier) if (state.label.contains("root", true)) { - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler {} } } diff --git a/circuitx/navigation/build.gradle.kts b/circuitx/navigation/build.gradle.kts index aa4b0fc506..d88f8a5615 100644 --- a/circuitx/navigation/build.gradle.kts +++ b/circuitx/navigation/build.gradle.kts @@ -42,10 +42,9 @@ kotlin { } commonTest { dependencies { - implementation(libs.compose.navigationevent) + implementation(libs.lifecycle.runtime.compose) implementation(libs.coroutines.test) implementation(libs.kotlin.test) - implementation(libs.lifecycle.runtime.compose) implementation(libs.molecule.runtime) implementation(libs.testing.assertk) implementation(libs.turbine) diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt index 048ef0694a..43aa0d0d22 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt @@ -66,7 +66,6 @@ public fun rememberInterceptingNavigator( } var hasPendingRootPop by remember(hasScreenChanged) { mutableStateOf(false) } var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enableRootBackHandler) { // Root pop check to prevent an infinite loop if this is used with the Android variant of // rememberCircuitNavigator as that calls `OnBackPressedDispatcher.onBackPressed`. We need to diff --git a/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt b/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt index 3a3d59d548..ba788152e0 100644 --- a/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt +++ b/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt @@ -7,9 +7,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.backhandler.BackGestureDispatcher import androidx.compose.ui.backhandler.BackHandler -import androidx.compose.ui.backhandler.LocalCompatNavigationEventDispatcherOwner +import androidx.compose.ui.backhandler.LocalBackGestureDispatcher import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -18,11 +18,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.navigationevent.DirectNavigationEventInput -import androidx.navigationevent.NavigationEvent -import androidx.navigationevent.NavigationEvent.Companion.EDGE_LEFT -import androidx.navigationevent.NavigationEventDispatcher -import androidx.navigationevent.NavigationEventDispatcherOwner import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent @@ -42,25 +37,25 @@ class NavigatorBackHandlerTest { @get:Rule val composeTestRule = createComposeRule() - @OptIn(ExperimentalComposeUiApi::class, InternalComposeUiApi::class) + @OptIn(ExperimentalComposeUiApi::class) @Test fun nestedNavigatorRootPopBackHandler() { - val dispatcher = BackDispatcher() val circuit = createTestCircuit(rememberType = TestCountPresenter.RememberType.Standard) var outerBackCount by mutableStateOf(0) lateinit var navigator: Navigator composeTestRule.setContent { + @Suppress("DEPRECATION") CompositionLocalProvider( - LocalCompatNavigationEventDispatcherOwner provides dispatcher, + LocalBackGestureDispatcher provides BackDispatcher, LocalLifecycleOwner provides StartedLifecycleOwner, ) { CircuitCompositionLocals(circuit) { - @Suppress("DEPRECATION") BackHandler(enabled = true) { outerBackCount++ } + BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val circuitNavigator = rememberCircuitNavigator( backStack = backStack, - onRootPop = { dispatcher.onBack() }, + onRootPop = { BackDispatcher.onBack() }, enableBackHandler = true, ) navigator = rememberInterceptingNavigator(circuitNavigator) @@ -82,26 +77,24 @@ class NavigatorBackHandlerTest { } } - @OptIn(ExperimentalComposeUiApi::class, InternalComposeUiApi::class) + @OptIn(ExperimentalComposeUiApi::class) @Test fun nestedNavigatorRootDispatchedBackHandler() { - val dispatcher = BackDispatcher() val circuit = createTestCircuit(rememberType = TestCountPresenter.RememberType.Standard) var outerBackCount by mutableStateOf(0) lateinit var navigator: Navigator composeTestRule.setContent { CompositionLocalProvider( - LocalCompatNavigationEventDispatcherOwner provides dispatcher, + LocalBackGestureDispatcher provides BackDispatcher, LocalLifecycleOwner provides StartedLifecycleOwner, ) { CircuitCompositionLocals(circuit) { - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val circuitNavigator = rememberCircuitNavigator( backStack = backStack, - onRootPop = { dispatcher.onBack() }, + onRootPop = { BackDispatcher.onBack() }, enableBackHandler = true, ) navigator = rememberInterceptingNavigator(circuitNavigator) @@ -117,32 +110,26 @@ class NavigatorBackHandlerTest { onNodeWithTag(TAG_GO_NEXT).performClick() onNodeWithTag(TAG_LABEL).assertTextEquals("C") // Back through the onRootPop into the back handler - dispatcher.onBack() + BackDispatcher.onBack() onNodeWithTag(TAG_LABEL).assertTextEquals("B") - dispatcher.onBack() + BackDispatcher.onBack() onNodeWithTag(TAG_LABEL).assertTextEquals("A") - dispatcher.onBack() + BackDispatcher.onBack() waitForIdle() assertEquals(1, outerBackCount) } } } -private class BackDispatcher : NavigationEventDispatcherOwner { - - private val input = DirectNavigationEventInput() - override val navigationEventDispatcher: NavigationEventDispatcher = NavigationEventDispatcher() - - init { - navigationEventDispatcher.addInput(input) - } +@OptIn(ExperimentalComposeUiApi::class) +private val BackDispatcher = + object : BackGestureDispatcher() { - fun onBack() { - input.backStarted(NavigationEvent(swipeEdge = EDGE_LEFT)) - input.backProgressed(NavigationEvent(swipeEdge = EDGE_LEFT, progress = 0.5f)) - input.backCompleted() + fun onBack() { + activeListener?.onStarted() + activeListener?.onCompleted() + } } -} private val StartedLifecycleOwner = object : Lifecycle(), LifecycleOwner { diff --git a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt index 5cbcd0acde..4c5700c9b2 100644 --- a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt +++ b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt @@ -147,7 +147,6 @@ private constructor( ModalBottomSheet( content = { val coroutineScope = rememberStableCoroutineScope() - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler BackHandler(enabled = sheetState.isVisible) { coroutineScope .launch { sheetState.hide() } diff --git a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt index 584b9c47c2..a7a2491d91 100644 --- a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt +++ b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt @@ -63,7 +63,6 @@ internal class FullScreenOverlay( val dispatchingNavigator = remember { DispatchingOverlayNavigator(screen, navigator, callbacks::onFinish) } - @Suppress("DEPRECATION") // Migrate to NavigationEventHandler PredictiveBackHandler(enabled = true) { progress -> try { progress.collect { transitionController.seek(it.progress) } From ea02fe9244e11496791677c9ed5f729644deda28 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 7 Nov 2025 12:57:40 -0800 Subject: [PATCH 31/44] Use navigation event --- circuit-foundation/build.gradle.kts | 2 +- .../foundation/NavigatorBackHandlerTest.kt | 12 +- .../slack/circuit/foundation/NavigatorImpl.kt | 13 +- .../internal/PredictiveBackHandler.kt | 166 ++++++++++++++++++ circuitx/gesture-navigation/build.gradle.kts | 2 +- .../gesturenavigation/BackNavigationTest.kt | 7 +- .../PredictiveBackNavigationDecorator.kt | 79 ++------- .../gesturenavigation/BackNavigationTest.kt | 11 +- circuitx/navigation/build.gradle.kts | 2 +- .../intercepting/InterceptingNavigator.kt | 32 ++-- .../intercepting/NavigatorBackHandlerTest.kt | 37 ++-- circuitx/overlays/build.gradle.kts | 2 +- .../circuitx/overlays/BottomSheetOverlay.kt | 9 +- .../circuitx/overlays/FullScreenOverlay.kt | 20 +-- gradle/libs.versions.toml | 2 +- 15 files changed, 277 insertions(+), 119 deletions(-) create mode 100644 circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt diff --git a/circuit-foundation/build.gradle.kts b/circuit-foundation/build.gradle.kts index 42d6ccab89..721d8520e7 100644 --- a/circuit-foundation/build.gradle.kts +++ b/circuit-foundation/build.gradle.kts @@ -67,7 +67,7 @@ kotlin { api(projects.circuitRuntimePresenter) api(projects.circuitRuntimeUi) api(projects.circuitSharedElements) - implementation(libs.compose.ui.backhandler) + implementation(libs.compose.navigationevent) } } androidMain { dependencies { implementation(libs.androidx.activity.compose) } } diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt index b090b4e3be..fe12a5ab38 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt @@ -10,11 +10,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.internal.test.TestContentTags.TAG_GO_NEXT import com.slack.circuit.internal.test.TestContentTags.TAG_LABEL @@ -83,7 +85,9 @@ class NavigatorBackHandlerTest { lateinit var navigator: Navigator composeTestRule.setContent { CircuitCompositionLocals(circuit) { - BackHandler(enabled = true) { outerBackCount++ } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + outerBackCount++ + } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) navigator = rememberCircuitNavigator(backStack = backStack) NavigableCircuitContent(navigator = navigator, backStack = backStack) @@ -111,7 +115,9 @@ class NavigatorBackHandlerTest { lateinit var navigator: Navigator composeTestRule.setContent { CircuitCompositionLocals(circuit) { - BackHandler(enabled = true) { outerBackCount++ } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + outerBackCount++ + } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) navigator = rememberCircuitNavigator(backStack = backStack) NavigableCircuitContent(navigator = navigator, backStack = backStack) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt index 6a37fd67d3..5d7ef639b4 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt @@ -12,7 +12,9 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.backhandler.BackHandler +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import com.slack.circuit.backstack.BackStack import com.slack.circuit.backstack.BackStack.Record import com.slack.circuit.backstack.isAtRoot @@ -43,7 +45,7 @@ public fun rememberCircuitNavigator( /** * Returns a new [Navigator] for navigating within [CircuitContents][CircuitContent] while also - * handling back events with a [BackHandler]. + * handling back events with a [NavigationBackHandler]. * * @param backStack The backing [BackStack] to navigate. * @param onRootPop Invoked when the backstack is at root (size 1) and the user presses the back @@ -74,9 +76,10 @@ public fun rememberCircuitNavigator( } var hasPendingRootPop by remember(hasScreenChanged) { mutableStateOf(false) } var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } - BackHandler( - enabled = enableBackHandler && enableRootBackHandler && backStack.size > 1, - onBack = { + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = enableBackHandler && enableRootBackHandler && backStack.size > 1, + onBackCompleted = { // We need to unload this BackHandler from the composition before the root pop is triggered so // any outer back handler will get called. So delay calling pop until after the next // composition. diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt new file mode 100644 index 0000000000..af039fdc7e --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt @@ -0,0 +1,166 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.foundation.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventHandler +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import com.slack.circuit.foundation.internal.PredictiveBack.Event +import com.slack.circuit.runtime.InternalCircuitApi +import com.slack.circuit.runtime.internal.rememberStableCoroutineScope +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.launch + +@InternalCircuitApi +@Composable +public fun PredictiveBackEventHandler( + isEnabled: Boolean = true, + onBackProgress: suspend (Float, Offset) -> Unit, + onBackCancelled: suspend () -> Unit, + onBackCompleted: suspend () -> Unit, +) { + val dispatcher = LocalNavigationEventDispatcherOwner.current?.navigationEventDispatcher ?: return + val scope = rememberStableCoroutineScope() + val handler = remember(dispatcher) { PredictiveBackEventHandler(isEnabled, scope, dispatcher) } + SideEffect { + with(handler) { + isBackEnabled = isEnabled + onProgress = onBackProgress + onCancelled = onBackCancelled + onCompleted = onBackCompleted + } + } +} + +@OptIn(InternalCircuitApi::class) +private class PredictiveBackEventHandler( + isEnabled: Boolean, + private val scope: CoroutineScope, + private val dispatcher: NavigationEventDispatcher, +) : + RememberObserver, + NavigationEventHandler( + initialInfo = NavigationEventInfo.None, + isBackEnabled = isEnabled, + ) { + + var back: PredictiveBack? = null + + var onProgress: suspend (Float, Offset) -> Unit = { _, _ -> } + var onCancelled: suspend () -> Unit = {} + var onCompleted: suspend () -> Unit = {} + + override fun onBackStarted(event: NavigationEvent) { + back?.cancel() + back = PredictiveBack(scope) { event -> onEvent(event) } + } + + override fun onBackProgressed(event: NavigationEvent) { + val offset = Offset(event.touchX, event.touchY) + val progress = + when (event.swipeEdge) { + NavigationEvent.EDGE_LEFT -> event.progress + NavigationEvent.EDGE_RIGHT -> -event.progress + else -> 0f + } + back?.send(Event.Progress(progress, offset)) + } + + override fun onBackCancelled() { + back?.send(Event.Canceled) + back = null + } + + override fun onBackCompleted() { + if (back == null) { + // Can happen if the back event is just a single "onBackPressed". + onBackStarted(NavigationEvent()) + } + back?.send(Event.Completed) + back = null + } + + override fun onRemembered() { + back = null + dispatcher.addHandler(this) + } + + override fun onForgotten() { + back?.cancel() + remove() + } + + override fun onAbandoned() { + onForgotten() + } + + private suspend fun onEvent(event: Event) { + try { + if (isBackEnabled) { + when (event) { + is Event.Progress -> onProgress(event.progress, event.offset) + is Event.Completed -> onCompleted() + is Event.Canceled -> onCancelled() + } + } else { + onCancelled() + } + } catch (e: CancellationException) { + onCancelled() + throw e + } + } +} + +@InternalCircuitApi +public class PredictiveBack(scope: CoroutineScope, public val onEvent: suspend (Event) -> Unit) { + + private var initialTouch = Offset.Zero + private val channel = Channel(capacity = BUFFERED) + private val job = + scope.launch { + for (event in channel) { + if (event is Event.Progress) { + onEvent(progressAsDelta(event)) + } else { + onEvent(event) + } + } + } + + public fun send(event: Event) { + channel.trySend(event) + } + + public fun cancel() { + channel.cancel() + job.cancel() + } + + private fun progressAsDelta(event: Event.Progress): Event.Progress { + return if (initialTouch == Offset.Zero) { + initialTouch = event.offset + Event.Progress(0f, Offset.Zero) + } else { + Event.Progress(event.progress, event.offset - initialTouch) + } + } + + public sealed interface Event { + public data class Progress(val progress: Float, val offset: Offset) : Event + + public object Completed : Event + + public object Canceled : Event + } +} diff --git a/circuitx/gesture-navigation/build.gradle.kts b/circuitx/gesture-navigation/build.gradle.kts index 36fdcbbb46..92b944a626 100644 --- a/circuitx/gesture-navigation/build.gradle.kts +++ b/circuitx/gesture-navigation/build.gradle.kts @@ -35,7 +35,7 @@ kotlin { dependencies { api(libs.compose.runtime) api(projects.circuitFoundation) - implementation(libs.compose.ui.backhandler) + implementation(libs.compose.navigationevent) } } diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt index affb2031b5..b82a48f4e6 100644 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt +++ b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt @@ -14,6 +14,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.performClick +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitCompositionLocals @@ -58,7 +61,9 @@ class BackNavigationTest(private val androidNavigator: Boolean) { backStack = backStack, onRootPop = {}, // no-op for tests ) - BackHandler { navigator.pop() } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + navigator.pop() + } } NavigableCircuitContent( navigator = navigator, diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt index cc12217c3b..2c2147db43 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt @@ -11,18 +11,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.backhandler.PredictiveBackHandler import androidx.compose.ui.geometry.Offset import com.slack.circuit.backstack.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavDecorator -import com.slack.circuit.runtime.internal.rememberStableCoroutineScope +import com.slack.circuit.foundation.internal.PredictiveBackEventHandler +import com.slack.circuit.runtime.InternalCircuitApi import kotlin.math.abs -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch internal abstract class PredictiveBackNavigationDecorator( private val onBackInvoked: () -> Unit @@ -48,10 +44,9 @@ internal abstract class PredictiveBackNavigationDecorator( return GestureNavTransitionHolder(args) } - @OptIn(ExperimentalComposeUiApi::class) + @OptIn(InternalCircuitApi::class) @Composable override fun updateTransition(args: List): Transition> { - val scope = rememberStableCoroutineScope() val current = remember(args) { targetState(args) } val previous = remember(args) { @@ -83,61 +78,19 @@ internal abstract class PredictiveBackNavigationDecorator( } } } - - if (args.size > 1) { - BackHandler( - onBackProgress = { progress, offset -> - showPrevious = progress != 0f - swipeProgress = progress - swipeOffset = offset - }, - onBackCancelled = { - scope.launch { - isSeeking = false - seekableTransitionState.animateTo(current) - } - }, - onBackInvoked = { onBackInvoked() }, - ) - } + PredictiveBackEventHandler( + isEnabled = args.size > 1, + onBackProgress = { progress, offset -> + showPrevious = progress != 0f + swipeProgress = progress + swipeOffset = offset + }, + onBackCancelled = { + isSeeking = false + seekableTransitionState.animateTo(current) + }, + onBackCompleted = { onBackInvoked() }, + ) return rememberTransition(seekableTransitionState, label = "PredictiveBackNavigationDecorator") } } - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun BackHandler( - onBackProgress: (Float, Offset) -> Unit, - onBackCancelled: () -> Unit, - onBackInvoked: () -> Unit, -) { - val lastOnBackProgress by rememberUpdatedState(onBackProgress) - val lastOnBackCancelled by rememberUpdatedState(onBackCancelled) - val lastOnBackInvoked by rememberUpdatedState(onBackInvoked) - - PredictiveBackHandler( - enabled = true, - onBack = { progress -> - try { - var initialTouch = Offset.Zero - progress.collect { backEvent -> - if (initialTouch == Offset.Zero) { - initialTouch = Offset(backEvent.touchX, backEvent.touchY) - lastOnBackProgress(0f, Offset.Zero) - } else { - lastOnBackProgress( - when (backEvent.swipeEdge) { - 0 -> backEvent.progress // BackEventCompat.EDGE_LEFT - else -> -backEvent.progress - }, - Offset(backEvent.touchX, backEvent.touchY) - initialTouch, - ) - } - } - lastOnBackInvoked() - } catch (_: CancellationException) { - lastOnBackCancelled() - } - }, - ) -} diff --git a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt index 86c4a57238..165b27248b 100644 --- a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt +++ b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt @@ -10,12 +10,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitCompositionLocals @@ -90,7 +92,9 @@ class BackNavigationTest { ) } else { navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) - BackHandler { navigator.pop() } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + navigator.pop() + } } NavigableCircuitContent( navigator = navigator, @@ -113,12 +117,11 @@ fun createTestBackCircuit( .addUiFactory { _, _ -> ui { state, modifier -> TestBackContent(state, modifier) } } .build() -@OptIn(ExperimentalComposeUiApi::class) @Composable fun TestBackContent(state: TestState, modifier: Modifier = Modifier) { TestContent(state, modifier) if (state.label.contains("root", true)) { - BackHandler {} + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) {} } } diff --git a/circuitx/navigation/build.gradle.kts b/circuitx/navigation/build.gradle.kts index d88f8a5615..dc7160af93 100644 --- a/circuitx/navigation/build.gradle.kts +++ b/circuitx/navigation/build.gradle.kts @@ -37,7 +37,7 @@ kotlin { api(libs.compose.runtime) api(libs.coroutines) api(projects.circuitFoundation) - implementation(libs.compose.ui.backhandler) + implementation(libs.compose.navigationevent) } } commonTest { diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt index 43aa0d0d22..3489a5c76c 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt @@ -11,7 +11,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.backhandler.BackHandler +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.Navigator.StateOptions @@ -66,18 +68,22 @@ public fun rememberInterceptingNavigator( } var hasPendingRootPop by remember(hasScreenChanged) { mutableStateOf(false) } var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } - BackHandler(enableRootBackHandler) { - // Root pop check to prevent an infinite loop if this is used with the Android variant of - // rememberCircuitNavigator as that calls `OnBackPressedDispatcher.onBackPressed`. We need to - // unload this BackHandler from the composition before the root pop is triggered, so delay - // calling pop until after the next composition. - if (navigator.peekBackStack().size > 1) { - interceptingNavigator.pop() - } else { - hasPendingRootPop = true - enableRootBackHandler = false - } - } + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = enableRootBackHandler, + onBackCompleted = { + // Root pop check to prevent an infinite loop if this is used with the Android variant of + // rememberCircuitNavigator as that calls `OnBackPressedDispatcher.onBackPressed`. We need + // to unload this BackHandler from the composition before the root pop is triggered, so + // delay calling pop until after the next composition. + if (navigator.peekBackStack().size > 1) { + interceptingNavigator.pop() + } else { + hasPendingRootPop = true + enableRootBackHandler = false + } + }, + ) if (hasPendingRootPop) { SideEffect { interceptingNavigator.pop() diff --git a/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt b/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt index ba788152e0..bcfa147e21 100644 --- a/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt +++ b/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt @@ -7,9 +7,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.backhandler.BackGestureDispatcher -import androidx.compose.ui.backhandler.BackHandler -import androidx.compose.ui.backhandler.LocalBackGestureDispatcher import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -18,6 +15,14 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigationevent.DirectNavigationEventInput +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent @@ -44,13 +49,14 @@ class NavigatorBackHandlerTest { var outerBackCount by mutableStateOf(0) lateinit var navigator: Navigator composeTestRule.setContent { - @Suppress("DEPRECATION") CompositionLocalProvider( - LocalBackGestureDispatcher provides BackDispatcher, + LocalNavigationEventDispatcherOwner provides BackDispatcher, LocalLifecycleOwner provides StartedLifecycleOwner, ) { CircuitCompositionLocals(circuit) { - BackHandler(enabled = true) { outerBackCount++ } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + outerBackCount++ + } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val circuitNavigator = rememberCircuitNavigator( @@ -85,11 +91,13 @@ class NavigatorBackHandlerTest { lateinit var navigator: Navigator composeTestRule.setContent { CompositionLocalProvider( - LocalBackGestureDispatcher provides BackDispatcher, + LocalNavigationEventDispatcherOwner provides BackDispatcher, LocalLifecycleOwner provides StartedLifecycleOwner, ) { CircuitCompositionLocals(circuit) { - BackHandler(enabled = true) { outerBackCount++ } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + outerBackCount++ + } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val circuitNavigator = rememberCircuitNavigator( @@ -123,11 +131,18 @@ class NavigatorBackHandlerTest { @OptIn(ExperimentalComposeUiApi::class) private val BackDispatcher = - object : BackGestureDispatcher() { + object : NavigationEventDispatcherOwner { + + override val navigationEventDispatcher = NavigationEventDispatcher() + private val input = DirectNavigationEventInput() + + init { + navigationEventDispatcher.addInput(input) + } fun onBack() { - activeListener?.onStarted() - activeListener?.onCompleted() + input.backStarted(NavigationEvent()) + input.backCompleted() } } diff --git a/circuitx/overlays/build.gradle.kts b/circuitx/overlays/build.gradle.kts index 6fb17ddd9b..78a2fad083 100644 --- a/circuitx/overlays/build.gradle.kts +++ b/circuitx/overlays/build.gradle.kts @@ -39,7 +39,7 @@ kotlin { api(libs.compose.runtime) api(libs.coroutines) api(libs.compose.material.material3) - implementation(libs.compose.ui.backhandler) + implementation(libs.compose.navigationevent) implementation(projects.circuitFoundation) } } diff --git a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt index 4c5700c9b2..3b0df1c22c 100644 --- a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt +++ b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt @@ -19,11 +19,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import com.slack.circuit.overlay.Overlay import com.slack.circuit.overlay.OverlayNavigator import com.slack.circuit.runtime.internal.rememberStableCoroutineScope @@ -147,7 +149,10 @@ private constructor( ModalBottomSheet( content = { val coroutineScope = rememberStableCoroutineScope() - BackHandler(enabled = sheetState.isVisible) { + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = sheetState.isVisible, + ) { coroutineScope .launch { sheetState.hide() } .invokeOnCompletion { diff --git a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt index a7a2491d91..997aa53416 100644 --- a/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt +++ b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/FullScreenOverlay.kt @@ -9,19 +9,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.key import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.backhandler.PredictiveBackHandler import com.slack.circuit.foundation.CircuitContent +import com.slack.circuit.foundation.internal.PredictiveBackEventHandler import com.slack.circuit.foundation.onNavEvent import com.slack.circuit.overlay.AnimatedOverlay import com.slack.circuit.overlay.OverlayHost import com.slack.circuit.overlay.OverlayNavigator import com.slack.circuit.overlay.OverlayTransitionController +import com.slack.circuit.runtime.InternalCircuitApi import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen import kotlin.jvm.JvmInline -import kotlinx.coroutines.CancellationException /** * Shows a full screen overlay with the given [screen]. As the name suggests, this overlay takes @@ -53,7 +52,7 @@ internal class FullScreenOverlay( @JvmInline internal value class Result(val result: PopResult?) - @OptIn(ExperimentalComposeUiApi::class) // For PredictiveBackHandler + @OptIn(InternalCircuitApi::class) // For PredictiveBackHandler @Composable override fun AnimatedVisibilityScope.AnimatedContent( navigator: OverlayNavigator, @@ -63,14 +62,11 @@ internal class FullScreenOverlay( val dispatchingNavigator = remember { DispatchingOverlayNavigator(screen, navigator, callbacks::onFinish) } - PredictiveBackHandler(enabled = true) { progress -> - try { - progress.collect { transitionController.seek(it.progress) } - dispatchingNavigator.pop() - } catch (_: CancellationException) { - transitionController.cancel() - } - } + PredictiveBackEventHandler( + onBackProgress = { progress, _ -> transitionController.seek(progress) }, + onBackCancelled = { transitionController.cancel() }, + onBackCompleted = { dispatchingNavigator.pop() }, + ) CircuitContent(screen = screen, onNavEvent = dispatchingNavigator::onNavEvent) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea514cc809..7a4c63e11f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.13.0" -androidx-activity = "1.11.0" +androidx-activity = "1.12.0-rc01" androidx-annotation = "1.9.1" androidx-appcompat = "1.7.1" androidx-browser = "1.9.0" From b46d202868c297b25654ba7350b2a101f76403dc Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 7 Nov 2025 12:57:55 -0800 Subject: [PATCH 32/44] baseline --- .../dependencies/androidReleaseRuntimeClasspath.txt | 7 +++++-- circuit-foundation/dependencies/jvmRuntimeClasspath.txt | 2 ++ .../dependencies/androidReleaseRuntimeClasspath.txt | 7 +++++-- circuit-test/dependencies/jvmRuntimeClasspath.txt | 2 ++ .../dependencies/androidReleaseRuntimeClasspath.txt | 7 +++++-- circuitx/effects/dependencies/jvmRuntimeClasspath.txt | 2 ++ .../dependencies/androidReleaseRuntimeClasspath.txt | 5 +++++ .../dependencies/jvmRuntimeClasspath.txt | 2 ++ .../dependencies/androidReleaseRuntimeClasspath.txt | 7 +++++-- circuitx/navigation/dependencies/jvmRuntimeClasspath.txt | 2 ++ .../dependencies/androidReleaseRuntimeClasspath.txt | 5 +++++ circuitx/overlays/dependencies/jvmRuntimeClasspath.txt | 2 ++ 12 files changed, 42 insertions(+), 8 deletions(-) diff --git a/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt index 0b682655ba..3997e67646 100644 --- a/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt @@ -70,6 +70,10 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel androidx.loader:loader androidx.localbroadcastmanager:localbroadcastmanager +androidx.navigationevent:navigationevent-android +androidx.navigationevent:navigationevent-compose-android +androidx.navigationevent:navigationevent-compose +androidx.navigationevent:navigationevent androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android @@ -91,6 +95,7 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core @@ -101,8 +106,6 @@ org.jetbrains.compose.foundation:foundation-layout org.jetbrains.compose.foundation:foundation org.jetbrains.compose.runtime:runtime-saveable org.jetbrains.compose.runtime:runtime -org.jetbrains.compose.ui:ui-backhandler-android -org.jetbrains.compose.ui:ui-backhandler org.jetbrains.compose.ui:ui-geometry org.jetbrains.compose.ui:ui-graphics org.jetbrains.compose.ui:ui-text diff --git a/circuit-foundation/dependencies/jvmRuntimeClasspath.txt b/circuit-foundation/dependencies/jvmRuntimeClasspath.txt index 6250a9477b..31b1dc5666 100644 --- a/circuit-foundation/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-foundation/dependencies/jvmRuntimeClasspath.txt @@ -35,6 +35,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose-desktop +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose-desktop org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate diff --git a/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt index 8daf0cec1f..daeedd6f2f 100644 --- a/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt @@ -70,6 +70,10 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel androidx.loader:loader androidx.localbroadcastmanager:localbroadcastmanager +androidx.navigationevent:navigationevent-android +androidx.navigationevent:navigationevent-compose-android +androidx.navigationevent:navigationevent-compose +androidx.navigationevent:navigationevent androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android @@ -95,6 +99,7 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core @@ -105,8 +110,6 @@ org.jetbrains.compose.foundation:foundation-layout org.jetbrains.compose.foundation:foundation org.jetbrains.compose.runtime:runtime-saveable org.jetbrains.compose.runtime:runtime -org.jetbrains.compose.ui:ui-backhandler-android -org.jetbrains.compose.ui:ui-backhandler org.jetbrains.compose.ui:ui-geometry org.jetbrains.compose.ui:ui-graphics org.jetbrains.compose.ui:ui-text diff --git a/circuit-test/dependencies/jvmRuntimeClasspath.txt b/circuit-test/dependencies/jvmRuntimeClasspath.txt index 690ecb178c..a80047a9f2 100644 --- a/circuit-test/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-test/dependencies/jvmRuntimeClasspath.txt @@ -39,6 +39,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose-desktop +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose-desktop org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate diff --git a/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt index 8f0bbc905b..237056de79 100644 --- a/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt @@ -70,6 +70,10 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel androidx.loader:loader androidx.localbroadcastmanager:localbroadcastmanager +androidx.navigationevent:navigationevent-android +androidx.navigationevent:navigationevent-compose-android +androidx.navigationevent:navigationevent-compose +androidx.navigationevent:navigationevent androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android @@ -91,6 +95,7 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core @@ -101,8 +106,6 @@ org.jetbrains.compose.foundation:foundation-layout org.jetbrains.compose.foundation:foundation org.jetbrains.compose.runtime:runtime-saveable org.jetbrains.compose.runtime:runtime -org.jetbrains.compose.ui:ui-backhandler-android -org.jetbrains.compose.ui:ui-backhandler org.jetbrains.compose.ui:ui-geometry org.jetbrains.compose.ui:ui-graphics org.jetbrains.compose.ui:ui-text diff --git a/circuitx/effects/dependencies/jvmRuntimeClasspath.txt b/circuitx/effects/dependencies/jvmRuntimeClasspath.txt index 6250a9477b..31b1dc5666 100644 --- a/circuitx/effects/dependencies/jvmRuntimeClasspath.txt +++ b/circuitx/effects/dependencies/jvmRuntimeClasspath.txt @@ -35,6 +35,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose-desktop +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose-desktop org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate diff --git a/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt index cc205e62e3..5db2c14577 100644 --- a/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt @@ -77,6 +77,10 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel androidx.loader:loader androidx.localbroadcastmanager:localbroadcastmanager +androidx.navigationevent:navigationevent-android +androidx.navigationevent:navigationevent-compose-android +androidx.navigationevent:navigationevent-compose +androidx.navigationevent:navigationevent androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android @@ -98,6 +102,7 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core diff --git a/circuitx/gesture-navigation/dependencies/jvmRuntimeClasspath.txt b/circuitx/gesture-navigation/dependencies/jvmRuntimeClasspath.txt index 6250a9477b..31b1dc5666 100644 --- a/circuitx/gesture-navigation/dependencies/jvmRuntimeClasspath.txt +++ b/circuitx/gesture-navigation/dependencies/jvmRuntimeClasspath.txt @@ -35,6 +35,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose-desktop +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose-desktop org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate diff --git a/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt index 8f0bbc905b..237056de79 100644 --- a/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt @@ -70,6 +70,10 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel androidx.loader:loader androidx.localbroadcastmanager:localbroadcastmanager +androidx.navigationevent:navigationevent-android +androidx.navigationevent:navigationevent-compose-android +androidx.navigationevent:navigationevent-compose +androidx.navigationevent:navigationevent androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android @@ -91,6 +95,7 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core @@ -101,8 +106,6 @@ org.jetbrains.compose.foundation:foundation-layout org.jetbrains.compose.foundation:foundation org.jetbrains.compose.runtime:runtime-saveable org.jetbrains.compose.runtime:runtime -org.jetbrains.compose.ui:ui-backhandler-android -org.jetbrains.compose.ui:ui-backhandler org.jetbrains.compose.ui:ui-geometry org.jetbrains.compose.ui:ui-graphics org.jetbrains.compose.ui:ui-text diff --git a/circuitx/navigation/dependencies/jvmRuntimeClasspath.txt b/circuitx/navigation/dependencies/jvmRuntimeClasspath.txt index 6250a9477b..31b1dc5666 100644 --- a/circuitx/navigation/dependencies/jvmRuntimeClasspath.txt +++ b/circuitx/navigation/dependencies/jvmRuntimeClasspath.txt @@ -35,6 +35,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose-desktop +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose-desktop org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate diff --git a/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt index cc205e62e3..5db2c14577 100644 --- a/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt @@ -77,6 +77,10 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate androidx.lifecycle:lifecycle-viewmodel androidx.loader:loader androidx.localbroadcastmanager:localbroadcastmanager +androidx.navigationevent:navigationevent-android +androidx.navigationevent:navigationevent-compose-android +androidx.navigationevent:navigationevent-compose +androidx.navigationevent:navigationevent androidx.print:print androidx.profileinstaller:profileinstaller androidx.savedstate:savedstate-android @@ -98,6 +102,7 @@ org.jetbrains.androidx.lifecycle:lifecycle-runtime org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate org.jetbrains.compose.animation:animation-core diff --git a/circuitx/overlays/dependencies/jvmRuntimeClasspath.txt b/circuitx/overlays/dependencies/jvmRuntimeClasspath.txt index b6e40ceaf1..c4d6ed2471 100644 --- a/circuitx/overlays/dependencies/jvmRuntimeClasspath.txt +++ b/circuitx/overlays/dependencies/jvmRuntimeClasspath.txt @@ -37,6 +37,8 @@ org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.navigationevent:navigationevent-compose-desktop +org.jetbrains.androidx.navigationevent:navigationevent-compose org.jetbrains.androidx.savedstate:savedstate-compose-desktop org.jetbrains.androidx.savedstate:savedstate-compose org.jetbrains.androidx.savedstate:savedstate From 87b0ba430c640024e3801ccce33bc58a0547c31f Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Wed, 12 Nov 2025 17:20:46 -0800 Subject: [PATCH 33/44] navstack --- .../com/slack/circuit/backstack/BackStack.kt | 154 ++------- .../com/slack/circuit/backstack/NavStack.kt | 145 ++++++++ .../circuit/backstack/SaveableBackStack.kt | 38 ++- .../circuit/backstack/SaveableNavStack.kt | 313 ++++++++++++++++++ .../slack/circuit/backstack/NavStackTest.kt | 307 +++++++++++++++++ 5 files changed, 827 insertions(+), 130 deletions(-) create mode 100644 backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt create mode 100644 backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt create mode 100644 backstack/src/commonTest/kotlin/com/slack/circuit/backstack/NavStackTest.kt diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt index 67c5ac8dda..5f1023f7c4 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt @@ -17,143 +17,51 @@ package com.slack.circuit.backstack import androidx.compose.runtime.Stable import androidx.compose.runtime.snapshots.Snapshot -import com.slack.circuit.backstack.BackStack.Record +import com.slack.circuit.backstack.NavStack.Record import com.slack.circuit.runtime.screen.Screen /** * A caller-supplied stack of [Record]s for presentation with a `Navigator`. Iteration order is * top-first (first element is the top of the stack). + * + * BackStack extends NavStack but is intended for backward-only navigation patterns. Implementations + * may provide no-op implementations for forward navigation methods. */ @Stable -public interface BackStack : Iterable { - /** The number of records contained in this [BackStack] that will be seen by an iterator. */ - public val size: Int - - /** The top-most record in the [BackStack], or `null` if the [BackStack] is empty. */ - public val topRecord: R? - - /** The bottom-most record in the [BackStack], or `null` if the [BackStack] is empty. */ - public val rootRecord: R? - - /** - * Push a new [Record] onto the back stack. The new record will become the top of the stack. - * - * @param record The record to push onto the stack. - * @return If the [record] was successfully pushed onto the back stack - */ - public fun push(record: R): Boolean +public interface BackStack : NavStack, Iterable { + // BackStack inherits all methods from NavStack +} - /** - * Push a new [Screen] onto the back stack. This will be enveloped in a [Record] and the new - * record will become the top of the stack. - * - * @param screen The screen to push onto the stack. - * @return If the [screen] was successfully pushed onto the back stack - */ - public fun push(screen: Screen): Boolean +public fun BackStack.push(record: R): Boolean { + return add(record) +} - /** - * Attempt to pop the top item off of the back stack, returning the popped [Record] if popping was - * successful or `null` if no entry was popped. - */ - public fun pop(): R? +public fun BackStack.push(screen: Screen): Boolean { + return add(screen) +} - /** - * Pop records off the top of the backstack until one is found that matches the given predicate. - */ - public fun popUntil(predicate: (R) -> Boolean): List { - return buildList { - while (topRecord?.let(predicate) == false) { - val popped = pop() ?: break - add(popped) - } +/** + * Attempt to pop the top item off of the back stack, returning the popped [Record] if popping was + * successful or `null` if no entry was popped. + */ +public fun BackStack.pop(): R? { + return Snapshot.withMutableSnapshot { + // todo edge cases + while (canGoForward) { + // Move forward and remove the previous record. + move(NavStack.Direction.Forward) + remove(NavStack.Direction.Backward) } - } - - /** - * Saves the current back stack entry list in an internal state store. It can be later restored by - * the root screen to [restoreState]. - * - * This call will overwrite any existing stored state with the same root screen. - */ - public fun saveState() - - /** - * Restores the saved state with the given [screen], adding it on top of the existing entry list. - * If you wish to replace the current entry list, you should [pop] all of the existing entries off - * before calling this function. - * - * @param screen The root screen which was previously saved using [saveState]. - * @return Returns true if there was any back stack state to restore. - */ - public fun restoreState(screen: Screen): Boolean - - /** - * Peek at the [Screen] in the internal state store that have been saved using [saveState]. - * - * @return The list of [Screen]s currently in the internal state store, will be empty if there is - * no saved state. - */ - public fun peekState(): List - - /** - * Removes the state associated with the given [screen] from the internal state store. - * - * @return true if the state was removed, false if no state was found for the given screen. - */ - public fun removeState(screen: Screen): Boolean - - /** - * Whether the back stack contains the given [record]. - * - * @param includeSaved Whether to also check if the record is contained by any saved back stack - * state. See [saveState]. - */ - public fun containsRecord(record: R, includeSaved: Boolean): Boolean - - /** - * Whether a record with the given [key] is reachable within the back stack or saved state. - * Reachable means that it is either currently in the visible back stack or if we popped `depth` - * times, it would be found. - * - * @param key The record's key to look for. - * @param depth How many records to consider popping from the top of the stack before considering - * the key unreachable. A depth of zero means only check the current visible stack. A depth of 1 - * means check the current visible stack plus one record popped off the top, and so on. - * @param includeSaved Whether to also check if the record is contained by any saved back stack - * state. See [saveState]. - */ - public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean - - @Stable - public interface Record { - /** - * A value that identifies this record uniquely, even if it shares the same [screen] with - * another record. This key may be used by [BackStackRecordLocalProvider]s to associate - * presentation data with a record across composition recreation. - * - * [key] MUST NOT change for the life of the record. - */ - public val key: String - - /** The [Screen] that should present this record. */ - public val screen: Screen + remove(NavStack.Direction.Backward) } } -/** `true` if the [BackStack] contains no records. [Iterable.firstOrNull] will return `null`. */ -public val BackStack.isEmpty: Boolean - get() = size == 0 - -/** `true` if the [BackStack] contains exactly one record. */ -public val BackStack.isAtRoot: Boolean - get() = size == 1 - -/** Clear any saved state from the [BackStack]. */ -public fun BackStack.clearState() { - Snapshot.withMutableSnapshot { - for (screen in peekState()) { - removeState(screen) +/** Pop records off the top of the stack until one is found that matches the given predicate. */ +public fun NavStack.popUntil(predicate: (R) -> Boolean): List { + return buildList { + while (currentRecord?.let(predicate) == false) { + val popped = this@popUntil.remove(NavStack.Direction.Backward) ?: break + add(popped) } } } diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt new file mode 100644 index 0000000000..37f95dd40d --- /dev/null +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt @@ -0,0 +1,145 @@ +package com.slack.circuit.backstack + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.snapshots.Snapshot +import com.slack.circuit.backstack.NavStack.Record +import com.slack.circuit.runtime.screen.Screen + +/** + * A navigation stack that supports forward navigation in addition to backward navigation. This is + * the base interface for navigation stacks in Circuit. + */ +@Stable +public interface NavStack { + /** The number of records in the stack. */ + public val size: Int + + /** The top-most record in the stack, including the or `null` if the stack is empty. */ + public val topRecord: R? + + /** The active record in the stack, or `null` if the stack is empty. */ + public val currentRecord: R? + + /** The bottom-most record in the stack, or `null` if the stack is empty. */ + public val rootRecord: R? + + public fun add(screen: Screen): Boolean + + public fun add(record: R): Boolean + + /** + * Removes and returns the top record from the stack and then moves the stack one position in the + * specified direction. + * + * @param direction The direction of removal (Forward or Backward). + * @return The removed record, or null if the stack is empty or removal is not possible. + */ + public fun remove(direction: Direction): R? + + public fun move(direction: Direction): Boolean + + /** + * Saves the current stack entry list in an internal state store. It can be later restored by the + * root screen to [restoreState]. + * + * This call will overwrite any existing stored state with the same root screen. + */ + public fun saveState() + + /** + * Restores the saved state with the given [screen], adding it on top of the existing entry list. + * If you wish to replace the current entry list, you should [pop] all of the existing entries off + * before calling this function. + * + * @param screen The root screen which was previously saved using [saveState]. + * @return Returns true if there was any stack state to restore. + */ + public fun restoreState(screen: Screen): Boolean + + /** + * Peek at the [Screen] in the internal state store that have been saved using [saveState]. + * + * @return The list of [Screen]s currently in the internal state store, will be empty if there is + * no saved state. + */ + public fun peekState(): List + + /** + * Removes the state associated with the given [screen] from the internal state store. + * + * @return true if the state was removed, false if no state was found for the given screen. + */ + public fun removeState(screen: Screen): Boolean + + /** + * Whether the stack contains the given [record]. + * + * @param includeSaved Whether to also check if the record is contained by any saved stack state. + * See [saveState]. + */ + public fun containsRecord(record: R, includeSaved: Boolean): Boolean + + /** + * Whether a record with the given [key] is reachable within the stack or saved state. Reachable + * means that it is either currently in the visible stack or if we popped `depth` times, it would + * be found. + * + * @param key The record's key to look for. + * @param depth How many records to consider popping from the top of the stack before considering + * the key unreachable. A depth of zero means only check the current visible stack. A depth of 1 + * means check the current visible stack plus one record popped off the top, and so on. + * @param includeSaved Whether to also check if the record is contained by any saved stack state. + * See [saveState]. + */ + public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean + + public enum class Direction { + Forward, + Backward, + } + + @Stable + public interface Record { + /** + * A value that identifies this record uniquely, even if it shares the same [screen] with + * another record. This key may be used by [BackStackRecordLocalProvider]s to associate + * presentation data with a record across composition recreation. + * + * [key] MUST NOT change for the life of the record. + */ + public val key: String + + /** The [Screen] that should present this record. */ + public val screen: Screen + } +} + +public val NavStack.currentScreen: Screen? + get() = currentRecord?.screen + +public val NavStack.isEmpty: Boolean + get() = size == 0 + +public val NavStack.lastIndex: Int + get() = size - 1 + +public val NavStack.isAtRoot: Boolean + get() = size == 1 + +public val NavStack.isAtTop: Boolean + get() = currentRecord == topRecord + +public val NavStack.canGoBack: Boolean + get() = currentRecord != rootRecord + +public val NavStack.canGoForward: Boolean + get() = currentRecord != topRecord + +/** Clear any saved state from the [BackStack]. */ +public fun NavStack.clearState() { + Snapshot.withMutableSnapshot { + for (screen in peekState()) { + removeState(screen) + } + } +} diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index b33727ba32..bc87b9892a 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -88,15 +88,23 @@ internal constructor( override val rootRecord: Record? get() = entryList.lastOrNull() - public override fun push(screen: Screen): Boolean { - return push(screen, emptyMap()) - } + override val currentIndex: Int + get() = 0 - public fun push(screen: Screen, args: Map): Boolean { - return push(Record(screen, args)) + override val currentRecord: Record? + get() = entryList.firstOrNull() + + override fun add( + index: Int, + screen: Screen + ): Record? { + return add(index, Record(screen, emptyMap())) } - public override fun push(record: Record): Boolean { + override fun add( + index: Int, + record: Record + ): Record? { val topRecord = Snapshot.withoutReadObservation { topRecord } // Guard pushing the exact same record value to the top, records.key is always unique so verify // the parameters individually. @@ -106,6 +114,22 @@ internal constructor( } else false } + override fun remove(index: Int): Record? { + TODO("Not yet implemented") + } + + override fun backward(): Record? { + TODO("Not yet implemented") + } + + public fun push(screen: Screen, args: Map): Boolean { + return + } + + public override fun push(record: Record): Boolean { + + } + override fun pop(): Record? { return Snapshot.withoutReadObservation { entryList.removeFirstOrNull() } } @@ -170,7 +194,7 @@ internal constructor( override val screen: Screen, val args: Map = emptyMap(), @OptIn(ExperimentalUuidApi::class) override val key: String = Uuid.random().toString(), - ) : BackStack.Record { + ) : NavStack.Record { internal companion object { val Saver: Saver = diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt new file mode 100644 index 0000000000..3f7157ca05 --- /dev/null +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt @@ -0,0 +1,313 @@ +/* + * Copyright (C) 2025 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.slack.circuit.backstack + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotStateList +import com.slack.circuit.backstack.SaveableBackStack.Record +import com.slack.circuit.runtime.screen.Screen +import kotlin.math.min + +/** + * Creates and remembers a [SaveableNavStack] with the given [root] screen. + * + * If [root] changes, a new nav stack will be created. + * + * @param init optional initializer callback to perform extra initialization logic. + */ +@Composable +public fun rememberSaveableNavStack( + root: Screen, + init: SaveableNavStack.() -> Unit = {}, +): SaveableNavStack = + rememberSaveable(root, saver = SaveableNavStack.Saver) { SaveableNavStack(root).apply(init) } + +/** + * Creates and remembers a [SaveableNavStack] filled with the given [initialScreens]. + * + * [initialScreens] must not be empty. If [initialScreens] changes, a new nav stack will be created. + */ +@Composable +public fun rememberSaveableNavStack(initialScreens: List): SaveableNavStack { + require(initialScreens.isNotEmpty()) { "Initial input screens cannot be empty!" } + return rememberSaveable(initialScreens, saver = SaveableNavStack.Saver) { + SaveableNavStack().apply { + for (screen in initialScreens) { + push(screen) + } + } + } +} + +/** + * A [NavStack] implementation that supports both backward and forward navigation with state saving. + * + * This implementation uses a single list with a pointer (currentIndex) to track the current + * position in the navigation history: + * - Items at indices 0 to currentIndex-1 are "back" items + * - Item at currentIndex is the current screen + * - Items after currentIndex are "forward" items + * + * When navigating back, the pointer is decremented. + * When navigating forward, the pointer is incremented. + * When pushing a new screen, items after the current position are removed and the new screen is + * added at the end (standard browser behavior). + */ +@Stable +public class SaveableNavStack +internal constructor( + // All visible for testing + internal val historyList: SnapshotStateList = mutableStateListOf(), + internal val initialIndex: Int = 0, + internal val stateStore: MutableMap, Int>> = mutableMapOf(), +) : NavStack { + + private var currentIndex by mutableIntStateOf(initialIndex) + + public constructor(root: Screen) : this(Record(root)) + + public constructor(root: Record) : this() { + push(root) + } + + /** + * The size reflects only the visible back stack (from index 0 to currentIndex, inclusive). + * This maintains compatibility with BackStack behavior. + */ + override val size: Int + get() = currentIndex + 1 + + /** + * Iterator returns only the visible back stack items (from index 0 to currentIndex). + * This maintains compatibility with BackStack behavior where iteration is top-first. + */ + override fun iterator(): Iterator = historyList.subList(0, currentIndex + 1).iterator() + + public override val topRecord: Record? + get() = historyList.getOrNull(currentIndex) + + override val rootRecord: Record? + get() = historyList.getOrNull(0) + + /** + * Returns true if forward navigation is possible (i.e., there are items after currentIndex). + */ + public val canGoForward: Boolean + get() = currentIndex < historyList.size - 1 + + /** + * Returns the number of items in the forward stack. + */ + public val forwardSize: Int + get() = (historyList.size - 1 - currentIndex).coerceAtLeast(0) + + public override fun push(screen: Screen): Boolean { + return push(screen, emptyMap()) + } + + public fun push(screen: Screen, args: Map): Boolean { + return push(Record(screen, args)) + } + + public override fun push(record: Record): Boolean { + val topRecord = Snapshot.withoutReadObservation { topRecord } + // Guard pushing the exact same record value to the top, records.key is always unique so verify + // the parameters individually. + return if (topRecord?.screen != record.screen || topRecord.args != record.args) { + // Remove all forward history when pushing a new record + if (currentIndex < historyList.size - 1) { + historyList.subList(currentIndex + 1, historyList.size).clear() + } + // Add new record at the end and update pointer + historyList.add(record) + currentIndex = historyList.size - 1 + true + } else false + } + + /** + * Navigate back in the history by decrementing the pointer. + * Returns the record at the new current position, or null if already at the root. + * + * @param clearForward if true, removes forward history when going back (default: false) + */ + public fun pop(clearForward: Boolean = false): Record? { + // Don't go back if we're already at the root + if (currentIndex <= 0) return null + + return Snapshot.withoutReadObservation { + if (clearForward) { + // Remove the current item and everything after it + historyList.subList(currentIndex, historyList.size).clear() + } + // Move pointer back + currentIndex-- + historyList.getOrNull(currentIndex) + } + } + + /** + * Standard pop() implementation that maintains forward history. + * This implements the BackStack interface. + */ + override fun pop(): Record? = pop(clearForward = false) + + /** + * Navigate forward in the history by incrementing the pointer. + * Returns the record at the new current position, or null if already at the end of history. + */ + public fun forward(): Record? { + // Can't go forward if we're already at the end + if (currentIndex >= historyList.size - 1) return null + + return Snapshot.withoutReadObservation { + currentIndex++ + historyList.getOrNull(currentIndex) + } + } + + /** + * Clear the forward history. Useful when you want to prevent forward navigation. + */ + public fun clearForward() { + if (currentIndex < historyList.size - 1) { + historyList.subList(currentIndex + 1, historyList.size).clear() + } + } + + /** + * Saves both the history list and current index in the state store. + */ + override fun saveState() { + val rootScreen = historyList.firstOrNull()?.screen ?: return + stateStore[rootScreen] = Pair(historyList.toList(), currentIndex) + } + + /** + * Restores the saved history list and current index from the state store. + */ + override fun restoreState(screen: Screen): Boolean { + val stored = stateStore[screen] + if (stored != null && stored.first.isNotEmpty()) { + // Clear current history and restore saved state + historyList.clear() + historyList.addAll(stored.first) + currentIndex = stored.second + // Clear the stored state + stateStore.remove(screen) + return true + } + return false + } + + override fun peekState(): List { + return stateStore.keys.toList() + } + + override fun removeState(screen: Screen): Boolean { + return stateStore.remove(screen) != null + } + + override fun containsRecord(record: Record, includeSaved: Boolean): Boolean { + // Check in the history list + if (record in historyList) return true + + if (includeSaved && stateStore.isNotEmpty()) { + // If we're checking our saved lists too, iterate through them and check + for ((stored, _) in stateStore.values) { + if (record in stored) return true + } + } + return false + } + + override fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean { + if (depth < 0) return false + + // Check in the visible back stack (from currentIndex backwards) + for (i in currentIndex downTo 0.coerceAtLeast(currentIndex - depth)) { + if (historyList.getOrNull(i)?.key == key) return true + } + + // Check in the forward stack + for (i in currentIndex + 1..min(currentIndex + depth, historyList.size - 1)) { + if (historyList.getOrNull(i)?.key == key) return true + } + + // If includeSaved, check saved backstack states too + if (includeSaved && stateStore.isNotEmpty()) { + for ((stored, _) in stateStore.values) { + if (stored.take(depth).any { it.key == key }) return true + } + } + return false + } + + internal companion object { + @Suppress("UNCHECKED_CAST") + val Saver = + listSaver( + save = { value -> + buildList { + with(Record.Saver) { + // First: save the history list + add(value.historyList.mapNotNull { save(it) }) + // Second: save the current index + add(value.currentIndex) + // Third: save the state store + val stateStoreData = + value.stateStore.map { (screen, pair) -> + mapOf( + "screen" to screen, + "records" to pair.first.mapNotNull { save(it) }, + "index" to pair.second, + ) + } + add(stateStoreData) + } + } + }, + restore = { value -> + val list = value as List + SaveableNavStack().also { navStack -> + with(Record.Saver) { + // First: restore the history list + (list[0] as List>).mapNotNullTo(navStack.historyList) { restore(it) } + // Second: restore the current index + navStack.currentIndex = list[1] as Int + // Third: restore the state store + @Suppress("UNCHECKED_CAST") + (list[2] as List>).forEach { stateEntry -> + val screen = stateEntry["screen"] as Screen + val records = + (stateEntry["records"] as List>).mapNotNull { restore(it) } + val index = stateEntry["index"] as Int + navStack.stateStore[screen] = Pair(records, index) + } + } + } + }, + ) + } +} diff --git a/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/NavStackTest.kt b/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/NavStackTest.kt new file mode 100644 index 0000000000..82f74462b3 --- /dev/null +++ b/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/NavStackTest.kt @@ -0,0 +1,307 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 + +package com.slack.circuit.backstack + +import androidx.compose.runtime.saveable.SaverScope +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import com.slack.circuit.internal.test.TestScreen +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SaveableNavStackTest { + + @Test + fun test_basic_forward_and_backward_navigation() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) + + assertThat(navStack.historyList).hasSize(4) + assertThat(navStack.size).isEqualTo(4) + assertThat(navStack.canGoForward).isFalse() + assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenC) + + // Navigate back + val popped = navStack.pop() + assertThat(popped?.screen).isEqualTo(TestScreen.ScreenB) + assertThat(navStack.historyList).hasSize(4) // History list doesn't shrink + assertThat(navStack.size).isEqualTo(3) // But visible size does + assertThat(navStack.forwardSize).isEqualTo(1) + assertThat(navStack.canGoForward).isTrue() + assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenB) + + // Navigate back again + navStack.pop() + assertThat(navStack.size).isEqualTo(2) + assertThat(navStack.forwardSize).isEqualTo(2) + assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenA) + + // Navigate forward + val forwarded = navStack.forward() + assertThat(forwarded?.screen).isEqualTo(TestScreen.ScreenB) + assertThat(navStack.size).isEqualTo(3) + assertThat(navStack.forwardSize).isEqualTo(1) + assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenB) + + // Navigate forward again + navStack.forward() + assertThat(navStack.size).isEqualTo(4) + assertThat(navStack.forwardSize).isEqualTo(0) + assertThat(navStack.canGoForward).isFalse() + assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenC) + } + + @Test + fun test_forward_stack_cleared_on_push() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + + // Navigate back to build up forward history + navStack.pop() + assertThat(navStack.forwardSize).isEqualTo(1) + assertThat(navStack.canGoForward).isTrue() + + // Push a new screen - should clear forward history + navStack.push(TestScreen.ScreenC) + assertThat(navStack.forwardSize).isEqualTo(0) + assertThat(navStack.canGoForward).isFalse() + assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenC) + // History list should only contain items up to the new push + assertThat(navStack.historyList).hasSize(3) + } + + @Test + fun test_cannot_pop_root() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + assertThat(navStack.historyList).hasSize(1) + assertThat(navStack.size).isEqualTo(1) + + // Try to pop the root - should return null + val popped = navStack.pop() + assertThat(popped).isNull() + assertThat(navStack.historyList).hasSize(1) + assertThat(navStack.size).isEqualTo(1) + assertThat(navStack.forwardSize).isEqualTo(0) + } + + @Test + fun test_forward_returns_null_when_empty() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + + assertThat(navStack.canGoForward).isFalse() + val forwarded = navStack.forward() + assertThat(forwarded).isNull() + } + + @Test + fun test_pop_with_clear_forward() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + + assertThat(navStack.historyList).hasSize(3) + assertThat(navStack.forwardSize).isEqualTo(0) + + // Use pop(clearForward = true) - should remove forward history + val popped = navStack.pop(clearForward = true) + assertThat(popped?.screen).isEqualTo(TestScreen.ScreenA) + assertThat(navStack.historyList).hasSize(2) + assertThat(navStack.size).isEqualTo(2) + assertThat(navStack.forwardSize).isEqualTo(0) + assertThat(navStack.canGoForward).isFalse() + } + + @Test + fun test_clear_forward() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + + // Navigate back to build up forward history + navStack.pop() + navStack.pop() + assertThat(navStack.forwardSize).isEqualTo(2) + assertThat(navStack.canGoForward).isTrue() + assertThat(navStack.historyList).hasSize(3) + + // Clear forward history + navStack.clearForward() + assertThat(navStack.forwardSize).isEqualTo(0) + assertThat(navStack.canGoForward).isFalse() + assertThat(navStack.historyList).hasSize(1) + } + + @Test + fun test_contains_record_includes_forward_history() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + val recordA = SaveableBackStack.Record(TestScreen.ScreenA) + val recordB = SaveableBackStack.Record(TestScreen.ScreenB) + + navStack.push(recordA) + navStack.push(recordB) + + // Both records should be in history list + assertThat(navStack.containsRecord(recordA, includeSaved = false)).isTrue() + assertThat(navStack.containsRecord(recordB, includeSaved = false)).isTrue() + + // Pop to move pointer back + navStack.pop() + + // Both A and B should still be in history list + assertThat(navStack.containsRecord(recordA, includeSaved = false)).isTrue() + assertThat(navStack.containsRecord(recordB, includeSaved = false)).isTrue() + } + + @Test + fun test_is_record_reachable_includes_forward_history() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + val recordA = SaveableBackStack.Record(TestScreen.ScreenA) + val recordB = SaveableBackStack.Record(TestScreen.ScreenB) + + navStack.push(recordA) + navStack.push(recordB) + + // Pop to move pointer back + navStack.pop() + + // Both keys should still be reachable + assertThat(navStack.isRecordReachable(recordB.key, depth = 10, includeSaved = false)).isTrue() + assertThat(navStack.isRecordReachable(recordA.key, depth = 10, includeSaved = false)).isTrue() + } + + @Test + fun test_save_and_restore_state() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + + assertThat(navStack.historyList).hasSize(3) + assertThat(navStack.stateStore).isEmpty() + + // Save state (saves both history and current index) + navStack.saveState() + assertThat(navStack.stateStore).hasSize(1) + + navStack.stateStore[TestScreen.RootAlpha].let { saved -> + assertThat(saved).isNotNull() + saved!! + assertThat(saved.first).hasSize(3) + assertThat(saved.first[0].screen).isEqualTo(TestScreen.RootAlpha) + assertThat(saved.first[1].screen).isEqualTo(TestScreen.ScreenA) + assertThat(saved.first[2].screen).isEqualTo(TestScreen.ScreenB) + assertThat(saved.second).isEqualTo(2) // Current index + } + + // Clear and add new navigation + navStack.historyList.clear() + navStack.push(TestScreen.RootBeta) + navStack.push(TestScreen.ScreenC) + + assertThat(navStack.historyList).hasSize(2) + + // Restore state + val restored = navStack.restoreState(TestScreen.RootAlpha) + assertThat(restored).isTrue() + + assertThat(navStack.historyList).hasSize(3) + assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenB) + assertThat(navStack.stateStore).isEmpty() + } + + @Test + fun test_saveable_save_and_restore() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + + // Navigate back to populate forward history + navStack.pop() + assertThat(navStack.size).isEqualTo(2) + assertThat(navStack.forwardSize).isEqualTo(1) + + val saved = save(navStack) + assertThat(saved).isNotNull() + saved!! + + val restored = SaveableNavStack.Saver.restore(saved) + assertThat(restored).isNotNull() + restored!! + + assertThat(navStack.historyList.toList()).isEqualTo(restored.historyList.toList()) + assertThat(navStack.currentIndex).isEqualTo(restored.currentIndex) + assertThat(navStack.stateStore.toMap()).isEqualTo(restored.stateStore.toMap()) + } + + @Test + fun test_saveable_save_and_restore_with_state_store() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + + // Save state and switch to different root + navStack.saveState() + navStack.historyList.clear() + navStack.push(TestScreen.RootBeta) + navStack.push(TestScreen.ScreenC) + + // Navigate back to populate forward history + navStack.pop() + + val saved = save(navStack) + assertThat(saved).isNotNull() + saved!! + + val restored = SaveableNavStack.Saver.restore(saved) + assertThat(restored).isNotNull() + restored!! + + assertThat(navStack.historyList.toList()).isEqualTo(restored.historyList.toList()) + assertThat(navStack.currentIndex).isEqualTo(restored.currentIndex) + assertThat(navStack.stateStore.toMap()).isEqualTo(restored.stateStore.toMap()) + } + + @Test + fun test_guard_pushing_same_top_record() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + assertTrue(navStack.push(TestScreen.ScreenA)) + assertTrue(navStack.push(TestScreen.ScreenB)) + assertFalse(navStack.push(TestScreen.ScreenB)) + } + + @Test + fun test_forward_size() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) + + assertThat(navStack.forwardSize).isEqualTo(0) + + navStack.pop() + assertThat(navStack.forwardSize).isEqualTo(1) + + navStack.pop() + assertThat(navStack.forwardSize).isEqualTo(2) + + navStack.forward() + assertThat(navStack.forwardSize).isEqualTo(1) + } +} + +private fun save(navStack: SaveableNavStack) = + with(SaveableNavStack.Saver) { + val scope = SaverScope { true } + scope.save(navStack) + } From 30202cc4f35390d4a06fa1f07122b39276b87222 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 14 Nov 2025 08:36:03 -0800 Subject: [PATCH 34/44] roll these back to stable releases --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b609e13b9e..c76a23f782 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,9 +17,9 @@ dokka = "2.1.0" eithernet = "2.0.0" jb-compose = "1.10.0-beta01" jb-compose-material-icons-core = "1.7.3" -jb-compose-material3 = "1.10.0-alpha04" +jb-compose-material3 = "1.9.3" jb-compose-navigationevent = "1.0.0-beta01" -jb-lifecycle = "2.10.0-alpha04" +jb-lifecycle = "2.9.6" jdk = "23" jvmTarget = "11" kct = "0.11.0" From 33e22ab04ba7449cb966fef09d526f747d4396dc Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 14 Nov 2025 12:53:13 -0800 Subject: [PATCH 35/44] Actual SaveableNavStack implementation --- .../com/slack/circuit/backstack/BackStack.kt | 80 ++-- .../com/slack/circuit/backstack/NavStack.kt | 147 +++++- .../circuit/backstack/SaveableBackStack.kt | 56 +-- .../circuit/backstack/SaveableNavStack.kt | 429 ++++++++++------- .../slack/circuit/backstack/NavStackTest.kt | 307 ------------ .../circuit/backstack/SaveableNavStackTest.kt | 441 ++++++++++++++++++ .../slack/circuit/foundation/NavDecoration.kt | 17 +- .../foundation/NavigableCircuitContent.kt | 12 +- 8 files changed, 917 insertions(+), 572 deletions(-) delete mode 100644 backstack/src/commonTest/kotlin/com/slack/circuit/backstack/NavStackTest.kt create mode 100644 backstack/src/commonTest/kotlin/com/slack/circuit/backstack/SaveableNavStackTest.kt diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt index 5f1023f7c4..3c5d7795dd 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt @@ -29,39 +29,67 @@ import com.slack.circuit.runtime.screen.Screen */ @Stable public interface BackStack : NavStack, Iterable { - // BackStack inherits all methods from NavStack -} + /** The number of records contained in this [BackStack] that will be seen by an iterator. */ + public override val size: Int -public fun BackStack.push(record: R): Boolean { - return add(record) -} + /** The top-most record in the [BackStack], or `null` if the [BackStack] is empty. */ + public override val currentRecord: R? + get() = topRecord -public fun BackStack.push(screen: Screen): Boolean { - return add(screen) -} + /** The bottom-most record in the [BackStack], or `null` if the [BackStack] is empty. */ + public override val rootRecord: R? -/** - * Attempt to pop the top item off of the back stack, returning the popped [Record] if popping was - * successful or `null` if no entry was popped. - */ -public fun BackStack.pop(): R? { - return Snapshot.withMutableSnapshot { - // todo edge cases - while (canGoForward) { - // Move forward and remove the previous record. - move(NavStack.Direction.Forward) - remove(NavStack.Direction.Backward) + /** + * Push a new [Record] onto the back stack. The new record will become the top of the stack. + * + * @param record The record to push onto the stack. + * @return If the [record] was successfully pushed onto the back stack + */ + public fun push(record: R): Boolean + + /** + * Push a new [Screen] onto the back stack. This will be enveloped in a [Record] and the new + * record will become the top of the stack. + * + * @param screen The screen to push onto the stack. + * @return If the [screen] was successfully pushed onto the back stack + */ + public fun push(screen: Screen): Boolean + + /** + * Attempt to pop the top item off of the back stack, returning the popped [Record] if popping was + * successful or `null` if no entry was popped. + */ + public fun pop(): R? + + /** + * Pop records off the top of the backstack until one is found that matches the given predicate. + */ + public fun popUntil(predicate: (R) -> Boolean): List { + return buildList { + while (topRecord?.let(predicate) == false) { + val popped = pop() ?: break + add(popped) + } } - remove(NavStack.Direction.Backward) } + + @Stable public interface Record : NavStack.Record } -/** Pop records off the top of the stack until one is found that matches the given predicate. */ -public fun NavStack.popUntil(predicate: (R) -> Boolean): List { - return buildList { - while (currentRecord?.let(predicate) == false) { - val popped = this@popUntil.remove(NavStack.Direction.Backward) ?: break - add(popped) +/** `true` if the [BackStack] contains no records. [Iterable.firstOrNull] will return `null`. */ +public val BackStack.isEmpty: Boolean + get() = size == 0 + +/** `true` if the [BackStack] contains exactly one record. */ +public val BackStack.isAtRoot: Boolean + get() = size == 1 + +/** Clear any saved state from the [BackStack]. */ +public fun BackStack.clearState() { + Snapshot.withMutableSnapshot { + for (screen in peekState()) { + removeState(screen) } } } diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt index 37f95dd40d..65b4b48274 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt @@ -8,39 +8,142 @@ import com.slack.circuit.runtime.screen.Screen /** * A navigation stack that supports forward navigation in addition to backward navigation. This is * the base interface for navigation stacks in Circuit. + * + * ## Navigation Model + * + * NavStack maintains a list of records with a current position, supporting browser-style + * navigation: + * - **topRecord**: The newest entry (most recently added) + * - **currentRecord**: The currently visible/active entry + * - **rootRecord**: The oldest entry (initial screen) + * + * Navigation operations: + * - **add()**: Adds a new entry at the top and truncates forward history + * - **remove()**: Removes the current entry (and forward history), makes the next entry current + * - **move()**: Changes position without modifying the stack + * - **snapshot()**: Captures the current state immutably + * + * ## Example Usage + * + * ```kotlin + * val navStack = rememberSaveableNavStack(HomeScreen) + * + * // Build up a stack: [Home, Profile, Settings] + * navStack.add(ProfileScreen) + * navStack.add(SettingsScreen) + * // Stack: [Settings (current), Profile, Home] + * + * // Navigate backward through history + * navStack.move(Direction.Backward) + * // Stack: [Settings, Profile (current), Home] + * + * navStack.move(Direction.Backward) + * // Stack: [Settings, Profile, Home (current)] + * + * // Navigate forward + * navStack.move(Direction.Forward) + * // Stack: [Settings, Profile (current), Home] + * + * // Adding a new screen truncates forward history + * navStack.add(NotificationsScreen) + * // Stack: [Notifications (current), Profile, Home] + * // Settings was removed from forward history + * + * // Remove current entry (also truncates forward history if any) + * navStack.remove() + * // Stack: [Profile (current), Home] + * ``` + * + * @see SaveableNavStack for a concrete implementation */ @Stable public interface NavStack { /** The number of records in the stack. */ public val size: Int - /** The top-most record in the stack, including the or `null` if the stack is empty. */ + /** + * The top-most (newest) record in the stack, or `null` if the stack is empty. This is always the + * most recently added record, regardless of the current position. + */ public val topRecord: R? - /** The active record in the stack, or `null` if the stack is empty. */ + /** + * The currently active record in the stack, or `null` if the stack is empty. This is the record + * at the current navigation position, which may differ from [topRecord] when the user has + * navigated backward. + */ public val currentRecord: R? - /** The bottom-most record in the stack, or `null` if the stack is empty. */ + /** + * The bottom-most (oldest) record in the stack, or `null` if the stack is empty. This is + * typically the initial root screen of the navigation flow. + */ public val rootRecord: R? + /** + * Adds a screen to the stack, wrapping it in a new [Record]. + * + * Implementations may truncate forward history when adding from a non-top position, similar to + * browser navigation behavior. + * + * @param screen The screen to add + * @return true if the screen was added, false if it was a duplicate of the current top + */ public fun add(screen: Screen): Boolean + /** + * Adds a record to the stack. + * + * Implementations may truncate forward history when adding from a non-top position, similar to + * browser navigation behavior. + * + * @param record The record to add + * @return true if the record was added, false if it was a duplicate of the current top + */ public fun add(record: R): Boolean /** - * Removes and returns the top record from the stack and then moves the stack one position in the - * specified direction. + * Removes and returns the current record from the stack. + * + * When called from a non-top position, this operation: + * 1. Truncates any forward history (entries between top and current) + * 2. Removes the current record + * 3. Sets the new top as current * - * @param direction The direction of removal (Forward or Backward). - * @return The removed record, or null if the stack is empty or removal is not possible. + * This provides browser-like "remove and discard forward history" behavior. + * + * @return The removed record, or null if the stack is empty */ - public fun remove(direction: Direction): R? + public fun remove(): R? + /** + * Moves the current position in the stack without removing any records. + * - **Direction.Forward**: Moves toward [topRecord] (newer entries) + * - **Direction.Backward**: Moves toward [rootRecord] (older entries) + * + * Use [canGoForward] and [canGoBack] to check if movement is possible. + * + * @param direction The direction to move + * @return true if the move was successful, false if already at the boundary + */ public fun move(direction: Direction): Boolean /** - * Saves the current stack entry list in an internal state store. It can be later restored by the - * root screen to [restoreState]. + * Creates a snapshot of the current stack state, including all entries and the current position. + * + * The returned snapshot is immutable and can be used to capture the current navigation state for + * inspection, serialization, or other purposes. + * + * @return A [Snapshot] containing the current entries and position + */ + public fun snapshot(): Snapshot + + /** + * Saves the current stack entry list and position in an internal state store. It can be later + * restored by the root screen to [restoreState]. + * + * The saved state includes both the list of records and the current position, allowing for + * complete restoration of the navigation state. * * This call will overwrite any existing stored state with the same root screen. */ @@ -48,7 +151,7 @@ public interface NavStack { /** * Restores the saved state with the given [screen], adding it on top of the existing entry list. - * If you wish to replace the current entry list, you should [pop] all of the existing entries off + * If you wish to replace the current entry list, you should [remove] all of the existing entries * before calling this function. * * @param screen The root screen which was previously saved using [saveState]. @@ -93,8 +196,18 @@ public interface NavStack { */ public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean + /** + * Navigation direction within a stack. + * - **Forward**: Toward [topRecord] (newer/more recent entries) + * - **Backward**: Toward [rootRecord] (older/earlier entries) + * + * This mirrors browser history navigation where Forward goes to newer pages and Backward goes to + * older pages. + */ public enum class Direction { + /** Navigate toward the top (newer entries) of the stack. */ Forward, + /** Navigate toward the root (older entries) of the stack. */ Backward, } @@ -112,6 +225,16 @@ public interface NavStack { /** The [Screen] that should present this record. */ public val screen: Screen } + + /** + * A snapshot of a nav stack state, capturing both the entries and the current position. Used for + * saving and restoring stack state, and for iterating over stack entries. + */ + @Stable + public interface Snapshot : Iterable { + public val entries: List + public val currentIndex: Int + } } public val NavStack.currentScreen: Screen? @@ -124,7 +247,7 @@ public val NavStack.lastIndex: Int get() = size - 1 public val NavStack.isAtRoot: Boolean - get() = size == 1 + get() = currentRecord == rootRecord public val NavStack.isAtTop: Boolean get() = currentRecord == topRecord diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index bc87b9892a..27085a4adc 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -88,23 +88,17 @@ internal constructor( override val rootRecord: Record? get() = entryList.lastOrNull() - override val currentIndex: Int - get() = 0 + override fun add(screen: Screen): Boolean = push(Record(screen)) - override val currentRecord: Record? - get() = entryList.firstOrNull() + override fun add(record: Record): Boolean = push(record) - override fun add( - index: Int, - screen: Screen - ): Record? { - return add(index, Record(screen, emptyMap())) - } + override fun remove(): Record? = pop() + + override fun move(direction: NavStack.Direction): Boolean = false - override fun add( - index: Int, - record: Record - ): Record? { + public override fun push(screen: Screen): Boolean = push(Record(screen)) + + public override fun push(record: Record): Boolean { val topRecord = Snapshot.withoutReadObservation { topRecord } // Guard pushing the exact same record value to the top, records.key is always unique so verify // the parameters individually. @@ -114,26 +108,15 @@ internal constructor( } else false } - override fun remove(index: Int): Record? { - TODO("Not yet implemented") - } - - override fun backward(): Record? { - TODO("Not yet implemented") - } - - public fun push(screen: Screen, args: Map): Boolean { - return - } - - public override fun push(record: Record): Boolean { - - } - override fun pop(): Record? { return Snapshot.withoutReadObservation { entryList.removeFirstOrNull() } } + override fun snapshot(): NavStack.Snapshot { + // BackStack always has current at top (index 0) + return BackStackSnapshot(entryList.toList()) + } + override fun saveState() { val rootScreen = entryList.last().screen stateStore[rootScreen] = entryList.toList() @@ -190,11 +173,22 @@ internal constructor( return false } + /** + * A snapshot of a back stack state. Since BackStack always has current at the top, currentIndex + * is always 0. + */ + public data class BackStackSnapshot(override val entries: List) : + NavStack.Snapshot { + override val currentIndex: Int = 0 + + override fun iterator(): Iterator = entries.iterator() + } + public data class Record( override val screen: Screen, val args: Map = emptyMap(), @OptIn(ExperimentalUuidApi::class) override val key: String = Uuid.random().toString(), - ) : NavStack.Record { + ) : BackStack.Record { internal companion object { val Saver: Saver = diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt index 3f7157ca05..e798b9e32e 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt @@ -1,33 +1,22 @@ -/* - * Copyright (C) 2025 Slack Technologies, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ package com.slack.circuit.backstack import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.snapshots.SnapshotStateList -import com.slack.circuit.backstack.SaveableBackStack.Record +import com.slack.circuit.backstack.SaveableNavStack.Record import com.slack.circuit.runtime.screen.Screen +import kotlin.collections.set import kotlin.math.min +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Creates and remembers a [SaveableNavStack] with the given [root] screen. @@ -54,166 +43,205 @@ public fun rememberSaveableNavStack(initialScreens: List): SaveableNavSt return rememberSaveable(initialScreens, saver = SaveableNavStack.Saver) { SaveableNavStack().apply { for (screen in initialScreens) { - push(screen) + add(screen) } } } } /** - * A [NavStack] implementation that supports both backward and forward navigation with state saving. + * A [NavStack] that supports saving its state via [rememberSaveable]. See + * [rememberSaveableNavStack]. + * + * Unlike [SaveableBackStack], this implementation supports forward navigation, removal in both + * directions, and moving through the stack without removing entries. + * + * ## Internal Structure + * + * The stack maintains an [entryList] of records and a [currentIndex] to track position: + * ``` + * entryList indices: [0, 1, 2, 3, 4, 5] + * ^ ^ ^ + * topRecord currentIndex rootRecord + * (newest) (current) (oldest) + * ``` + * + * ## Navigation Examples + * + * ### Moving Forward (toward top/newer entries): + * ``` + * Before: [A, B, C, D, E, F] + * ^ + * currentIndex = 3 + * + * move(Forward) + * + * After: [A, B, C, D, E, F] + * ^ + * currentIndex = 2 + * ``` + * + * ### Moving Backward (toward root/older entries): + * ``` + * Before: [A, B, C, D, E, F] + * ^ + * currentIndex = 2 + * + * move(Backward) * - * This implementation uses a single list with a pointer (currentIndex) to track the current - * position in the navigation history: - * - Items at indices 0 to currentIndex-1 are "back" items - * - Item at currentIndex is the current screen - * - Items after currentIndex are "forward" items + * After: [A, B, C, D, E, F] + * ^ + * currentIndex = 3 + * ``` * - * When navigating back, the pointer is decremented. - * When navigating forward, the pointer is incremented. - * When pushing a new screen, items after the current position are removed and the new screen is - * added at the end (standard browser behavior). + * ### Adding New Entry (truncates forward history): + * ``` + * Before: [A, B, C, D, E, F] + * ^ + * currentIndex = 2 (pointing to C) + * + * add(G) + * + * After: [G, C, D, E, F] + * ^ + * currentIndex = 0 (pointing to G) + * (A & B removed, G becomes new top and current) + * ``` + * + * ### Removing with Direction: + * ``` + * # Forward removal (removes current, moves toward top) + * Before: [A, B, C, D, E, F] + * ^ + * currentIndex = 3 + * + * remove(Forward) + * + * After: [A, B, C, E, F] + * ^ + * currentIndex = 2 + * + * # Backward removal (removes current, stays at same logical position) + * Before: [A, B, C, D, E, F] + * ^ + * currentIndex = 3 + * + * remove(Backward) + * + * After: [A, B, C, E, F] + * ^ + * currentIndex = 3 (now points to E) + * ``` */ -@Stable public class SaveableNavStack internal constructor( - // All visible for testing - internal val historyList: SnapshotStateList = mutableStateListOf(), - internal val initialIndex: Int = 0, - internal val stateStore: MutableMap, Int>> = mutableMapOf(), -) : NavStack { - + // Both visible for testing + internal val entryList: SnapshotStateList = mutableStateListOf(), + internal val stateStore: MutableMap> = mutableMapOf(), + initialIndex: Int = -1, +) : NavStack, Iterable { + + // Track the current position in the stack (index into entryList) + // Index 0 is the top (newest), size-1 is the root (oldest) private var currentIndex by mutableIntStateOf(initialIndex) public constructor(root: Screen) : this(Record(root)) public constructor(root: Record) : this() { - push(root) + add(root) + // Set current to the new top + currentIndex = 0 } - /** - * The size reflects only the visible back stack (from index 0 to currentIndex, inclusive). - * This maintains compatibility with BackStack behavior. - */ override val size: Int - get() = currentIndex + 1 - - /** - * Iterator returns only the visible back stack items (from index 0 to currentIndex). - * This maintains compatibility with BackStack behavior where iteration is top-first. - */ - override fun iterator(): Iterator = historyList.subList(0, currentIndex + 1).iterator() + get() = entryList.size - public override val topRecord: Record? - get() = historyList.getOrNull(currentIndex) + override fun iterator(): Iterator = entryList.iterator() - override val rootRecord: Record? - get() = historyList.getOrNull(0) - - /** - * Returns true if forward navigation is possible (i.e., there are items after currentIndex). - */ - public val canGoForward: Boolean - get() = currentIndex < historyList.size - 1 + override val topRecord: Record? + get() = entryList.firstOrNull() - /** - * Returns the number of items in the forward stack. - */ - public val forwardSize: Int - get() = (historyList.size - 1 - currentIndex).coerceAtLeast(0) + override val currentRecord: Record? + get() = entryList.getOrNull(currentIndex) - public override fun push(screen: Screen): Boolean { - return push(screen, emptyMap()) - } + override val rootRecord: Record? + get() = entryList.lastOrNull() - public fun push(screen: Screen, args: Map): Boolean { - return push(Record(screen, args)) + override fun add(screen: Screen): Boolean { + return add(Record(screen)) } - public override fun push(record: Record): Boolean { + override fun add(record: Record): Boolean { val topRecord = Snapshot.withoutReadObservation { topRecord } // Guard pushing the exact same record value to the top, records.key is always unique so verify // the parameters individually. return if (topRecord?.screen != record.screen || topRecord.args != record.args) { - // Remove all forward history when pushing a new record - if (currentIndex < historyList.size - 1) { - historyList.subList(currentIndex + 1, historyList.size).clear() + // When adding a new record, truncate any entries above the current position (forward history) + if (currentIndex > 0) { + // Remove all entries before currentIndex + repeat(currentIndex) { entryList.removeAt(0) } } - // Add new record at the end and update pointer - historyList.add(record) - currentIndex = historyList.size - 1 + // Add the new record at the top + entryList.add(0, record) + // Set current to the new top + currentIndex = 0 true } else false } - /** - * Navigate back in the history by decrementing the pointer. - * Returns the record at the new current position, or null if already at the root. - * - * @param clearForward if true, removes forward history when going back (default: false) - */ - public fun pop(clearForward: Boolean = false): Record? { - // Don't go back if we're already at the root - if (currentIndex <= 0) return null - - return Snapshot.withoutReadObservation { - if (clearForward) { - // Remove the current item and everything after it - historyList.subList(currentIndex, historyList.size).clear() + override fun remove(): Record? { + if (currentIndex < 0 || entryList.isEmpty()) return null + return Snapshot.withMutableSnapshot { + // When removing the current record, truncate any entries above the current position (forward + // history) + if (currentIndex > 0) { + // Remove all entries before currentIndex + repeat(currentIndex) { entryList.removeAt(0) } } - // Move pointer back - currentIndex-- - historyList.getOrNull(currentIndex) + // Set current to the new top + currentIndex = 0 + // Remove the current record + entryList.removeAt(0) } } - /** - * Standard pop() implementation that maintains forward history. - * This implements the BackStack interface. - */ - override fun pop(): Record? = pop(clearForward = false) - - /** - * Navigate forward in the history by incrementing the pointer. - * Returns the record at the new current position, or null if already at the end of history. - */ - public fun forward(): Record? { - // Can't go forward if we're already at the end - if (currentIndex >= historyList.size - 1) return null - - return Snapshot.withoutReadObservation { - currentIndex++ - historyList.getOrNull(currentIndex) + override fun move(direction: NavStack.Direction): Boolean { + return when (direction) { + NavStack.Direction.Forward -> { + // Move forward (toward top, decrease index) + if (currentIndex > 0) { + currentIndex-- + true + } else false + } + NavStack.Direction.Backward -> { + // Move backward (toward root, increase index) + if (currentIndex < entryList.lastIndex) { + currentIndex++ + true + } else false + } } } - /** - * Clear the forward history. Useful when you want to prevent forward navigation. - */ - public fun clearForward() { - if (currentIndex < historyList.size - 1) { - historyList.subList(currentIndex + 1, historyList.size).clear() - } + override fun snapshot(): NavStack.Snapshot { + return SaveableSnapshot(entryList.toList(), currentIndex) } - /** - * Saves both the history list and current index in the state store. - */ override fun saveState() { - val rootScreen = historyList.firstOrNull()?.screen ?: return - stateStore[rootScreen] = Pair(historyList.toList(), currentIndex) + if (entryList.isNotEmpty()) { + val rootScreen = entryList.last().screen + stateStore[rootScreen] = SaveableSnapshot(entryList.toList(), currentIndex) + } } - /** - * Restores the saved history list and current index from the state store. - */ override fun restoreState(screen: Screen): Boolean { val stored = stateStore[screen] - if (stored != null && stored.first.isNotEmpty()) { - // Clear current history and restore saved state - historyList.clear() - historyList.addAll(stored.first) - currentIndex = stored.second + if (stored != null && stored.entries.isNotEmpty()) { + // Add the stored state into the entry list + entryList.addAll(stored.entries) + // Restore the current index + currentIndex = stored.currentIndex // Clear the stored state stateStore.remove(screen) return true @@ -230,13 +258,13 @@ internal constructor( } override fun containsRecord(record: Record, includeSaved: Boolean): Boolean { - // Check in the history list - if (record in historyList) return true + // If it's in the main entry list, return true + if (record in entryList) return true if (includeSaved && stateStore.isNotEmpty()) { - // If we're checking our saved lists too, iterate through them and check - for ((stored, _) in stateStore.values) { - if (record in stored) return true + // If we're checking our saved snapshots too, iterate through them and check + for (snapshot in stateStore.values) { + if (record in snapshot.entries) return true } } return false @@ -244,68 +272,115 @@ internal constructor( override fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean { if (depth < 0) return false - - // Check in the visible back stack (from currentIndex backwards) - for (i in currentIndex downTo 0.coerceAtLeast(currentIndex - depth)) { - if (historyList.getOrNull(i)?.key == key) return true - } - - // Check in the forward stack - for (i in currentIndex + 1..min(currentIndex + depth, historyList.size - 1)) { - if (historyList.getOrNull(i)?.key == key) return true + // Check in the current entry list + for (i in 0 until min(depth, entryList.size)) { + if (entryList[i].key == key) return true } - - // If includeSaved, check saved backstack states too + // If includeSaved, check saved stack states too if (includeSaved && stateStore.isNotEmpty()) { - for ((stored, _) in stateStore.values) { - if (stored.take(depth).any { it.key == key }) return true + val storedSnapshots = stateStore.values + for ((i, snapshot) in storedSnapshots.withIndex()) { + if (i >= depth) break + // snapshot.entries is immutable, so safely get the record. + if (snapshot.entries.getOrNull(i)?.key == key) return true } } return false } + public data class Record( + override val screen: Screen, + val args: Map = emptyMap(), + @OptIn(ExperimentalUuidApi::class) override val key: String = Uuid.random().toString(), + ) : NavStack.Record { + + internal companion object { + val Saver: Saver = + mapSaver( + save = { value -> + buildMap { + put("screen", value.screen) + put("args", value.args) + put("key", value.key) + } + }, + restore = { map -> + @Suppress("UNCHECKED_CAST") + Record( + screen = map["screen"] as Screen, + args = map["args"] as Map, + key = map["key"] as String, + ) + }, + ) + } + } + + public data class SaveableSnapshot( + override val entries: List, + override val currentIndex: Int, + ) : NavStack.Snapshot { + override fun iterator(): Iterator = entries.iterator() + + internal companion object { + val Saver: Saver, Any> = + mapSaver( + save = { value -> + buildMap { + with(Record.Saver) { put("entries", value.entries.mapNotNull { save(it) }) } + put("currentIndex", value.currentIndex) + } + }, + restore = { map -> + @Suppress("UNCHECKED_CAST") + SaveableSnapshot( + entries = (map["entries"] as List>).mapNotNull { Record.Saver.restore(it) }, + currentIndex = map["currentIndex"] as Int, + ) + }, + ) + } + } + internal companion object { @Suppress("UNCHECKED_CAST") val Saver = - listSaver( + listSaver>( save = { value -> buildList { - with(Record.Saver) { - // First: save the history list - add(value.historyList.mapNotNull { save(it) }) - // Second: save the current index - add(value.currentIndex) - // Third: save the state store - val stateStoreData = - value.stateStore.map { (screen, pair) -> - mapOf( - "screen" to screen, - "records" to pair.first.mapNotNull { save(it) }, - "index" to pair.second, - ) - } - add(stateStoreData) - } + // Add the current index + add(listOf(value.currentIndex)) + // Save the entry list + with(Record.Saver) { add(value.entryList.mapNotNull { save(it) }) } + // Now add any snapshots from the state store + with(SaveableSnapshot.Saver) { add(value.stateStore.values.map { save(it) }) } } }, restore = { value -> - val list = value as List + var currentIndex = -1 SaveableNavStack().also { navStack -> - with(Record.Saver) { - // First: restore the history list - (list[0] as List>).mapNotNullTo(navStack.historyList) { restore(it) } - // Second: restore the current index - navStack.currentIndex = list[1] as Int - // Third: restore the state store - @Suppress("UNCHECKED_CAST") - (list[2] as List>).forEach { stateEntry -> - val screen = stateEntry["screen"] as Screen - val records = - (stateEntry["records"] as List>).mapNotNull { restore(it) } - val index = stateEntry["index"] as Int - navStack.stateStore[screen] = Pair(records, index) + value.forEachIndexed { index, item -> + when (index) { + 0 -> { + // The first list is the current index + currentIndex = item.first() as Int + } + 1 -> { + // The second list is the entry list + item.mapNotNullTo(navStack.entryList) { Record.Saver.restore(it as List) } + } + else -> { + // Any list after that is from the state store (as snapshots) + item + .mapNotNull { SaveableSnapshot.Saver.restore(it as List) } + .forEach { snapshot -> + // The key is always the root screen (i.e. last item) + navStack.stateStore[snapshot.entries.last().screen] = snapshot + } + } } } + navStack.currentIndex = currentIndex } }, ) diff --git a/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/NavStackTest.kt b/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/NavStackTest.kt deleted file mode 100644 index 82f74462b3..0000000000 --- a/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/NavStackTest.kt +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (C) 2025 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 - -package com.slack.circuit.backstack - -import androidx.compose.runtime.saveable.SaverScope -import assertk.assertThat -import assertk.assertions.hasSize -import assertk.assertions.isEmpty -import assertk.assertions.isEqualTo -import assertk.assertions.isFalse -import assertk.assertions.isNotNull -import assertk.assertions.isNull -import assertk.assertions.isTrue -import com.slack.circuit.internal.test.TestScreen -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class SaveableNavStackTest { - - @Test - fun test_basic_forward_and_backward_navigation() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - navStack.push(TestScreen.ScreenB) - navStack.push(TestScreen.ScreenC) - - assertThat(navStack.historyList).hasSize(4) - assertThat(navStack.size).isEqualTo(4) - assertThat(navStack.canGoForward).isFalse() - assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenC) - - // Navigate back - val popped = navStack.pop() - assertThat(popped?.screen).isEqualTo(TestScreen.ScreenB) - assertThat(navStack.historyList).hasSize(4) // History list doesn't shrink - assertThat(navStack.size).isEqualTo(3) // But visible size does - assertThat(navStack.forwardSize).isEqualTo(1) - assertThat(navStack.canGoForward).isTrue() - assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenB) - - // Navigate back again - navStack.pop() - assertThat(navStack.size).isEqualTo(2) - assertThat(navStack.forwardSize).isEqualTo(2) - assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenA) - - // Navigate forward - val forwarded = navStack.forward() - assertThat(forwarded?.screen).isEqualTo(TestScreen.ScreenB) - assertThat(navStack.size).isEqualTo(3) - assertThat(navStack.forwardSize).isEqualTo(1) - assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenB) - - // Navigate forward again - navStack.forward() - assertThat(navStack.size).isEqualTo(4) - assertThat(navStack.forwardSize).isEqualTo(0) - assertThat(navStack.canGoForward).isFalse() - assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenC) - } - - @Test - fun test_forward_stack_cleared_on_push() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - navStack.push(TestScreen.ScreenB) - - // Navigate back to build up forward history - navStack.pop() - assertThat(navStack.forwardSize).isEqualTo(1) - assertThat(navStack.canGoForward).isTrue() - - // Push a new screen - should clear forward history - navStack.push(TestScreen.ScreenC) - assertThat(navStack.forwardSize).isEqualTo(0) - assertThat(navStack.canGoForward).isFalse() - assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenC) - // History list should only contain items up to the new push - assertThat(navStack.historyList).hasSize(3) - } - - @Test - fun test_cannot_pop_root() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - assertThat(navStack.historyList).hasSize(1) - assertThat(navStack.size).isEqualTo(1) - - // Try to pop the root - should return null - val popped = navStack.pop() - assertThat(popped).isNull() - assertThat(navStack.historyList).hasSize(1) - assertThat(navStack.size).isEqualTo(1) - assertThat(navStack.forwardSize).isEqualTo(0) - } - - @Test - fun test_forward_returns_null_when_empty() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - - assertThat(navStack.canGoForward).isFalse() - val forwarded = navStack.forward() - assertThat(forwarded).isNull() - } - - @Test - fun test_pop_with_clear_forward() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - navStack.push(TestScreen.ScreenB) - - assertThat(navStack.historyList).hasSize(3) - assertThat(navStack.forwardSize).isEqualTo(0) - - // Use pop(clearForward = true) - should remove forward history - val popped = navStack.pop(clearForward = true) - assertThat(popped?.screen).isEqualTo(TestScreen.ScreenA) - assertThat(navStack.historyList).hasSize(2) - assertThat(navStack.size).isEqualTo(2) - assertThat(navStack.forwardSize).isEqualTo(0) - assertThat(navStack.canGoForward).isFalse() - } - - @Test - fun test_clear_forward() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - navStack.push(TestScreen.ScreenB) - - // Navigate back to build up forward history - navStack.pop() - navStack.pop() - assertThat(navStack.forwardSize).isEqualTo(2) - assertThat(navStack.canGoForward).isTrue() - assertThat(navStack.historyList).hasSize(3) - - // Clear forward history - navStack.clearForward() - assertThat(navStack.forwardSize).isEqualTo(0) - assertThat(navStack.canGoForward).isFalse() - assertThat(navStack.historyList).hasSize(1) - } - - @Test - fun test_contains_record_includes_forward_history() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - val recordA = SaveableBackStack.Record(TestScreen.ScreenA) - val recordB = SaveableBackStack.Record(TestScreen.ScreenB) - - navStack.push(recordA) - navStack.push(recordB) - - // Both records should be in history list - assertThat(navStack.containsRecord(recordA, includeSaved = false)).isTrue() - assertThat(navStack.containsRecord(recordB, includeSaved = false)).isTrue() - - // Pop to move pointer back - navStack.pop() - - // Both A and B should still be in history list - assertThat(navStack.containsRecord(recordA, includeSaved = false)).isTrue() - assertThat(navStack.containsRecord(recordB, includeSaved = false)).isTrue() - } - - @Test - fun test_is_record_reachable_includes_forward_history() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - val recordA = SaveableBackStack.Record(TestScreen.ScreenA) - val recordB = SaveableBackStack.Record(TestScreen.ScreenB) - - navStack.push(recordA) - navStack.push(recordB) - - // Pop to move pointer back - navStack.pop() - - // Both keys should still be reachable - assertThat(navStack.isRecordReachable(recordB.key, depth = 10, includeSaved = false)).isTrue() - assertThat(navStack.isRecordReachable(recordA.key, depth = 10, includeSaved = false)).isTrue() - } - - @Test - fun test_save_and_restore_state() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - navStack.push(TestScreen.ScreenB) - - assertThat(navStack.historyList).hasSize(3) - assertThat(navStack.stateStore).isEmpty() - - // Save state (saves both history and current index) - navStack.saveState() - assertThat(navStack.stateStore).hasSize(1) - - navStack.stateStore[TestScreen.RootAlpha].let { saved -> - assertThat(saved).isNotNull() - saved!! - assertThat(saved.first).hasSize(3) - assertThat(saved.first[0].screen).isEqualTo(TestScreen.RootAlpha) - assertThat(saved.first[1].screen).isEqualTo(TestScreen.ScreenA) - assertThat(saved.first[2].screen).isEqualTo(TestScreen.ScreenB) - assertThat(saved.second).isEqualTo(2) // Current index - } - - // Clear and add new navigation - navStack.historyList.clear() - navStack.push(TestScreen.RootBeta) - navStack.push(TestScreen.ScreenC) - - assertThat(navStack.historyList).hasSize(2) - - // Restore state - val restored = navStack.restoreState(TestScreen.RootAlpha) - assertThat(restored).isTrue() - - assertThat(navStack.historyList).hasSize(3) - assertThat(navStack.topRecord?.screen).isEqualTo(TestScreen.ScreenB) - assertThat(navStack.stateStore).isEmpty() - } - - @Test - fun test_saveable_save_and_restore() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - navStack.push(TestScreen.ScreenB) - - // Navigate back to populate forward history - navStack.pop() - assertThat(navStack.size).isEqualTo(2) - assertThat(navStack.forwardSize).isEqualTo(1) - - val saved = save(navStack) - assertThat(saved).isNotNull() - saved!! - - val restored = SaveableNavStack.Saver.restore(saved) - assertThat(restored).isNotNull() - restored!! - - assertThat(navStack.historyList.toList()).isEqualTo(restored.historyList.toList()) - assertThat(navStack.currentIndex).isEqualTo(restored.currentIndex) - assertThat(navStack.stateStore.toMap()).isEqualTo(restored.stateStore.toMap()) - } - - @Test - fun test_saveable_save_and_restore_with_state_store() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - navStack.push(TestScreen.ScreenB) - - // Save state and switch to different root - navStack.saveState() - navStack.historyList.clear() - navStack.push(TestScreen.RootBeta) - navStack.push(TestScreen.ScreenC) - - // Navigate back to populate forward history - navStack.pop() - - val saved = save(navStack) - assertThat(saved).isNotNull() - saved!! - - val restored = SaveableNavStack.Saver.restore(saved) - assertThat(restored).isNotNull() - restored!! - - assertThat(navStack.historyList.toList()).isEqualTo(restored.historyList.toList()) - assertThat(navStack.currentIndex).isEqualTo(restored.currentIndex) - assertThat(navStack.stateStore.toMap()).isEqualTo(restored.stateStore.toMap()) - } - - @Test - fun test_guard_pushing_same_top_record() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - assertTrue(navStack.push(TestScreen.ScreenA)) - assertTrue(navStack.push(TestScreen.ScreenB)) - assertFalse(navStack.push(TestScreen.ScreenB)) - } - - @Test - fun test_forward_size() { - val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.push(TestScreen.ScreenA) - navStack.push(TestScreen.ScreenB) - navStack.push(TestScreen.ScreenC) - - assertThat(navStack.forwardSize).isEqualTo(0) - - navStack.pop() - assertThat(navStack.forwardSize).isEqualTo(1) - - navStack.pop() - assertThat(navStack.forwardSize).isEqualTo(2) - - navStack.forward() - assertThat(navStack.forwardSize).isEqualTo(1) - } -} - -private fun save(navStack: SaveableNavStack) = - with(SaveableNavStack.Saver) { - val scope = SaverScope { true } - scope.save(navStack) - } diff --git a/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/SaveableNavStackTest.kt b/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/SaveableNavStackTest.kt new file mode 100644 index 0000000000..c1f9f7e1f3 --- /dev/null +++ b/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/SaveableNavStackTest.kt @@ -0,0 +1,441 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.backstack + +import androidx.compose.runtime.saveable.SaverScope +import com.slack.circuit.internal.test.TestScreen +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SaveableNavStackTest { + + @Test + fun test_initial_state() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + + assertEquals(1, navStack.size) + assertEquals(TestScreen.RootAlpha, navStack.topRecord?.screen) + assertEquals(TestScreen.RootAlpha, navStack.currentRecord?.screen) + assertEquals(TestScreen.RootAlpha, navStack.rootRecord?.screen) + assertFalse(navStack.isEmpty) + assertTrue(navStack.isAtRoot) + assertTrue(navStack.isAtTop) + assertFalse(navStack.canGoBack) + assertFalse(navStack.canGoForward) + } + + @Test + fun test_add_records() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + assertTrue(navStack.add(TestScreen.ScreenA)) + assertTrue(navStack.add(TestScreen.ScreenB)) + + assertEquals(3, navStack.size) + assertEquals(TestScreen.ScreenB, navStack.topRecord?.screen) + assertEquals(TestScreen.ScreenB, navStack.currentRecord?.screen) + assertEquals(TestScreen.RootAlpha, navStack.rootRecord?.screen) + assertFalse(navStack.isAtRoot) + assertTrue(navStack.isAtTop) + } + + @Test + fun test_move_backward_and_forward() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + navStack.add(TestScreen.ScreenC) + + // [C, B, A, RootAlpha] - current at C (index 0) + assertEquals(TestScreen.ScreenC, navStack.currentRecord?.screen) + assertTrue(navStack.canGoBack) + assertFalse(navStack.canGoForward) + + // Move backward to B + assertTrue(navStack.move(NavStack.Direction.Backward)) + assertEquals(TestScreen.ScreenB, navStack.currentRecord?.screen) + assertTrue(navStack.canGoBack) + assertTrue(navStack.canGoForward) + + // Move backward to A + assertTrue(navStack.move(NavStack.Direction.Backward)) + assertEquals(TestScreen.ScreenA, navStack.currentRecord?.screen) + assertTrue(navStack.canGoBack) + assertTrue(navStack.canGoForward) + + // Move backward to Root + assertTrue(navStack.move(NavStack.Direction.Backward)) + assertEquals(TestScreen.RootAlpha, navStack.currentRecord?.screen) + assertTrue(navStack.isAtRoot) + assertFalse(navStack.canGoBack) + assertTrue(navStack.canGoForward) + + // Can't move backward from root + assertFalse(navStack.move(NavStack.Direction.Backward)) + + // Move forward to A + assertTrue(navStack.move(NavStack.Direction.Forward)) + assertEquals(TestScreen.ScreenA, navStack.currentRecord?.screen) + + // Move forward to B + assertTrue(navStack.move(NavStack.Direction.Forward)) + assertEquals(TestScreen.ScreenB, navStack.currentRecord?.screen) + + // Move forward to C + assertTrue(navStack.move(NavStack.Direction.Forward)) + assertEquals(TestScreen.ScreenC, navStack.currentRecord?.screen) + assertTrue(navStack.isAtTop) + assertTrue(navStack.canGoBack) + assertFalse(navStack.canGoForward) + + // Can't move forward from top + assertFalse(navStack.move(NavStack.Direction.Forward)) + } + + @Test + fun test_add_truncates_forward_history() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + navStack.add(TestScreen.ScreenC) + + // [C, B, A, RootAlpha] - current at C + assertEquals(4, navStack.size) + + // Move back to B + navStack.move(NavStack.Direction.Backward) + assertEquals(TestScreen.ScreenB, navStack.currentRecord?.screen) + + // Add a new screen - should truncate C + navStack.add(TestScreen.RootBeta) + + // [RootBeta, B, A, RootAlpha] - current at Beta + assertEquals(4, navStack.size) + assertEquals(TestScreen.RootBeta, navStack.topRecord?.screen) + assertEquals(TestScreen.RootBeta, navStack.currentRecord?.screen) + assertFalse(navStack.canGoForward) + + // Verify the stack contents + val screens = navStack.map { it.screen } + assertEquals( + listOf(TestScreen.RootBeta, TestScreen.ScreenB, TestScreen.ScreenA, TestScreen.RootAlpha), + screens + ) + } + + @Test + fun test_remove() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + navStack.add(TestScreen.ScreenC) + + // Move back to B + navStack.move(NavStack.Direction.Backward) + navStack.move(NavStack.Direction.Backward) + assertEquals(TestScreen.ScreenA, navStack.currentRecord?.screen) + + // Remove (removes A, current stays at same index pointing to Root) + val removed = navStack.remove() + assertEquals(TestScreen.ScreenA, removed?.screen) + assertEquals(TestScreen.RootAlpha, navStack.currentRecord?.screen) + assertEquals(1, navStack.size) + } + + @Test + fun test_save_and_restore_state() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + + // Move back to A + navStack.move(NavStack.Direction.Backward) + assertEquals(TestScreen.ScreenA, navStack.currentRecord?.screen) + + // Save state + navStack.saveState() + assertEquals(1, navStack.stateStore.size) + + val saved = navStack.stateStore[TestScreen.RootAlpha] + assertNotNull(saved) + assertEquals(3, saved.entries.size) + assertEquals(1, saved.currentIndex) // Index of A in [B, A, Root] + assertEquals(TestScreen.ScreenB, saved.entries[0].screen) + assertEquals(TestScreen.ScreenA, saved.entries[1].screen) + assertEquals(TestScreen.RootAlpha, saved.entries[2].screen) + + // Clear the nav stack + while (navStack.size > 0) { + navStack.remove() + } + assertEquals(0, navStack.size) + + // Add a different root + navStack.add(TestScreen.RootBeta) + navStack.add(TestScreen.ScreenC) + assertEquals(TestScreen.ScreenC, navStack.currentRecord?.screen) + + // Clear again and restore the saved state + while (navStack.size > 0) { + navStack.remove() + } + assertTrue(navStack.restoreState(TestScreen.RootAlpha)) + + // Verify restored state + assertEquals(3, navStack.size) + assertEquals(TestScreen.ScreenA, navStack.currentRecord?.screen) + assertEquals(TestScreen.ScreenB, navStack.topRecord?.screen) + assertEquals(TestScreen.RootAlpha, navStack.rootRecord?.screen) + assertTrue(navStack.stateStore.isEmpty()) + } + + @Test + fun test_saveable_save_and_restore() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + + // Move back to A + navStack.move(NavStack.Direction.Backward) + + val saved = save(navStack) + assertNotNull(saved) + + val restored = SaveableNavStack.Saver.restore(saved) + assertNotNull(restored) + + assertEquals(navStack.size, restored.size) + assertEquals(navStack.currentRecord?.screen, restored.currentRecord?.screen) + assertEquals(navStack.entryList.toList(), restored.entryList.toList()) + assertEquals(navStack.stateStore.toMap(), restored.stateStore.toMap()) + } + + @Test + fun test_saveable_save_and_restore_with_saved_state() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + + // Move back and save state + navStack.move(NavStack.Direction.Backward) + navStack.saveState() + + // Add different screens + while (navStack.size > 0) { + navStack.remove() + } + navStack.add(TestScreen.RootBeta) + navStack.add(TestScreen.ScreenC) + + val saved = save(navStack) + assertNotNull(saved) + + val restored = SaveableNavStack.Saver.restore(saved) + assertNotNull(restored) + + assertEquals(navStack.size, restored.size) + assertEquals(navStack.currentRecord?.screen, restored.currentRecord?.screen) + assertEquals(navStack.entryList.toList(), restored.entryList.toList()) + assertEquals(navStack.stateStore.size, restored.stateStore.size) + + // Verify the saved state was preserved + val restoredSaved = restored.stateStore[TestScreen.RootAlpha] + assertNotNull(restoredSaved) + assertEquals(3, restoredSaved.entries.size) + assertEquals(1, restoredSaved.currentIndex) + } + + @Test + fun test_guard_pushing_same_top_record() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + assertTrue(navStack.add(TestScreen.ScreenA)) + assertTrue(navStack.add(TestScreen.ScreenB)) + assertFalse(navStack.add(TestScreen.ScreenB)) + } + + @Test + fun test_iteration() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + navStack.add(TestScreen.ScreenC) + + val screens = navStack.map { it.screen } + assertEquals( + listOf(TestScreen.ScreenC, TestScreen.ScreenB, TestScreen.ScreenA, TestScreen.RootAlpha), + screens + ) + } + + @Test + fun test_contains_record() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + val recordA = SaveableNavStack.Record(TestScreen.ScreenA) + navStack.add(recordA) + navStack.add(TestScreen.ScreenB) + + assertTrue(navStack.containsRecord(recordA, includeSaved = false)) + + // Save state and clear + navStack.saveState() + while (navStack.size > 0) { + navStack.remove() + } + + assertFalse(navStack.containsRecord(recordA, includeSaved = false)) + assertTrue(navStack.containsRecord(recordA, includeSaved = true)) + } + + @Test + fun test_is_record_reachable() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + val recordA = SaveableNavStack.Record(TestScreen.ScreenA) + navStack.add(recordA) + navStack.add(TestScreen.ScreenB) + navStack.add(TestScreen.ScreenC) + + // Stack is [C, B, A, Root] + assertTrue(navStack.isRecordReachable(recordA.key, depth = 10, includeSaved = false)) + assertFalse(navStack.isRecordReachable(recordA.key, depth = 1, includeSaved = false)) + } + + @Test + fun test_snapshot_captures_current_state() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + navStack.add(TestScreen.ScreenC) + + // Move back to B + navStack.move(NavStack.Direction.Backward) + assertEquals(TestScreen.ScreenB, navStack.currentRecord?.screen) + + // Create snapshot + val snapshot = navStack.snapshot() + + // Verify snapshot captures correct state + assertEquals(4, snapshot.entries.size) + assertEquals(1, snapshot.currentIndex) // Index of B in [C, B, A, Root] + assertEquals(TestScreen.ScreenC, snapshot.entries[0].screen) + assertEquals(TestScreen.ScreenB, snapshot.entries[1].screen) + assertEquals(TestScreen.ScreenA, snapshot.entries[2].screen) + assertEquals(TestScreen.RootAlpha, snapshot.entries[3].screen) + } + + @Test + fun test_snapshot_is_immutable() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + + // Create snapshot + val snapshot = navStack.snapshot() + val originalSize = snapshot.entries.size + val originalIndex = snapshot.currentIndex + + // Modify the nav stack + navStack.add(TestScreen.ScreenC) + navStack.move(NavStack.Direction.Backward) + + // Verify snapshot hasn't changed + assertEquals(originalSize, snapshot.entries.size) + assertEquals(originalIndex, snapshot.currentIndex) + assertEquals( + listOf(TestScreen.ScreenB, TestScreen.ScreenA, TestScreen.RootAlpha), + snapshot.entries.map { it.screen } + ) + } + + @Test + fun test_snapshot_empty_stack() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + + // Remove everything + navStack.remove() + + val snapshot = navStack.snapshot() + assertTrue(snapshot.entries.isEmpty()) + assertEquals(0, snapshot.currentIndex) + } + + @Test + fun test_snapshot_iteration() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + + val snapshot = navStack.snapshot() + + // Test that snapshot is iterable + val screens = snapshot.map { it.screen } + assertEquals( + listOf(TestScreen.ScreenB, TestScreen.ScreenA, TestScreen.RootAlpha), + screens + ) + } + + @Test + fun test_snapshot_reflects_navigation_state() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + navStack.add(TestScreen.ScreenC) + + // At top + val snapshot1 = navStack.snapshot() + assertEquals(0, snapshot1.currentIndex) + + // Move to root + navStack.move(NavStack.Direction.Backward) + navStack.move(NavStack.Direction.Backward) + navStack.move(NavStack.Direction.Backward) + val snapshot2 = navStack.snapshot() + assertEquals(3, snapshot2.currentIndex) + + // Move to middle + navStack.move(NavStack.Direction.Forward) + val snapshot3 = navStack.snapshot() + assertEquals(2, snapshot3.currentIndex) + } + + @Test + fun test_saved_state_snapshot_is_iterable() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + navStack.add(TestScreen.ScreenB) + + navStack.saveState() + val snapshot = navStack.stateStore[TestScreen.RootAlpha] + assertNotNull(snapshot) + + val screens = snapshot.map { it.screen } + assertEquals( + listOf(TestScreen.ScreenB, TestScreen.ScreenA, TestScreen.RootAlpha), + screens + ) + } + + @Test + fun test_remove_at_boundaries() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.add(TestScreen.ScreenA) + + // At top, remove forward + val removed = navStack.remove() + assertEquals(TestScreen.ScreenA, removed?.screen) + assertEquals(TestScreen.RootAlpha, navStack.currentRecord?.screen) + + // At root after removal + val removed2 = navStack.remove() + assertEquals(TestScreen.RootAlpha, removed2?.screen) + assertEquals(0, navStack.size) + } +} + +private fun save(navStack: SaveableNavStack) = + with(SaveableNavStack.Saver) { + val scope = SaverScope { true } + scope.save(navStack) + } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt index b09fa23d07..42f0b80acb 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -1,18 +1,5 @@ -/* - * Copyright (C) 2022 Adam Powell - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Copyright (C) 2022 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.foundation import androidx.compose.runtime.Composable diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 818dc5d1ee..181e9304d0 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -43,8 +43,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.slack.circuit.backstack.BackStack import com.slack.circuit.backstack.BackStack.Record -import com.slack.circuit.backstack.NavArgument -import com.slack.circuit.backstack.NavDecoration import com.slack.circuit.backstack.ProvidedValues import com.slack.circuit.backstack.isEmpty import com.slack.circuit.backstack.providedValuesForBackStack @@ -256,7 +254,7 @@ public fun NavigableCircuitContent( val activeContentProviders = buildCircuitContentProviders(backStack = navigator.backStack) val circuitProvidedValues = providedValuesForBackStack(navigator.backStack, circuit.backStackLocalProviders) - navDecoration.DecoratedContent(activeContentProviders, modifier) { provider -> + navDecoration.DecoratedContent(activeContentProviders, navigator, modifier) { provider -> val record = provider.record // Remember the `providedValues` lookup because this composition can live longer than @@ -343,6 +341,9 @@ public class RecordContentProvider( internal val content: @Composable (R, ContentProviderState) -> Unit, ) : NavArgument { + override val key: String + get() = record.key + override val screen: Screen get() = record.screen @@ -496,7 +497,9 @@ public object NavigatorDefaults { private const val NORMAL_DURATION = 450 * DEBUG_MULTIPLIER public object DefaultDecoratorFactory : AnimatedNavDecorator.Factory { - override fun create(): AnimatedNavDecorator = DefaultDecorator() + + override fun create(navigator: Navigator): AnimatedNavDecorator = + DefaultDecorator() } /** @@ -612,6 +615,7 @@ public object NavigatorDefaults { @Composable override fun DecoratedContent( args: List, + navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, ) { From 121dbd7b51c813cd841b031d294bc6baa1a7c4c8 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 14 Nov 2025 14:22:26 -0800 Subject: [PATCH 36/44] NavStack everywhere --- .../com/slack/circuit/backstack/NavStack.kt | 127 +++++++- ...ider.kt => NavStackRecordLocalProvider.kt} | 12 +- .../circuit/backstack/SaveableBackStack.kt | 4 +- .../circuit/backstack/SaveableNavStack.kt | 1 - .../foundation/SaveableRestRootTest.kt | 2 +- .../circuit/foundation/Navigator.android.kt | 7 +- .../NavigableCircuitChangeRootScreenTest.kt | 2 +- ...vigableCircuitRetainedStateTestActivity.kt | 2 +- ...vigableCircuitSaveableStateTestActivity.kt | 2 +- ...igableCircuitViewModelStateTestActivity.kt | 2 +- .../foundation/NavigatorBackHandlerTest.kt | 6 +- .../NavigableCircuitRetainedStateTest.kt | 4 +- .../NavigableCircuitSaveableStateTest.kt | 6 +- .../foundation/ProvidedValuesLifetimeTest.kt | 14 +- .../RememberCircuitNavigatorTest.kt | 6 +- .../circuit/foundation/AnsweringNavigator.kt | 4 +- .../com/slack/circuit/foundation/Circuit.kt | 17 +- .../slack/circuit/foundation/NavDecoration.kt | 112 ++++++- .../foundation/NavigableCircuitContent.kt | 114 +++---- .../slack/circuit/foundation/NavigatorImpl.kt | 60 ++-- .../animation/AnimatedNavDecoration.kt | 35 ++- .../animation/AnimatedNavDecorator.kt | 18 +- ...> ViewModelNavStackRecordLocalProvider.kt} | 28 +- .../foundation/AnsweringNavigatorTest.kt | 2 +- .../foundation/KeyedCircuitContentTests.kt | 2 +- .../slack/circuit/foundation/NavResultTest.kt | 6 +- .../NavigableCircuitConditionalRetainTest.kt | 2 +- .../gesturenavigation/BackNavigationTest.kt | 4 +- .../GestureNavigationCrashTest.kt | 2 +- .../GestureNavTransitionHolder.kt | 8 +- .../PredictiveBackNavigationDecorator.kt | 12 +- .../GestureNavigationStateTest.kt | 4 +- .../gesturenavigation/BackNavigationTest.kt | 4 +- .../intercepting/NavigatorBackHandlerTest.kt | 4 +- .../AdaptiveListDetailNavDecoration.kt | 35 ++- .../sample/navigation/ContentScreen.kt | 4 +- .../SlideOverNavigationDecorator.kt | 289 ------------------ .../circuit/sample/navigation/TabScreen.kt | 4 +- .../star/GestureSaveableRestRootTest.kt | 2 +- .../com/slack/circuit/star/home/HomeScreen.kt | 2 +- 40 files changed, 467 insertions(+), 504 deletions(-) rename backstack/src/commonMain/kotlin/com/slack/circuit/backstack/{BackStackRecordLocalProvider.kt => NavStackRecordLocalProvider.kt} (82%) rename circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/backstack/{ViewModelBackStackRecordLocalProvider.kt => ViewModelNavStackRecordLocalProvider.kt} (90%) delete mode 100644 samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SlideOverNavigationDecorator.kt diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt index 65b4b48274..b28bec9270 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt @@ -215,7 +215,7 @@ public interface NavStack { public interface Record { /** * A value that identifies this record uniquely, even if it shares the same [screen] with - * another record. This key may be used by [BackStackRecordLocalProvider]s to associate + * another record. This key may be used by [NavStackRecordLocalProvider]s to associate * presentation data with a record across composition recreation. * * [key] MUST NOT change for the life of the record. @@ -227,13 +227,134 @@ public interface NavStack { } /** - * A snapshot of a nav stack state, capturing both the entries and the current position. Used for - * saving and restoring stack state, and for iterating over stack entries. + * An immutable snapshot of a navigation stack's state at a point in time. + * + * ## Structure + * + * A snapshot captures the complete state using a **List+Index model**: + * - **entries**: An immutable list of all records in the stack + * - **currentIndex**: The position of the currently active record + * + * ## Index Semantics + * + * The list is ordered from newest (index 0) to oldest (last index): + * ``` + * entries: [TopScreen, MiddleScreen, RootScreen] + * indices: 0 1 2 + * ^ ^ + * newest oldest + * ``` + * + * - `currentIndex = 0`: At the top (newest entry), no forward history + * - `currentIndex = lastIndex`: At the root (oldest entry), no backward history + * - `0 < currentIndex < lastIndex`: In the middle, can move both directions + * + * ## Navigation View + * + * The snapshot provides convenient methods to view the stack from a navigation perspective: + * - **[forwardStack]**: Entries between current and top (newer entries you can navigate to) + * - **[backwardStack]**: Entries from current to root (includes current, older entries) + * + * ``` + * Stack: [A, B, C, D, E] (currentIndex = 2, current = C) + * ^ ^ ^ + * top cur root + * + * forwardStack() = [B, A] // Can move forward to these + * backwardStack() = [C, D, E] // Current and entries behind + * ``` + * + * ## Immutability + * + * Snapshots are immutable and will not change even if the source [NavStack] is modified. This + * makes them safe to: + * - Pass across composition boundaries + * - Store for later comparison + * - Serialize for state restoration + * - Iterate over without synchronization + * + * ## Use Cases + * + * 1. **State Persistence**: Save via [saveState] and restore via [restoreState] + * 2. **Inspection**: Examine stack contents without modifying the live stack + * 3. **Iteration**: Use the [Iterable] interface to traverse all entries + * 4. **Testing**: Verify navigation state in unit tests + * 5. **UI Display**: Show forward/backward history in navigation UI + * + * ## Example + * + * ```kotlin + * val snapshot = navStack.snapshot() + * + * // Check position + * val isAtTop = snapshot.currentIndex == 0 + * val isAtRoot = snapshot.currentIndex == snapshot.entries.lastIndex + * + * // Access entries + * val current = snapshot[snapshot.currentIndex] + * val top = snapshot[0] + * + * // Iterate over all entries (top to root) + * snapshot.forEach { record -> + * println("Screen: ${record.screen}") + * } + * + * // View navigation stacks + * val canGoForward = snapshot.forwardStack().any() + * snapshot.forwardStack().forEach { record -> + * println("Can navigate forward to: ${record.screen}") + * } + * ``` + * + * @see NavStack.snapshot + * @see NavStack.saveState + * @see NavStack.restoreState */ @Stable public interface Snapshot : Iterable { + /** All records in the stack, ordered from top (newest, index 0) to root (oldest, last index). */ public val entries: List + + /** + * The index of the currently active record in [entries]. + * - `0`: At the top (newest entry) + * - `entries.lastIndex`: At the root (oldest entry) + * - Between: In the middle of the history + */ public val currentIndex: Int + + /** Retrieves the record at the given [index] in [entries]. */ + public operator fun get(index: Int): R = entries[index] + + /** Returns an iterator over all entries from top (index 0) to root (last index). */ + override fun iterator(): Iterator = entries.iterator() + + /** + * Returns entries between the current position and the top, in order from nearest to top. + * These are entries you can navigate forward to (toward newer entries). + * + * Empty when [currentIndex] is 0 (at top). + * + * Example: + * ``` + * entries = [A, B, C, D], currentIndex = 2 (at C) + * forwardStack() = [B, A] // Can move forward through B to reach A + * ``` + */ + public fun forwardStack(): Iterable = entries.subList(0, currentIndex).asReversed() + + /** + * Returns entries from the current position to the root, in order from current to root. + * This includes the current entry and all entries you can navigate backward to (toward older + * entries). + * + * Example: + * ``` + * entries = [A, B, C, D], currentIndex = 1 (at B) + * backwardStack() = [B, C, D] // Current B, can move back through C to D + * ``` + */ + public fun backwardStack(): Iterable = entries.subList(currentIndex, entries.size) } } diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStackRecordLocalProvider.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStackRecordLocalProvider.kt similarity index 82% rename from backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStackRecordLocalProvider.kt rename to backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStackRecordLocalProvider.kt index a1e1cb6a96..d34696ad9c 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStackRecordLocalProvider.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStackRecordLocalProvider.kt @@ -21,7 +21,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.key @Stable -public fun interface BackStackRecordLocalProvider { +public fun interface NavStackRecordLocalProvider { @Composable public fun providedValuesFor(record: R): ProvidedValues } @@ -38,12 +38,12 @@ internal class CompositeProvidedValues(private val list: List) : } @Composable -public fun providedValuesForBackStack( - backStack: BackStack, - backStackLocalProviders: List> = emptyList(), +public fun providedValuesForNavStack( + navStack: NavStack, + backStackLocalProviders: List> = emptyList(), ): Map = - buildMap(backStack.size) { - backStack.forEach { record -> + buildMap(navStack.size) { + navStack.snapshot().forEach { record -> key(record) { put( record, diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index 27085a4adc..081ce733af 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -181,7 +181,9 @@ internal constructor( NavStack.Snapshot { override val currentIndex: Int = 0 - override fun iterator(): Iterator = entries.iterator() + override fun forwardStack(): Iterable = emptyList() + + override fun backwardStack(): Iterable = entries } public data class Record( diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt index e798b9e32e..f3922f2006 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt @@ -320,7 +320,6 @@ internal constructor( override val entries: List, override val currentIndex: Int, ) : NavStack.Snapshot { - override fun iterator(): Iterator = entries.iterator() internal companion object { val Saver: Saver, Any> = diff --git a/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt b/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt index 26699e5313..1809a0b043 100644 --- a/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt +++ b/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt @@ -82,7 +82,7 @@ class SaveableRestRootTest { private fun TestContent(liftNavigator: (Navigator) -> Unit) { Column(Modifier.windowInsetsPadding(WindowInsets.safeDrawing)) { val backStack = rememberSaveableBackStack(root = TestScreen.RootAlpha) - val navigator = rememberCircuitNavigator(backStack = backStack) + val navigator = rememberCircuitNavigator(navStack = backStack) SideEffect { liftNavigator(navigator) } NavigableCircuitContent(navigator, backStack, modifier = Modifier) } diff --git a/circuit-foundation/src/androidMain/kotlin/com/slack/circuit/foundation/Navigator.android.kt b/circuit-foundation/src/androidMain/kotlin/com/slack/circuit/foundation/Navigator.android.kt index c2472680c4..5377f37995 100644 --- a/circuit-foundation/src/androidMain/kotlin/com/slack/circuit/foundation/Navigator.android.kt +++ b/circuit-foundation/src/androidMain/kotlin/com/slack/circuit/foundation/Navigator.android.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.Composable import com.slack.circuit.backstack.BackStack import com.slack.circuit.backstack.BackStack.Record +import com.slack.circuit.backstack.NavStack import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.PopResult @@ -13,18 +14,18 @@ import com.slack.circuit.runtime.screen.PopResult * Returns a new [Navigator] for navigating within [CircuitContents][CircuitContent]. Delegates * onRootPop to the [LocalOnBackPressedDispatcherOwner]. * - * @param backStack The backing [BackStack] to navigate. + * @param navStack The backing [BackStack] to navigate. * @param enableBackHandler Indicates whether or not [Navigator.pop] should be called by the system * back handler. Defaults to true. * @see NavigableCircuitContent */ @Composable public fun rememberCircuitNavigator( - backStack: BackStack, + navStack: NavStack, enableBackHandler: Boolean = true, ): Navigator { return rememberCircuitNavigator( - backStack = backStack, + navStack = navStack, onRootPop = backDispatcherRootPop(), enableBackHandler = enableBackHandler, ) diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitChangeRootScreenTest.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitChangeRootScreenTest.kt index 44c07f1b26..66aeb63a0b 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitChangeRootScreenTest.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitChangeRootScreenTest.kt @@ -32,7 +32,7 @@ class NavigableCircuitChangeRootScreenTest { composeTestRule.setContent { CircuitCompositionLocals(circuit) { val backStack = rememberSaveableBackStack(rootScreen) - val navigator = rememberCircuitNavigator(backStack = backStack) + val navigator = rememberCircuitNavigator(navStack = backStack) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitRetainedStateTestActivity.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitRetainedStateTestActivity.kt index 01f5c7c622..e6c2056330 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitRetainedStateTestActivity.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitRetainedStateTestActivity.kt @@ -22,7 +22,7 @@ class NavigableCircuitRetainedStateTestActivity : ComponentActivity() { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent(navigator = navigator, backStack = backStack) diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitSaveableStateTestActivity.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitSaveableStateTestActivity.kt index 940770bc4e..0ed502f53e 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitSaveableStateTestActivity.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitSaveableStateTestActivity.kt @@ -22,7 +22,7 @@ class NavigableCircuitSaveableStateTestActivity : ComponentActivity() { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent(navigator = navigator, backStack = backStack) diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitViewModelStateTestActivity.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitViewModelStateTestActivity.kt index b3e162c56e..9c571437a4 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitViewModelStateTestActivity.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitViewModelStateTestActivity.kt @@ -22,7 +22,7 @@ class NavigableCircuitViewModelStateTestActivity : ComponentActivity() { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent(navigator = navigator, backStack = backStack) diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt index 5b5a61dfeb..23d8dc3fe8 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigatorBackHandlerTest.kt @@ -54,7 +54,7 @@ class NavigatorBackHandlerTest { } CircuitCompositionLocals(circuit) { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) - navigator = rememberCircuitNavigator(backStack = backStack) + navigator = rememberCircuitNavigator(navStack = backStack) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } @@ -82,7 +82,7 @@ class NavigatorBackHandlerTest { CircuitCompositionLocals(circuit) { BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) - navigator = rememberCircuitNavigator(backStack = backStack) + navigator = rememberCircuitNavigator(navStack = backStack) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } @@ -110,7 +110,7 @@ class NavigatorBackHandlerTest { CircuitCompositionLocals(circuit) { BackHandler(enabled = true) { outerBackCount++ } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) - navigator = rememberCircuitNavigator(backStack = backStack) + navigator = rememberCircuitNavigator(navStack = backStack) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } diff --git a/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitRetainedStateTest.kt b/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitRetainedStateTest.kt index b4dc512d2a..cb3856b282 100644 --- a/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitRetainedStateTest.kt +++ b/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitRetainedStateTest.kt @@ -47,7 +47,7 @@ class NavigableCircuitRetainedStateTest { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent(navigator = navigator, backStack = backStack) @@ -118,7 +118,7 @@ class NavigableCircuitRetainedStateTest { val backStack = rememberSaveableBackStack(TestScreen.RootAlpha) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent(navigator = navigator, backStack = backStack) diff --git a/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitSaveableStateTest.kt b/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitSaveableStateTest.kt index 2921b65e13..e7b2525ceb 100644 --- a/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitSaveableStateTest.kt +++ b/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitSaveableStateTest.kt @@ -55,7 +55,7 @@ class NavigableCircuitSaveableStateTest { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent(navigator = navigator, backStack = backStack) @@ -126,7 +126,7 @@ class NavigableCircuitSaveableStateTest { val backStack = rememberSaveableBackStack(TestScreen.RootAlpha) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent(navigator = navigator, backStack = backStack) @@ -222,7 +222,7 @@ class NavigableCircuitSaveableStateTest { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) CompositionLocalProvider(LocalSaveableStateRegistry provides saveableStateRegistry) { diff --git a/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/ProvidedValuesLifetimeTest.kt b/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/ProvidedValuesLifetimeTest.kt index 9b8e923427..ebadda344c 100644 --- a/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/ProvidedValuesLifetimeTest.kt +++ b/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/ProvidedValuesLifetimeTest.kt @@ -17,9 +17,9 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import com.slack.circuit.backstack.BackStack -import com.slack.circuit.backstack.BackStackRecordLocalProvider +import com.slack.circuit.backstack.NavStackRecordLocalProvider import com.slack.circuit.backstack.ProvidedValues -import com.slack.circuit.backstack.providedValuesForBackStack +import com.slack.circuit.backstack.providedValuesForNavStack import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.internal.test.TestContentTags import com.slack.circuit.internal.test.TestEvent @@ -51,16 +51,16 @@ class ProvidedValuesLifetimeTest { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent( navigator = navigator, backStack = backStack, providedValues = - providedValuesForBackStack( - backStack = backStack, - backStackLocalProviders = listOf(TestBackStackRecordLocalProvider), + providedValuesForNavStack( + navStack = backStack, + backStackLocalProviders = listOf(TestNavStackRecordLocalProvider), ), ) } @@ -145,7 +145,7 @@ class ProvidedValuesLifetimeTest { } } - private object TestBackStackRecordLocalProvider : BackStackRecordLocalProvider { + private object TestNavStackRecordLocalProvider : NavStackRecordLocalProvider { @Composable override fun providedValuesFor(record: BackStack.Record): ProvidedValues { return ProvidedValues { diff --git a/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/RememberCircuitNavigatorTest.kt b/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/RememberCircuitNavigatorTest.kt index 70da6492a2..d0445309d7 100644 --- a/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/RememberCircuitNavigatorTest.kt +++ b/circuit-foundation/src/commonJvmTest/kotlin/com/slack/circuit/foundation/RememberCircuitNavigatorTest.kt @@ -29,7 +29,7 @@ class RememberCircuitNavigatorTest { val backStack = FakeBackStack() backStack.push(TestScreen) setContent { - val navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) + val navigator = rememberCircuitNavigator(navStack = backStack, onRootPop = {}) assertNotNull(navigator) assertEquals(TestScreen, navigator.peek()) assertEquals(listOf(TestScreen), navigator.peekBackStack()) @@ -42,7 +42,7 @@ class RememberCircuitNavigatorTest { val navigators = mutableSetOf() setContent { var backStack by remember { mutableStateOf(FakeBackStack().apply { push(TestScreen) }) } - rememberCircuitNavigator(backStack = backStack, onRootPop = {}).also { navigators += it } + rememberCircuitNavigator(navStack = backStack, onRootPop = {}).also { navigators += it } SideEffect { // Simulate the backstack instance changing if (backStack.rootRecord?.screen != TestScreen2) { @@ -66,7 +66,7 @@ class RememberCircuitNavigatorTest { val onRootPop2 = { _: PopResult? -> onRootPopped = 2 } setContent { var onRootPop by remember { mutableStateOf(onRootPop1) } - val navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = onRootPop) + val navigator = rememberCircuitNavigator(navStack = backStack, onRootPop = onRootPop) SideEffect { // Call pop on the root screen to trigger the onRootPop lambda if (onRootPopped == 0 && onRootPop == onRootPop2) { diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt index 64167035cb..a4d6f9732b 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt @@ -28,7 +28,7 @@ import kotlin.uuid.Uuid @OptIn(ExperimentalCircuitApi::class) @Composable public fun answeringNavigationAvailable(): Boolean = - LocalBackStack.current != null && LocalAnsweringResultHandler.current != null + LocalNavStack.current != null && LocalAnsweringResultHandler.current != null /** * A reified version of [rememberAnsweringNavigator]. See documented overloads of this function for @@ -51,7 +51,7 @@ public fun rememberAnsweringNavigator( resultType: KClass, block: (result: T) -> Unit, ): GoToNavigator { - val backStack = LocalBackStack.current ?: return fallbackNavigator + val backStack = LocalNavStack.current ?: return fallbackNavigator val resultHandler = LocalAnsweringResultHandler.current ?: return fallbackNavigator return rememberAnsweringNavigator(backStack, resultHandler, resultType, block) } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt index bc3578b0bc..d82e40fb21 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt @@ -11,11 +11,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.TextStyle import com.slack.circuit.backstack.BackStack -import com.slack.circuit.backstack.BackStackRecordLocalProvider +import com.slack.circuit.backstack.NavStack +import com.slack.circuit.backstack.NavStackRecordLocalProvider import com.slack.circuit.foundation.animation.AnimatedNavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform -import com.slack.circuit.foundation.backstack.ViewModelBackStackRecordLocalProvider +import com.slack.circuit.foundation.backstack.ViewModelNavStackRecordLocalProvider import com.slack.circuit.runtime.CircuitContext import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.ExperimentalCircuitApi @@ -105,7 +106,7 @@ public class Circuit private constructor(builder: Builder) { */ public val presentWithLifecycle: Boolean = builder.presentWithLifecycle - public val backStackLocalProviders: List> = + public val backStackLocalProviders: List> = builder.backStackLocalProviders.toList() @OptIn(InternalCircuitApi::class) @@ -188,8 +189,8 @@ public class Circuit private constructor(builder: Builder) { private set public val backStackLocalProviders: - MutableList> = - mutableListOf(ViewModelBackStackRecordLocalProvider) + MutableList> = + mutableListOf(ViewModelNavStackRecordLocalProvider) @OptIn(ExperimentalCircuitApi::class) internal constructor(circuit: Circuit) : this() { @@ -290,11 +291,11 @@ public class Circuit private constructor(builder: Builder) { } public fun addBackStackRecordLocalProvider( - provider: BackStackRecordLocalProvider + provider: NavStackRecordLocalProvider ): Builder = apply { backStackLocalProviders.add(provider) } public fun addBackStackRecordLocalProvider( - vararg providers: BackStackRecordLocalProvider + vararg providers: NavStackRecordLocalProvider ): Builder = apply { for (p in providers) { backStackLocalProviders.add(p) @@ -302,7 +303,7 @@ public class Circuit private constructor(builder: Builder) { } public fun addBackStackRecordLocalProviders( - providers: Iterable> + providers: Iterable> ): Builder = apply { backStackLocalProviders.addAll(providers) } public fun clearBackStackRecordLocalProviders(): Builder = apply { diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt index 42f0b80acb..3525aa060e 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -13,13 +13,123 @@ import com.slack.circuit.runtime.screen.Screen public interface NavDecoration { @Composable public fun DecoratedContent( - args: List, + args: NavStackList, navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, ) } +/** + * A lightweight view of a navigation stack state, used by [NavDecoration] to provide context about + * the current navigation position and history. + * + * ## Structure + * + * `NavStackList` uses a **List+Index model** to represent navigation state: + * - **entries**: A list of items ordered from newest (index 0) to oldest (last index) + * - **currentIndex**: The position of the currently active/visible entry + * + * ## Index Semantics + * + * The list is ordered from top (newest) to root (oldest): + * ``` + * entries: [TopScreen, MiddleScreen, RootScreen] + * indices: 0 1 2 + * ^ ^ + * newest oldest + * ``` + * - `currentIndex = 0`: At the top (newest entry), no forward history + * - `currentIndex = lastIndex`: At the root (oldest entry), no backward history + * - `0 < currentIndex < lastIndex`: In the middle, between newer and older entries + * + * ## Use in NavDecoration + * + * [NavDecoration] receives a `NavStackList` to apply presentation logic based on navigation state. + * For example, a decoration might: + * - Apply different transitions when navigating forward vs backward + * - Show breadcrumbs based on the backward stack + * - Display a forward navigation UI when history is available + * + * ## Example + * + * ```kotlin + * val stackList = NavStackList( + * entries = listOf(DetailsScreen, ListScreen, HomeScreen), + * currentIndex = 1 // Currently at ListScreen + * ) + * + * // Access current and boundary entries + * val current = stackList.current // ListScreen + * val top = stackList.top // DetailsScreen (newest) + * val root = stackList.root // HomeScreen (oldest) + * + * // Check navigation position + * val canGoForward = stackList.forwardStack().isNotEmpty() // true (can go to DetailsScreen) + * val canGoBack = stackList.backwardStack().size > 1 // true (can go to HomeScreen) + * + * // Iterate forward history + * stackList.forwardStack().forEach { screen -> + * // Process DetailsScreen + * } + * ``` + * + * @param T The type of entries stored in the navigation stack, typically implementing [NavArgument] + * @param entries The complete list of entries, ordered from top (newest, index 0) to root (oldest, + * last index) + * @param currentIndex The index of the currently active/visible entry in [entries]. Defaults to 0 + * (top). + * @see NavDecoration + * @see NavArgument + */ +public data class NavStackList(val entries: List, val currentIndex: Int = 0) { + /** The number of entries in the stack. */ + public val size: Int + get() = entries.size + + /** The currently active/visible entry at [currentIndex]. */ + public val current: T + get() = entries[currentIndex] + + /** The top (newest) entry in the stack at index 0. Could be in the forward history. */ + public val top: T + get() = entries.first() + + /** The root (oldest) entry in the stack at the last index. */ + public val root: T + get() = entries.last() + + /** Retrieves the entry at the given [index]. */ + public operator fun get(index: Int): T = entries[index] + + /** + * Returns entries between the current position and the top, in order from nearest to top. These + * are entries the user can navigate forward to (toward newer entries). + * + * Empty when [currentIndex] is 0 (at top). + * + * Example: + * ``` + * entries = [A, B, C, D], currentIndex = 2 (at C) + * forwardStack() = [B, A] // Can move forward through B to reach A + * ``` + */ + public fun forwardStack(): List = entries.subList(0, currentIndex).asReversed() + + /** + * Returns entries from the current position to the root, in order from current to root. This + * includes the current entry and all entries the user can navigate backward to (toward older + * entries). + * + * Example: + * ``` + * entries = [A, B, C, D], currentIndex = 1 (at B) + * backwardStack() = [B, C, D] // Current B, can move back through C to D + * ``` + */ + public fun backwardStack(): List = entries.subList(currentIndex, entries.size) +} + /** Argument provided to [NavDecoration] that exposes the underlying [Screen]. */ public interface NavArgument { public val screen: Screen diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 181e9304d0..f7064940e3 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -41,11 +41,11 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.toString import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.slack.circuit.backstack.BackStack -import com.slack.circuit.backstack.BackStack.Record +import com.slack.circuit.backstack.NavStack +import com.slack.circuit.backstack.NavStack.Record import com.slack.circuit.backstack.ProvidedValues import com.slack.circuit.backstack.isEmpty -import com.slack.circuit.backstack.providedValuesForBackStack +import com.slack.circuit.backstack.providedValuesForNavStack import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.NavigatorDefaults.DefaultDecorator.DefaultAnimatedState import com.slack.circuit.foundation.animation.AnimatedNavDecoration @@ -62,6 +62,7 @@ import com.slack.circuit.runtime.InternalCircuitApi import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen +import kotlin.collections.mutableSetOf /** * A composable that provides the core navigation and state management for Circuit-based navigation @@ -85,9 +86,9 @@ import com.slack.circuit.runtime.screen.Screen * * ```kotlin * setContent { - * val backStack = rememberSaveableBackStack(root = HomeScreen) - * val navigator = rememberCircuitNavigator(backStack) - * NavigableCircuitContent(navigator, backStack) + * val navStack = rememberSaveableNavStack(root = HomeScreen) + * val navigator = rememberCircuitNavigator(navStack) + * NavigableCircuitContent(navigator, navStack) * } * ``` * @@ -99,7 +100,7 @@ import com.slack.circuit.runtime.screen.Screen * * @param navigator The [Navigator] used to handle navigation events. Typically created via * [rememberCircuitNavigator]. - * @param backStack The [BackStack] containing the stack of [Record]s to display. Must have at least + * @param navStack The [NavStack] containing the stack of [Record]s to display. Must have at least * one record. Typically created via [rememberSaveableBackStack]. * @param modifier The [Modifier] to apply to the content. * @param circuit The [Circuit] instance providing UI factories and configuration. Defaults to @@ -122,7 +123,7 @@ import com.slack.circuit.runtime.screen.Screen @Composable public fun NavigableCircuitContent( navigator: Navigator, - backStack: BackStack, + navStack: NavStack, modifier: Modifier = Modifier, circuit: Circuit = requireNotNull(LocalCircuit.current), providedValues: Map = emptyMap(), @@ -132,7 +133,7 @@ public fun NavigableCircuitContent( circuit.onUnavailableContent, ) { NavigableCircuitContent( - navigator = rememberAnsweringResultNavigator(navigator, backStack), + navigator = rememberAnsweringResultNavigator(navigator, navStack), modifier = modifier, circuit = circuit, providedValues = providedValues, @@ -151,15 +152,15 @@ public fun NavigableCircuitContent( * shared result handling, or when you need custom control over the result handler lifecycle. * * For most use cases, prefer the standard [NavigableCircuitContent] overload that takes a - * [Navigator] and [BackStack], as it automatically creates and manages the result navigator. + * [Navigator] and [NavStack], as it automatically creates and manages the result navigator. * * ## Usage * * ```kotlin * setContent { - * val backStack = rememberSaveableBackStack(root = HomeScreen) - * val baseNavigator = rememberCircuitNavigator(backStack) - * val navigator = rememberAnsweringResultNavigator(baseNavigator, backStack) + * val navStack = rememberSaveableNavStack(root = HomeScreen) + * val baseNavigator = rememberCircuitNavigator(navStack) + * val navigator = rememberAnsweringResultNavigator(baseNavigator, navStack) * NavigableCircuitContent(navigator) * } * ``` @@ -187,7 +188,7 @@ public fun NavigableCircuitContent( unavailableRoute: (@Composable (screen: Screen, modifier: Modifier) -> Unit) = circuit.onUnavailableContent, ) { - if (navigator.backStack.isEmpty) return + if (navigator.navStack.isEmpty) return /* * We store the RetainedStateRegistries for each back stack entry into an 'navigation content' * RetainedStateRegistry. If we don't do this, those registries would be stored directly in the @@ -251,9 +252,9 @@ public fun NavigableCircuitContent( lastCircuit = circuit lastUnavailableRoute = unavailableRoute } - val activeContentProviders = buildCircuitContentProviders(backStack = navigator.backStack) + val activeContentProviders = buildCircuitContentProviders(navStack = navigator.navStack) val circuitProvidedValues = - providedValuesForBackStack(navigator.backStack, circuit.backStackLocalProviders) + providedValuesForNavStack(navigator.navStack, circuit.backStackLocalProviders) navDecoration.DecoratedContent(activeContentProviders, navigator, modifier) { provider -> val record = provider.record @@ -266,10 +267,10 @@ public fun NavigableCircuitContent( remember(values, circuitProvidedValues) { (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() } - val localBackstack = contentProviderState.lastNavigator.backStack + val localNavStack = contentProviderState.lastNavigator.navStack val localResultHandler = contentProviderState.lastNavigator.answeringResultHandler CompositionLocalProvider( - LocalBackStack provides localBackstack, + LocalNavStack provides localNavStack, LocalAnsweringResultHandler provides localResultHandler, *providedLocals, ) { @@ -288,7 +289,7 @@ public fun NavigableCircuitContent( * screens are popped. * * @param navigator The base [Navigator] to wrap with result handling. - * @param backStack The [BackStack] used for tracking navigation state. + * @param navStack The [NavStack] used for tracking navigation state. * @param answeringResultHandler The [AnsweringResultHandler] for managing screen results. Defaults * to a new instance created via [rememberAnsweringResultHandler]. Only provide a custom handler * if you need to share result handling across multiple navigation graphs or require custom result @@ -301,11 +302,11 @@ public fun NavigableCircuitContent( @Composable public fun rememberAnsweringResultNavigator( navigator: Navigator, - backStack: BackStack, + navStack: NavStack, answeringResultHandler: AnsweringResultHandler = rememberAnsweringResultHandler(), ): AnsweringResultNavigator { - return remember(navigator, backStack, answeringResultHandler) { - AnsweringResultNavigator(navigator, backStack, answeringResultHandler) + return remember(navigator, navStack, answeringResultHandler) { + AnsweringResultNavigator(navigator, navStack, answeringResultHandler) } } @@ -313,7 +314,7 @@ public fun rememberAnsweringResultNavigator( @ExperimentalCircuitApi public class AnsweringResultNavigator( internal val originalNavigator: Navigator, - internal val backStack: BackStack, + internal val navStack: NavStack, internal val answeringResultHandler: AnsweringResultHandler, ) : Navigator by originalNavigator { override fun pop(result: PopResult?): Screen? { @@ -321,8 +322,8 @@ public class AnsweringResultNavigator( return Snapshot.withMutableSnapshot { val popped = originalNavigator.pop(result) if (result != null) { - // Send the pending result to our new top record, but only if it's expecting one - backStack.topRecord?.apply { + // Send the pending result to our new current record, but only if it's expecting one + navStack.currentRecord?.apply { if (answeringResultHandler.expectingResult(key)) { answeringResultHandler.sendResult(key, result) } @@ -365,14 +366,15 @@ public class RecordContentProvider( @ExperimentalCircuitApi @Composable private fun buildCircuitContentProviders( - backStack: BackStack -): List> { + navStack: NavStack +): NavStackList> { + val navStackSnapshot = navStack.snapshot() val previousContentProviders = remember { mutableMapOf>() } val activeRecordKeys = remember { mutableSetOf() } val recordKeys by remember { mutableStateOf(emptySet()) } - .apply { value = backStack.mapTo(mutableSetOf()) { it.key } } - val latestBackStack by rememberUpdatedState(backStack) + .apply { value = navStackSnapshot.mapTo(mutableSetOf()) { it.key } } + val latestBackStack by rememberUpdatedState(navStack) DisposableEffect(recordKeys) { // Delay cleanup until the next backstack change. // - Any record in composition is considered active @@ -395,20 +397,22 @@ private fun buildCircuitContentProviders( .forEach { previousContentProviders.remove(it) } } } - return backStack.map { record -> - // Query the previous content providers map, so that we use the same - // RecordContentProvider instances across calls. - previousContentProviders.getOrPut(record.key) { - RecordContentProvider( - record = record, - content = - createRecordContent( - onActive = { activeRecordKeys.add(record.key) }, - onDispose = { activeRecordKeys.remove(record.key) }, - ), - ) + val entries = + navStackSnapshot.map { record -> + // Query the previous content providers map, so that we use the same + // RecordContentProvider instances across calls. + previousContentProviders.getOrPut(record.key) { + RecordContentProvider( + record = record, + content = + createRecordContent( + onActive = { activeRecordKeys.add(record.key) }, + onDispose = { activeRecordKeys.remove(record.key) }, + ), + ) + } } - } + return NavStackList(entries, navStackSnapshot.currentIndex) } @ExperimentalCircuitApi @@ -448,7 +452,7 @@ private fun createRecordContent(onActive: () -> Unit, onDispose: () with(contentProviderState) { val lifecycle = remember { MutableRecordLifecycle() } - .apply { isActive = lastNavigator.backStack.topRecord == record } + .apply { isActive = lastNavigator.navStack.topRecord == record } saveableStateHolder.SaveableStateProvider(record.registryKey) { // Provides a RetainedStateRegistry that is maintained independently for each record while // the record exists in the back stack. @@ -467,7 +471,7 @@ private fun createRecordContent(onActive: () -> Unit, onDispose: () // Remove saved states for records that are no longer in the back stack DisposableEffect(record.registryKey) { onDispose { - if (!lastNavigator.backStack.containsRecord(record, includeSaved = true)) { + if (!lastNavigator.navStack.containsRecord(record, includeSaved = true)) { retainedStateHolder.removeState(record.registryKey) saveableStateHolder.removeState(record.registryKey) } @@ -570,16 +574,18 @@ public object NavigatorDefaults { public class DefaultDecorator : AnimatedNavDecorator> { - public data class DefaultAnimatedState(val args: List) : AnimatedNavState { - override val backStack: List = args - } + public data class DefaultAnimatedState( + override val navStack: NavStackList + ) : AnimatedNavState - override fun targetState(args: List): DefaultAnimatedState { + override fun targetState(args: NavStackList): DefaultAnimatedState { return DefaultAnimatedState(args) } @Composable - public override fun updateTransition(args: List): Transition> { + public override fun updateTransition( + args: NavStackList + ): Transition> { return updateTransition(targetState(args)) } @@ -606,7 +612,7 @@ public object NavigatorDefaults { targetState: DefaultAnimatedState, innerContent: @Composable (T) -> Unit, ) { - innerContent(targetState.args.first()) + innerContent(targetState.navStack.current) } } @@ -614,18 +620,18 @@ public object NavigatorDefaults { public object EmptyDecoration : NavDecoration { @Composable override fun DecoratedContent( - args: List, + args: NavStackList, navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, ) { - Box(modifier = modifier) { content(args.first()) } + Box(modifier = modifier) { content(args.current) } } } } /** - * Delicate API to access the [BackStack] from within a [CircuitContent] or + * Delicate API to access the [NavStack] from within a [CircuitContent] or * [rememberAnsweringNavigator] composable, useful for cases where we create nested nav handling. * * This is generally considered an internal API to Circuit, but can be useful for interop cases and @@ -633,7 +639,7 @@ public object NavigatorDefaults { * [DelicateCircuitFoundationApi]. */ @DelicateCircuitFoundationApi -public val LocalBackStack: ProvidableCompositionLocal?> = compositionLocalOf { +public val LocalNavStack: ProvidableCompositionLocal?> = compositionLocalOf { null } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt index 6a37fd67d3..8023aaf4a4 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt @@ -13,8 +13,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.backhandler.BackHandler -import com.slack.circuit.backstack.BackStack -import com.slack.circuit.backstack.BackStack.Record +import com.slack.circuit.backstack.NavStack +import com.slack.circuit.backstack.NavStack.Record import com.slack.circuit.backstack.isAtRoot import com.slack.circuit.backstack.isEmpty import com.slack.circuit.foundation.internal.mapToImmutableList @@ -25,27 +25,27 @@ import com.slack.circuit.runtime.screen.Screen /** * Creates and remembers a new [Navigator] for navigating within [CircuitContents][CircuitContent]. - * A new [Navigator] will be created if the [backStack] instance changes. + * A new [Navigator] will be created if the [navStack] instance changes. * - * @param backStack The backing [BackStack] to navigate. + * @param navStack The backing [NavStack] to navigate. * @param onRootPop Invoked when the backstack is at root (size 1) and the user presses the back * button. * @see NavigableCircuitContent */ @Composable public fun rememberCircuitNavigator( - backStack: BackStack, + navStack: NavStack, onRootPop: (result: PopResult?) -> Unit, ): Navigator { val latestOnRootPop by rememberUpdatedState(onRootPop) - return remember(backStack) { Navigator(backStack) { popResult -> latestOnRootPop(popResult) } } + return remember(navStack) { Navigator(navStack) { popResult -> latestOnRootPop(popResult) } } } /** * Returns a new [Navigator] for navigating within [CircuitContents][CircuitContent] while also * handling back events with a [BackHandler]. * - * @param backStack The backing [BackStack] to navigate. + * @param navStack The backing [NavStack] to navigate. * @param onRootPop Invoked when the backstack is at root (size 1) and the user presses the back * button. * @param enableBackHandler Indicates whether or not [Navigator.pop] should be called by the system @@ -55,11 +55,11 @@ public fun rememberCircuitNavigator( @OptIn(ExperimentalComposeUiApi::class) @Composable public fun rememberCircuitNavigator( - backStack: BackStack, + navStack: NavStack, onRootPop: (result: PopResult?) -> Unit, enableBackHandler: Boolean = true, ): Navigator { - val navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = onRootPop) + val navigator = rememberCircuitNavigator(navStack = navStack, onRootPop = onRootPop) // Check the screen and not the record as `popRoot()` reorders the screens creating new records. // Also `popUntil` can run to a null screen, which we want to treat as the last screen. val hasScreenChanged = remember { @@ -75,12 +75,12 @@ public fun rememberCircuitNavigator( var hasPendingRootPop by remember(hasScreenChanged) { mutableStateOf(false) } var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } BackHandler( - enabled = enableBackHandler && enableRootBackHandler && backStack.size > 1, + enabled = enableBackHandler && enableRootBackHandler && navStack.size > 1, onBack = { // We need to unload this BackHandler from the composition before the root pop is triggered so // any outer back handler will get called. So delay calling pop until after the next // composition. - if (backStack.size > 1) { + if (navStack.size > 1) { navigator.pop() } else { hasPendingRootPop = true @@ -106,51 +106,57 @@ public fun rememberCircuitNavigator( * @see NavigableCircuitContent */ public fun Navigator( - backStack: BackStack, + backStack: NavStack, onRootPop: (result: PopResult?) -> Unit, ): Navigator = NavigatorImpl(backStack, onRootPop) internal class NavigatorImpl( - private val backStack: BackStack, + private val navStack: NavStack, private val onRootPop: (result: PopResult?) -> Unit, ) : Navigator { init { - check(!backStack.isEmpty) { "Backstack size must not be empty." } + check(!navStack.isEmpty) { "Backstack size must not be empty." } } override fun goTo(screen: Screen): Boolean { - return backStack.push(screen) + return navStack.add(screen) } override fun pop(result: PopResult?): Screen? { - if (backStack.isAtRoot) { + if (navStack.isAtRoot) { onRootPop(result) return null } - return backStack.pop()?.screen + return navStack.remove()?.screen } - override fun peek(): Screen? = backStack.firstOrNull()?.screen + override fun peek(): Screen? = navStack.currentRecord?.screen - override fun peekBackStack(): List = backStack.mapToImmutableList { it.screen } + override fun peekBackStack(): List = + navStack.snapshot().backwardStack().mapToImmutableList { it.screen } override fun resetRoot(newRoot: Screen, options: StateOptions): List { // Run this in a mutable snapshot (bit like a transaction) val currentStack = Snapshot.withMutableSnapshot { - if (options.save) backStack.saveState() + if (options.save) navStack.saveState() // Pop everything off the back stack - val popped = backStack.popUntil { false }.mapToImmutableList { it.screen } + val popped = buildList { + while (navStack.size > 0) { + val screen = navStack.remove()?.screen ?: break + add(screen) + } + } // If we're not restoring state, or the restore didn't work, we need to push the new root // onto the stack - if (!options.restore || !backStack.restoreState(newRoot)) { - backStack.push(newRoot) + if (!options.restore || !navStack.restoreState(newRoot)) { + navStack.add(newRoot) } // Clear the state if requested, do this last to allow restoring the state once. - if (options.clear) backStack.removeState(newRoot) + if (options.clear) navStack.removeState(newRoot) popped } @@ -163,19 +169,19 @@ internal class NavigatorImpl( other as NavigatorImpl - if (backStack != other.backStack) return false + if (navStack != other.navStack) return false if (onRootPop != other.onRootPop) return false return true } override fun hashCode(): Int { - var result = backStack.hashCode() + var result = navStack.hashCode() result = 31 * result + onRootPop.hashCode() return result } override fun toString(): String { - return "NavigatorImpl(backStack=$backStack, onRootPop=$onRootPop)" + return "NavigatorImpl(backStack=$navStack, onRootPop=$onRootPop)" } } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt index f2f4d7965b..b6048be50b 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavDecoration +import com.slack.circuit.foundation.NavStackList import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen @@ -98,7 +99,7 @@ public class AnimatedNavDecoration( @Composable public override fun DecoratedContent( - args: List, + args: NavStackList, navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, @@ -127,16 +128,24 @@ private fun AnimatedNavDecorator.transiti animatedScreenTransforms: Map, AnimatedScreenTransform> ): AnimatedContentTransitionScope.() -> ContentTransform = spec@{ val animatedNavEvent = - if (targetState.root != initialState.root) { - AnimatedNavEvent.RootReset - } else if (targetState.top == initialState.top) { - // Target screen has not changed, probably we should not show any animation even if the back - // stack is changed - return@spec EnterTransition.None togetherWith ExitTransition.None - } else if (initialState.backStack.contains(targetState.top)) { - AnimatedNavEvent.Pop - } else { - AnimatedNavEvent.GoTo + when { + targetState.navStack.root != initialState.navStack.root -> { + AnimatedNavEvent.RootReset + } + targetState.navStack.current == initialState.navStack.current -> { + // Target screen has not changed, probably we should not show any animation even if the back + // stack is changed + return@spec EnterTransition.None togetherWith ExitTransition.None + } + initialState.navStack.backwardStack().contains(targetState.navStack.current) -> { + AnimatedNavEvent.Pop + } + initialState.navStack.forwardStack().contains(targetState.navStack.current) -> { + AnimatedNavEvent.GoTo // todo New Event? + } + else -> { + AnimatedNavEvent.GoTo + } } val baseTransform = transitionSpec(animatedNavEvent) @@ -151,9 +160,9 @@ private fun AnimatedContentTransitionScope.screenSpecificOverr ): PartialContentTransform { // Read any screen specific overrides val targetAnimatedScreenTransform = - animatedScreenTransforms[targetState.top.screen::class] ?: NoOpAnimatedScreenTransform + animatedScreenTransforms[targetState.navStack.current.screen::class] ?: NoOpAnimatedScreenTransform val initialAnimatedScreenTransform = - animatedScreenTransforms[initialState.top.screen::class] ?: NoOpAnimatedScreenTransform + animatedScreenTransforms[initialState.navStack.current.screen::class] ?: NoOpAnimatedScreenTransform return PartialContentTransform( enter = targetAnimatedScreenTransform.run { enterTransition(animatedNavEvent) }, diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt index 400dea695c..785781e067 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt @@ -11,8 +11,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavDecoration +import com.slack.circuit.foundation.NavStackList import com.slack.circuit.runtime.Navigator -import com.slack.circuit.runtime.screen.Screen /** * [AnimatedNavDecorator] is used by [AnimatedNavDecoration] as a way to provide hooks into its @@ -95,14 +95,14 @@ import com.slack.circuit.runtime.screen.Screen @Stable public interface AnimatedNavDecorator { /** For the args create the expected target [AnimatedNavState]. */ - public fun targetState(args: List): S + public fun targetState(args: NavStackList): S /** * Sets up a [Transition] for driving an [AnimatedContent] used to navigate between screens. The * transition should be setup from the current [NavDecoration.DecoratedContent] arguments, and * then updated when the arguments change. */ - @Composable public fun updateTransition(args: List): Transition + @Composable public fun updateTransition(args: NavStackList): Transition /** Builds the default [AnimatedContent] transition spec. */ public fun AnimatedContentTransitionScope.transitionSpec( @@ -123,18 +123,10 @@ public interface AnimatedNavDecorator { /** A state created for the [Transition] in [AnimatedNavDecorator.Decoration]. */ @Stable public interface AnimatedNavState { - /** The [Screen] associated with this state. */ - public val top: NavArgument - get() = backStack.first() - - /** The root screen of the back stack at the time this state was created. */ - public val root: NavArgument - get() = backStack.last() - /** The depth of the back stack at the time this state was created. */ public val backStackDepth: Int - get() = backStack.size + get() = navStack.size - navStack.currentIndex /** Snapshot of the back stack at the time this state was created. */ - public val backStack: List + public val navStack: NavStackList } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/backstack/ViewModelBackStackRecordLocalProvider.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/backstack/ViewModelNavStackRecordLocalProvider.kt similarity index 90% rename from circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/backstack/ViewModelBackStackRecordLocalProvider.kt rename to circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/backstack/ViewModelNavStackRecordLocalProvider.kt index 862c8c7de4..f8703c4da7 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/backstack/ViewModelBackStackRecordLocalProvider.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/backstack/ViewModelNavStackRecordLocalProvider.kt @@ -17,7 +17,8 @@ import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.slack.circuit.backstack.BackStack -import com.slack.circuit.backstack.BackStackRecordLocalProvider +import com.slack.circuit.backstack.NavStack +import com.slack.circuit.backstack.NavStackRecordLocalProvider import com.slack.circuit.backstack.ProvidedValues import com.slack.circuit.retained.rememberRetained import kotlin.reflect.KClass @@ -56,7 +57,7 @@ public inline fun backStackHostViewModel( /** * Returns the [ViewModelStoreOwner] of the component hosting the back stack, populated by - * [ViewModelBackStackRecordLocalProvider] or the current [LocalViewModelStoreOwner]. + * [ViewModelNavStackRecordLocalProvider] or the current [LocalViewModelStoreOwner]. * * @return The [ViewModelStoreOwner], or `null` if none is set. */ @@ -72,21 +73,20 @@ private val LocalBackStackHostViewModelStoreOwner: } /** - * A [BackStackRecordLocalProvider] that provides a [LocalViewModelStoreOwner] for each record in - * the back stack. + * A [NavStackRecordLocalProvider] that provides a [LocalViewModelStoreOwner] for each record in the + * back stack. * * This allows [ViewModel] instances to be scoped to the lifecycle of a specific [BackStack.Record]. * It also provides [LocalBackStackHostViewModelStoreOwner] with the [ViewModelStoreOwner] of the * host of the back stack. */ -public object ViewModelBackStackRecordLocalProvider : - BackStackRecordLocalProvider { +public object ViewModelNavStackRecordLocalProvider : NavStackRecordLocalProvider { /** * Provides [LocalViewModelStoreOwner] scoped to the given [record] and * [LocalBackStackHostViewModelStoreOwner] scoped to the host of the back stack. */ @Composable - override fun providedValuesFor(record: BackStack.Record): ProvidedValues { + override fun providedValuesFor(record: NavStack.Record): ProvidedValues { // Gracefully fail if we don't have a host ViewModelStoreOwner. val backStackHostViewModelStoreOwner = LocalViewModelStoreOwner.current ?: return EMPTY_PROVIDED_VALUES @@ -115,21 +115,15 @@ public object ViewModelBackStackRecordLocalProvider : override val viewModelStore: ViewModelStore = viewModelStore }, ) - object : ProvidedValues { - @Composable - override fun provideValues(): List> { - remember { observer.UiRememberObserver() } - return list - } + ProvidedValues { + remember { observer.UiRememberObserver() } + list } } } } -private val EMPTY_PROVIDED_VALUES = - object : ProvidedValues { - @Composable override fun provideValues() = emptyList>() - } +private val EMPTY_PROVIDED_VALUES = ProvidedValues { emptyList() } /** * A [ViewModel] responsible for holding and managing [ViewModelStore] instances for each record in diff --git a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt index b57d58cc0a..f2b87b10fd 100644 --- a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt +++ b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/AnsweringNavigatorTest.kt @@ -67,7 +67,7 @@ private fun ComposeContentTestRule.setCircuitContent(circuit: Circuit): Saveable setContent { CircuitCompositionLocals(circuit) { backStack = rememberSaveableBackStack(TestScreen) - val navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) + val navigator = rememberCircuitNavigator(navStack = backStack, onRootPop = {}) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } diff --git a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/KeyedCircuitContentTests.kt b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/KeyedCircuitContentTests.kt index 1386606fb5..fdc574ba9f 100644 --- a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/KeyedCircuitContentTests.kt +++ b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/KeyedCircuitContentTests.kt @@ -169,7 +169,7 @@ class KeyedCircuitContentTests { setContent { CircuitCompositionLocals(circuit) { val backStack = rememberSaveableBackStack(screen) - navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) + navigator = rememberCircuitNavigator(navStack = backStack, onRootPop = {}) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } diff --git a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavResultTest.kt b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavResultTest.kt index 6ab50cfca6..eba206738e 100644 --- a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavResultTest.kt +++ b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavResultTest.kt @@ -118,7 +118,7 @@ class NavResultTest { answeringResultHandler = rememberAnsweringResultHandler() val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) val answeringResultNavigator = @@ -145,7 +145,7 @@ class NavResultTest { answeringResultHandler = rememberAnsweringResultHandler() val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) val answeringResultNavigator = @@ -327,7 +327,7 @@ class TestResultPresenter(private val navigator: Navigator, private val screen: */ @Composable fun UnscrupulousResultListenerEffect() { - val backStack = LocalBackStack.current!! + val backStack = LocalNavStack.current!! val resultHandler = LocalAnsweringResultHandler.current!! LaunchedEffect(Unit) { resultHandler diff --git a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt index 090c5bf896..e876337c35 100644 --- a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt +++ b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt @@ -200,7 +200,7 @@ class NavigableCircuitConditionalRetainTest { setContent { CircuitCompositionLocals(circuit) { val backStack = rememberSaveableBackStack(screen) - navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) + navigator = rememberCircuitNavigator(navStack = backStack, onRootPop = {}) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt index affb2031b5..467ae3941a 100644 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt +++ b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt @@ -51,11 +51,11 @@ class BackNavigationTest(private val androidNavigator: Boolean) { val backStack = rememberSaveableBackStack(TestScreen.RootAlpha) val navigator: Navigator if (androidNavigator) { - navigator = rememberCircuitNavigator(backStack = backStack, enableBackHandler = true) + navigator = rememberCircuitNavigator(navStack = backStack, enableBackHandler = true) } else { navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) BackHandler { navigator.pop() } diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationCrashTest.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationCrashTest.kt index 172189d58c..065f9056a9 100644 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationCrashTest.kt +++ b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationCrashTest.kt @@ -55,7 +55,7 @@ class GestureNavigationCrashTest { setContent { CircuitCompositionLocals(circuit) { val backStack = rememberSaveableBackStack(TestScreen.RootAlpha) - navigator = rememberCircuitNavigator(backStack = backStack) + navigator = rememberCircuitNavigator(navStack = backStack) NavigableCircuitContent( navigator = navigator, backStack = backStack, diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt index fbb65474d3..10ce9d2400 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt @@ -4,6 +4,7 @@ package com.slack.circuitx.gesturenavigation import androidx.compose.runtime.Immutable import com.slack.circuit.foundation.NavArgument +import com.slack.circuit.foundation.NavStackList import com.slack.circuit.foundation.animation.AnimatedNavState /** @@ -11,7 +12,6 @@ import com.slack.circuit.foundation.animation.AnimatedNavState * the necessary information as an argument, which is optimal for `AnimatedContent`. */ @Immutable -public data class GestureNavTransitionHolder(val args: List) : - AnimatedNavState { - override val backStack: List = args -} +public data class GestureNavTransitionHolder( + override val navStack: NavStackList +) : AnimatedNavState diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt index f84e792577..c77d544f34 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.backhandler.PredictiveBackHandler import androidx.compose.ui.geometry.Offset import com.slack.circuit.foundation.NavArgument +import com.slack.circuit.foundation.NavStackList import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.runtime.internal.rememberStableCoroutineScope import kotlin.math.abs @@ -41,19 +42,20 @@ public abstract class PredictiveBackNavigationDecorator( protected var swipeOffset: Offset by mutableStateOf(Offset.Zero) private set - override fun targetState(args: List): GestureNavTransitionHolder { + override fun targetState(args: NavStackList): GestureNavTransitionHolder { return GestureNavTransitionHolder(args) } @OptIn(ExperimentalComposeUiApi::class) @Composable - override fun updateTransition(args: List): Transition> { + override fun updateTransition(args: NavStackList): Transition> { val scope = rememberStableCoroutineScope() val current = remember(args) { targetState(args) } val previous = remember(args) { - if (args.size > 1) { - targetState(args.subList(1, args.size)) + val backwardStack = args.backwardStack() + if (backwardStack.size > 1) { + targetState(NavStackList(backwardStack.subList(1, args.size))) } else null } @@ -81,7 +83,7 @@ public abstract class PredictiveBackNavigationDecorator( } } - if (args.size > 1) { + if (previous != null) { BackHandler( onBackProgress = { progress, offset -> showPrevious = progress != 0f diff --git a/circuitx/gesture-navigation/src/commonTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationStateTest.kt b/circuitx/gesture-navigation/src/commonTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationStateTest.kt index a55a3ab64f..cd3696bb89 100644 --- a/circuitx/gesture-navigation/src/commonTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationStateTest.kt +++ b/circuitx/gesture-navigation/src/commonTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationStateTest.kt @@ -49,7 +49,7 @@ internal interface GestureNavigationStateTest { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent( @@ -138,7 +138,7 @@ internal interface GestureNavigationStateTest { val backStack = rememberSaveableBackStack(TestScreen.RootAlpha) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, // no-op for tests ) NavigableCircuitContent( diff --git a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt index 86c4a57238..fe5661a07e 100644 --- a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt +++ b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt @@ -84,12 +84,12 @@ class BackNavigationTest { if (useIntegratedBackHandler) { navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, enableBackHandler = true, ) } else { - navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) + navigator = rememberCircuitNavigator(navStack = backStack, onRootPop = {}) BackHandler { navigator.pop() } } NavigableCircuitContent( diff --git a/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt b/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt index 01d006ced1..c511c5bbbe 100644 --- a/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt +++ b/circuitx/navigation/src/jvmTest/kotlin/com/slack/circuitx/navigation/intercepting/NavigatorBackHandlerTest.kt @@ -51,7 +51,7 @@ class NavigatorBackHandlerTest { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val circuitNavigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = { BackDispatcher.onBack() }, enableBackHandler = true, ) @@ -90,7 +90,7 @@ class NavigatorBackHandlerTest { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) val circuitNavigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = { BackDispatcher.onBack() }, enableBackHandler = true, ) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt index 811b679b64..249e393ad3 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt @@ -45,6 +45,7 @@ import androidx.window.core.layout.WindowWidthSizeClass import com.slack.circuit.foundation.DelicateCircuitFoundationApi import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavDecoration +import com.slack.circuit.foundation.NavStackList import com.slack.circuit.foundation.animation.AnimatedNavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform @@ -114,7 +115,7 @@ class AdaptiveListDetailNavDecoration( @OptIn(DelicateCircuitFoundationApi::class) @Composable override fun DecoratedContent( - args: List, + args: NavStackList, navigator: Navigator, modifier: Modifier, content: @Composable (T) -> Unit, @@ -128,7 +129,7 @@ class AdaptiveListDetailNavDecoration( ProvideAnimatedTransitionScope(AdaptiveListDetailAnimatedScope, this) { if (shouldUsePaneLayout) { // todo I had expected the `PaneContent` retains to work with just the navigable_registry - rememberRetainedStateHolder().RetainedStateProvider("list-detail-${args.first().key}") { + rememberRetainedStateHolder().RetainedStateProvider("list-detail-${args.current.key}") { ListDetailContent( args = args, navigator = navigator, @@ -155,7 +156,7 @@ class AdaptiveListDetailNavDecoration( ) @Composable private fun ListDetailContent( - args: List, + args: NavStackList, navigator: Navigator, listDetailScaffoldStyle: ListDetailScaffoldStyle, modifier: Modifier = Modifier, @@ -167,7 +168,7 @@ class AdaptiveListDetailNavDecoration( val secondaryArgs = with(secondaryLookupTransition) { currentState[primary] ?: targetState[primary] } - val hasSecondary = !secondaryArgs.isNullOrEmpty() + val hasSecondary = !secondaryArgs?.entries.isNullOrEmpty() val scaffoldValue = if (hasSecondary) PrimarySecondary else Primary val scaffoldState = remember { MutableThreePaneScaffoldState(scaffoldValue) } @@ -328,33 +329,39 @@ private fun Density.paneExpansionAnchors( } /** - * Partitions navigation arguments into primary (list) and secondary (detail) panes based on the - * [showInDetailPane] predicate. + * Strategy that partitions navigation arguments into primary (list) and secondary (detail) panes + * based on the [showInDetailPane] predicate. */ @Composable internal fun rememberListDetailNavArguments( - args: List, + navStackList: NavStackList, showInDetailPane: (T) -> Boolean, -): Pair, Map>> = - remember(args) { +): Pair, Map>> = + remember(navStackList) { val primary = mutableListOf() - val secondaryLookup = mutableMapOf>() + val secondaryLookup = mutableMapOf>() val secondary = mutableListOf() - for (arg in args) { + for (arg in navStackList.backwardStack()) { when { showInDetailPane(arg) -> { secondary += arg } else -> { primary += arg - secondaryLookup[arg] = secondary.toList() + secondaryLookup[arg] = NavStackList(secondary.toList()) secondary.clear() } } } if (primary.isEmpty()) { - secondary.toList() to emptyMap() + NavStackList(secondary.toList()) to emptyMap() } else { - primary.toList() to secondaryLookup.toMap() + // Show the next secondary if it exists + navStackList + .forwardStack() + .firstOrNull() + ?.takeIf { showInDetailPane(it) } + ?.let { secondaryLookup.getOrPut(primary.first()) { NavStackList(listOf(it)) } } + NavStackList(primary.toList()) to secondaryLookup.toMap() } } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 46bc6ab67d..3e7016caab 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -55,6 +55,7 @@ import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionLayout import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult +import com.slack.circuitx.navigation.intercepting.NavigationContext import com.slack.circuitx.navigation.intercepting.NavigationInterceptor import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.Skipped import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.SuccessConsumed @@ -113,6 +114,7 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> override fun resetRoot( newRoot: Screen, options: Navigator.StateOptions, + navigationContext: NavigationContext, ): InterceptedResetRootResult { return when (newRoot) { is TabScreen, @@ -126,7 +128,7 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> } } - override fun goTo(screen: Screen): InterceptedGoToResult { + override fun goTo(screen: Screen, navigationContext: NavigationContext): InterceptedGoToResult { return when (screen) { is TabScreen, is DetailScreen -> { diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SlideOverNavigationDecorator.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SlideOverNavigationDecorator.kt deleted file mode 100644 index 5aff124cb4..0000000000 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/SlideOverNavigationDecorator.kt +++ /dev/null @@ -1,289 +0,0 @@ -// Copyright (C) 2025 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.sample.navigation - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.PredictiveBackHandler -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onLayoutRectChanged -import com.slack.circuit.foundation.NavArgument -import com.slack.circuit.foundation.NavDecoration -import com.slack.circuit.foundation.NavigatorDefaults -import com.slack.circuit.foundation.animation.AnimatedNavDecoration -import com.slack.circuit.foundation.animation.AnimatedNavDecorator -import com.slack.circuit.foundation.animation.AnimatedScreenTransform -import com.slack.circuit.runtime.ExperimentalCircuitApi -import com.slack.circuit.runtime.InternalCircuitApi -import com.slack.circuit.runtime.Navigator -import com.slack.circuit.runtime.Navigator.StateOptions -import com.slack.circuit.runtime.screen.PopResult -import com.slack.circuit.runtime.screen.Screen -import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope -import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation -import kotlin.coroutines.cancellation.CancellationException -import kotlin.reflect.KClass -import kotlinx.coroutines.launch - -@OptIn(ExperimentalCircuitApi::class) -class SlideOverNavDecoration( - screenTransforms: Map, AnimatedScreenTransform>, - private val decoratorFactory: AnimatedNavDecorator.Factory, - private val slideOverNavState: SlideOverNavState, - private val showInDetailPane: (NavArgument) -> Boolean, - private val scrimColor: @Composable () -> Color, - private val backgroundColor: @Composable () -> Color, -) : NavDecoration { - - private val delegate = - AnimatedNavDecoration( - animatedScreenTransforms = screenTransforms, - decoratorFactory = decoratorFactory, - ) - - @OptIn( - ExperimentalComposeUiApi::class, - ExperimentalSharedTransitionApi::class, - InternalCircuitApi::class, - ) - @Composable - override fun DecoratedContent( - args: List, - navigator: Navigator, - modifier: Modifier, - content: @Composable ((T) -> Unit), - ) { - DisposableEffect(Unit) { - slideOverNavState.isDecorating = true - onDispose { slideOverNavState.isDecorating = false } - } - val (primaryArgs, secondaryLookup) = rememberListDetailNavArguments(args, showInDetailPane) - - val primary = primaryArgs.first() - val secondary = secondaryLookup[primary]?.singleOrNull() - val detailLookupTransition = updateTransition(primary to secondary) - val delegateArgs = if (secondary != null) primaryArgs else args - - delegate.DecoratedContent(delegateArgs, navigator, modifier) { base -> - val detail = - with(detailLookupTransition) { - val (currentBase, currentDetail) = currentState - val (targetBase, targetDetail) = targetState - when { - // todo Fix composition timings - currentBase == base -> currentDetail - targetBase == base -> targetDetail - else -> null - } - } - - // TODO State and somehow use the decorator animations - var maxOffset by remember { mutableFloatStateOf(0f) } - val offset = remember(detail?.key) { Animatable(0f) } - val scope = rememberCoroutineScope() - // todo Real drag handling with velocity etc - val dragState = rememberDraggableState { drag -> - scope.launch { offset.snapTo((offset.value + drag).coerceIn(0f, maxOffset)) } - } - var isDragging by remember { mutableStateOf(false) } - val isOffScreen = slideOverNavState.isOffScreen(detail?.screen) - var wasOffScreen by remember { mutableStateOf(isOffScreen) } - LaunchedEffect(isOffScreen) { - when { - isOffScreen -> offset.snapTo(maxOffset) - wasOffScreen -> offset.animateTo(0f) - else -> offset.snapTo(0f) - } - wasOffScreen = isOffScreen - } - - Box( - modifier = - Modifier.onLayoutRectChanged { maxOffset = it.width.toFloat() } - .draggable( - state = dragState, - enabled = isOffScreen, - orientation = Orientation.Horizontal, - onDragStarted = { isDragging = true }, - onDragStopped = { - if (it > maxOffset / 0.4f) { // swipeThreshold: Float = 0.4f, - offset.animateTo(maxOffset) - } else { - offset.animateTo(0f) - slideOverNavState.setOnScreen(detail?.screen) - } - isDragging = false - }, - ) - ) { - - // Base - val scrimColor = scrimColor() - Box( - modifier = - Modifier.graphicsLayer { - if (detail != null) { - val percentage = offset.value / maxOffset - val max = - (maxOffset * .25f).coerceAtLeast(0f) // enterOffsetFraction: Float = 0.25f, - translationX = -max + (percentage * max).coerceIn(0f, max) - } else { - translationX = 0f - } - } - .drawWithContent { - drawContent() - if (detail != null) { - val max = maxOffset * .6f // swipeThreshold: Float = 0.4f, - val percentage = (offset.value / max).coerceIn(0f, 1f) - val alpha = scrimColor.alpha - (scrimColor.alpha * percentage) - drawRect( - color = scrimColor, - size = size.copy(width = size.width * 1.25f), - alpha = alpha, - ) - } - } - .run { - if (detail != null && maxOffset == 0f) { - pointerInput(Unit) {} - } else this - } - ) { - content(base) - } - - // SlideOver - AnimatedVisibility( - visible = detail != null, - modifier = Modifier.fillMaxSize(), - enter = - if (isOffScreen) EnterTransition.None else NavigatorDefaults.forward.targetContentEnter, - exit = - if (isOffScreen) ExitTransition.None else NavigatorDefaults.backward.initialContentExit, - ) { - if (detail != null) { - ProvideAnimatedTransitionScope(Navigation, this) { - val backgroundColor = backgroundColor() - Box( - modifier = - Modifier.graphicsLayer { translationX = offset.value } - .background(backgroundColor) - .run { - if (maxOffset != 0f) { - pointerInput(Unit) {} - } else this - } - ) { - content(detail) - } - PredictiveBackHandler(enabled = !isOffScreen) { progress -> - try { - progress.collect { backEvent -> - val progress = backEvent.progress * maxOffset - offset.snapTo(progress) - } - navigator.pop() - } catch (_: CancellationException) { - offset.animateTo(0f) - } - } - } - } - } - } - } - } -} - -@Composable -fun rememberSlideOverNavState( - showInDetailPane: (Screen) -> Boolean, - delegate: Navigator, -): SlideOverNavState { - // todo saveable - return remember(delegate) { SlideOverNavState(showInDetailPane, delegate) } - .apply { this.showInDetailPane = showInDetailPane } -} - -class SlideOverNavState(var showInDetailPane: (Screen) -> Boolean, val delegate: Navigator) : - Navigator by delegate { - - var isDecorating by mutableStateOf(false) - internal set - - var isActive by mutableStateOf(false) - internal set - - private val isOffScreen = mutableStateMapOf() - - fun isOffScreen(screen: Screen?): Boolean { - return screen != null && isOffScreen[screen] == true - } - - fun setOnScreen(screen: Screen?) { - isOffScreen.remove(screen) - } - - override fun goTo(screen: Screen): Boolean { - val peekBackStack = peekBackStack() - val topScreen = peekBackStack.first() - return Snapshot.withMutableSnapshot { - if (isDecorating && isOffScreen(topScreen)) { - // Pop off the top one to replace it as we're not navigating from the detail screen - delegate.pop() - } - isOffScreen.remove(topScreen) - // Then go to the new screen - delegate.goTo(screen) - } - } - - override fun pop(result: PopResult?): Screen? { - val peekBackStack = peekBackStack() - val topScreen = peekBackStack.first() - val nextScreen = peekBackStack.getOrNull(1) - if (isDecorating && nextScreen != null) { - // Slide over the detail screen - if (!isOffScreen(topScreen) && showInDetailPane(topScreen) && !showInDetailPane(nextScreen)) { - isOffScreen[topScreen] = true - return null - } - setOnScreen(nextScreen) - } - setOnScreen(topScreen) - return delegate.pop(result) - } - - override fun resetRoot(newRoot: Screen, options: StateOptions): List { - setOnScreen(newRoot) - // todo restore is open state here? - return delegate.resetRoot(newRoot, options) - } -} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index f3f3219c97..2c3815b657 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.slack.circuit.foundation.DelicateCircuitFoundationApi -import com.slack.circuit.foundation.LocalBackStack +import com.slack.circuit.foundation.LocalNavStack import com.slack.circuit.internal.runtime.Parcelize import com.slack.circuit.runtime.CircuitContext import com.slack.circuit.runtime.CircuitUiEvent @@ -112,7 +112,7 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat @Composable fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = Modifier) = SharedElementTransitionScope { - val backStack = LocalBackStack.current?.toList().orEmpty() + val backStack = LocalNavStack.current?.toList().orEmpty() Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), diff --git a/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/GestureSaveableRestRootTest.kt b/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/GestureSaveableRestRootTest.kt index d0e17e69ed..5e60df1857 100644 --- a/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/GestureSaveableRestRootTest.kt +++ b/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/GestureSaveableRestRootTest.kt @@ -104,7 +104,7 @@ class GestureSaveableRestRootTest { private fun TestContent(liftNavigator: (Navigator) -> Unit) { Column(Modifier.windowInsetsPadding(WindowInsets.safeDrawing)) { val backStack = rememberSaveableBackStack(root = TestScreen.RootAlpha) - val navigator = rememberCircuitNavigator(backStack = backStack) + val navigator = rememberCircuitNavigator(navStack = backStack) SideEffect { liftNavigator(navigator) } NavigableCircuitContent(navigator, backStack, modifier = Modifier) } diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt index 99b42b7732..34a7c644c3 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt @@ -135,7 +135,7 @@ fun HomeContent(state: HomeScreen.State, modifier: Modifier = Modifier) = val backStack = rememberSaveableBackStack(root = state.navItems[state.selectedIndex].screen) val navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = { state.eventSink(HomeScreen.Event.Back) }, ) From 48bd6f1b860751d98028f6a86201c8e92efc79a3 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 14 Nov 2025 15:14:56 -0800 Subject: [PATCH 37/44] Start wiring into NavEventHandler --- .../circuit/foundation/Navigator.android.kt | 5 +- .../circuit/foundation/AnsweringNavigator.kt | 9 +- .../com/slack/circuit/foundation/Circuit.kt | 6 +- .../circuit/foundation/CircuitContent.kt | 8 ++ .../slack/circuit/foundation/NavDecoration.kt | 1 + .../com/slack/circuit/foundation/NavEvent.kt | 7 + .../foundation/NavigableCircuitContent.kt | 1 + .../slack/circuit/foundation/NavigatorImpl.kt | 8 ++ .../foundation/animation/AnimatedNavEvent.kt | 1 + .../internal/PredictiveBackHandler.kt | 124 ++++++++++++------ .../com/slack/circuit/runtime/Navigator.kt | 8 ++ ...droidPredictiveBackNavigationDecoration.kt | 21 ++- .../PredictiveBackNavigationDecorator.kt | 72 +++++++--- gradle/libs.versions.toml | 2 +- samples/bottom-navigation/build.gradle.kts | 1 - .../circuit/sample/navigation/MainActivity.kt | 8 +- .../AdaptiveListDetailNavDecoration.kt | 1 + .../sample/navigation/ContentScreen.kt | 51 +++---- .../circuit/sample/navigation/TabScreen.kt | 16 ++- 19 files changed, 243 insertions(+), 107 deletions(-) diff --git a/circuit-foundation/src/androidMain/kotlin/com/slack/circuit/foundation/Navigator.android.kt b/circuit-foundation/src/androidMain/kotlin/com/slack/circuit/foundation/Navigator.android.kt index 5377f37995..2428e5e35d 100644 --- a/circuit-foundation/src/androidMain/kotlin/com/slack/circuit/foundation/Navigator.android.kt +++ b/circuit-foundation/src/androidMain/kotlin/com/slack/circuit/foundation/Navigator.android.kt @@ -4,9 +4,8 @@ package com.slack.circuit.foundation import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.Composable -import com.slack.circuit.backstack.BackStack -import com.slack.circuit.backstack.BackStack.Record import com.slack.circuit.backstack.NavStack +import com.slack.circuit.backstack.NavStack.Record import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.PopResult @@ -14,7 +13,7 @@ import com.slack.circuit.runtime.screen.PopResult * Returns a new [Navigator] for navigating within [CircuitContents][CircuitContent]. Delegates * onRootPop to the [LocalOnBackPressedDispatcherOwner]. * - * @param navStack The backing [BackStack] to navigate. + * @param navStack The backing [NavStack] to navigate. * @param enableBackHandler Indicates whether or not [Navigator.pop] should be called by the system * back handler. Defaults to true. * @see NavigableCircuitContent diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt index a4d6f9732b..bbac3415e4 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import com.slack.circuit.backstack.BackStack +import com.slack.circuit.backstack.NavStack import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.GoToNavigator import com.slack.circuit.runtime.Navigator @@ -71,7 +72,7 @@ public inline fun rememberAnsweringNavigator( } /** - * Returns a [GoToNavigator] that answers with the given [resultType] using the given [backStack]. + * Returns a [GoToNavigator] that answers with the given [resultType] using the given [navStack]. * * Handling of the result type [T] should be handled in the [block] parameter and is guaranteed to * only be called _at most_ once. It may never be called if the navigated screen never pops with a @@ -99,12 +100,12 @@ public inline fun rememberAnsweringNavigator( @ExperimentalCircuitApi @Composable public fun rememberAnsweringNavigator( - backStack: BackStack, + navStack: NavStack, answeringResultHandler: AnsweringResultHandler, resultType: KClass, block: (result: T) -> Unit, ): GoToNavigator { - val currentBackStack by rememberUpdatedState(backStack) + val currentBackStack by rememberUpdatedState(navStack) val currentResultType by rememberUpdatedState(resultType) // Top screen at the start, so we can ensure we only collect the result if @@ -138,7 +139,7 @@ public fun rememberAnsweringNavigator( object : GoToNavigator { override fun goTo(screen: Screen): Boolean { val previousTopRecord = currentBackStack.topRecord - val success = currentBackStack.push(screen) + val success = currentBackStack.add(screen) if (success) { // Clear the cached pending result from the previous top record if (previousTopRecord != null) { diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt index d82e40fb21..f5db834b6e 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/Circuit.kt @@ -291,11 +291,11 @@ public class Circuit private constructor(builder: Builder) { } public fun addBackStackRecordLocalProvider( - provider: NavStackRecordLocalProvider + provider: NavStackRecordLocalProvider ): Builder = apply { backStackLocalProviders.add(provider) } public fun addBackStackRecordLocalProvider( - vararg providers: NavStackRecordLocalProvider + vararg providers: NavStackRecordLocalProvider ): Builder = apply { for (p in providers) { backStackLocalProviders.add(p) @@ -303,7 +303,7 @@ public class Circuit private constructor(builder: Builder) { } public fun addBackStackRecordLocalProviders( - providers: Iterable> + providers: Iterable> ): Builder = apply { backStackLocalProviders.addAll(providers) } public fun clearBackStackRecordLocalProviders(): Builder = apply { diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt index 49fd1d70a2..94edb134f2 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt @@ -49,6 +49,14 @@ public fun CircuitContent( return true } + override fun forward(): Boolean { + TODO("Not yet implemented") + } + + override fun backward(): Boolean { + TODO("Not yet implemented") + } + override fun resetRoot(newRoot: Screen, options: StateOptions): List { onNavEvent(NavEvent.ResetRoot(newRoot, options)) return emptyList() diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt index 3525aa060e..e75a06b7fd 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -127,6 +127,7 @@ public data class NavStackList(val entries: List, val currentIndex: Int = * backwardStack() = [B, C, D] // Current B, can move back through C to D * ``` */ + // todo Should this include the current entry? public fun backwardStack(): List = entries.subList(currentIndex, entries.size) } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt index 4506e52e08..fc69e9572c 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt @@ -18,11 +18,18 @@ public fun Navigator.onNavEvent(event: NavEvent) { is NavEvent.Pop -> pop(event.result) is NavEvent.GoTo -> goTo(event.screen) is NavEvent.ResetRoot -> resetRoot(event.newRoot, event.options) + is NavEvent.Backward -> backward() + is NavEvent.Forward -> forward() } } /** A sealed navigation interface intended to be used when making a navigation callback. */ public sealed interface NavEvent : CircuitUiEvent { + + public data object Forward : NavEvent + + public data object Backward : NavEvent + /** Corresponds to [Navigator.pop]. */ public data class Pop(val result: PopResult? = null) : NavEvent diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 1133ddde43..712d592e6c 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -597,6 +597,7 @@ public object NavigatorDefaults { // the transitionSpec recomposing. // The states are available as `targetState` and `initialState`. return when (animatedNavEvent) { + AnimatedNavEvent.Forward -> forward AnimatedNavEvent.GoTo -> forward AnimatedNavEvent.Pop -> backward AnimatedNavEvent.RootReset -> fadeIn() togetherWith fadeOut() diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt index ec891d8918..a534722c6b 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt @@ -126,6 +126,14 @@ internal class NavigatorImpl( return navStack.add(screen) } + override fun forward(): Boolean { + return navStack.move(NavStack.Direction.Forward) + } + + override fun backward(): Boolean { + return navStack.move(NavStack.Direction.Backward) + } + override fun pop(result: PopResult?): Screen? { if (navStack.isAtRoot) { onRootPop(result) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt index a90214f661..1358bca795 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt @@ -12,5 +12,6 @@ package com.slack.circuit.foundation.animation public enum class AnimatedNavEvent { GoTo, Pop, + Forward, RootReset, } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt index af039fdc7e..9f15bf2bfe 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt @@ -12,7 +12,7 @@ import androidx.navigationevent.NavigationEventDispatcher import androidx.navigationevent.NavigationEventHandler import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner -import com.slack.circuit.foundation.internal.PredictiveBack.Event +import com.slack.circuit.foundation.internal.PredictiveNavProgress.Event import com.slack.circuit.runtime.InternalCircuitApi import com.slack.circuit.runtime.internal.rememberStableCoroutineScope import kotlin.coroutines.cancellation.CancellationException @@ -23,46 +23,55 @@ import kotlinx.coroutines.launch @InternalCircuitApi @Composable -public fun PredictiveBackEventHandler( - isEnabled: Boolean = true, - onBackProgress: suspend (Float, Offset) -> Unit, - onBackCancelled: suspend () -> Unit, - onBackCompleted: suspend () -> Unit, +public fun PredictiveNavEventHandler( + isBackEnabled: Boolean = true, + isForwardEnabled: Boolean = false, + onProgress: suspend (PredictiveNavDirection, Float, Offset) -> Unit = { _, _, _ -> }, + onCancelled: suspend (PredictiveNavDirection) -> Unit = {}, + onCompleted: suspend (PredictiveNavDirection) -> Unit = {}, ) { val dispatcher = LocalNavigationEventDispatcherOwner.current?.navigationEventDispatcher ?: return val scope = rememberStableCoroutineScope() - val handler = remember(dispatcher) { PredictiveBackEventHandler(isEnabled, scope, dispatcher) } - SideEffect { - with(handler) { - isBackEnabled = isEnabled - onProgress = onBackProgress - onCancelled = onBackCancelled - onCompleted = onBackCompleted + val handler = + remember(dispatcher) { + PredictiveNavEventHandler(isBackEnabled, isForwardEnabled, scope, dispatcher) } + SideEffect { + handler.isBackEnabled = isBackEnabled + handler.onProgress = onProgress + handler.onCancelled = onCancelled + handler.onCompleted = onCompleted } } +public enum class PredictiveNavDirection { + Back, + Forward, +} + @OptIn(InternalCircuitApi::class) -private class PredictiveBackEventHandler( - isEnabled: Boolean, +private class PredictiveNavEventHandler( + isBackEnabled: Boolean, + isForwardEnabled: Boolean, private val scope: CoroutineScope, private val dispatcher: NavigationEventDispatcher, ) : RememberObserver, NavigationEventHandler( initialInfo = NavigationEventInfo.None, - isBackEnabled = isEnabled, + isBackEnabled = isBackEnabled, + isForwardEnabled = isForwardEnabled, ) { - var back: PredictiveBack? = null + var current: PredictiveNavProgress? = null - var onProgress: suspend (Float, Offset) -> Unit = { _, _ -> } - var onCancelled: suspend () -> Unit = {} - var onCompleted: suspend () -> Unit = {} + var onProgress: suspend (PredictiveNavDirection, Float, Offset) -> Unit = { _, _, _ -> } + var onCancelled: suspend (PredictiveNavDirection) -> Unit = {} + var onCompleted: suspend (PredictiveNavDirection) -> Unit = {} override fun onBackStarted(event: NavigationEvent) { - back?.cancel() - back = PredictiveBack(scope) { event -> onEvent(event) } + current?.cancel() + current = PredictiveNavProgress(scope) { event -> onEvent(PredictiveNavDirection.Back, event) } } override fun onBackProgressed(event: NavigationEvent) { @@ -73,30 +82,61 @@ private class PredictiveBackEventHandler( NavigationEvent.EDGE_RIGHT -> -event.progress else -> 0f } - back?.send(Event.Progress(progress, offset)) + current?.send(Event.Progress(progress, offset)) } override fun onBackCancelled() { - back?.send(Event.Canceled) - back = null + current?.send(Event.Canceled) + current = null } override fun onBackCompleted() { - if (back == null) { + if (current == null) { + // Can happen if the back event is just a single "onBackPressed". + onBackStarted(NavigationEvent()) + } + current?.send(Event.Completed) + current = null + } + + override fun onForwardStarted(event: NavigationEvent) { + current?.cancel() + current = PredictiveNavProgress(scope) { event -> onEvent(PredictiveNavDirection.Back, event) } + } + + override fun onForwardProgressed(event: NavigationEvent) { + val offset = Offset(event.touchX, event.touchY) + val progress = + when (event.swipeEdge) { + // todo No idea what to do here + NavigationEvent.EDGE_LEFT -> -event.progress + NavigationEvent.EDGE_RIGHT -> event.progress + else -> 0f + } + current?.send(Event.Progress(progress, offset)) + } + + override fun onForwardCancelled() { + current?.send(Event.Canceled) + current = null + } + + override fun onForwardCompleted() { + if (current == null) { // Can happen if the back event is just a single "onBackPressed". onBackStarted(NavigationEvent()) } - back?.send(Event.Completed) - back = null + current?.send(Event.Completed) + current = null } override fun onRemembered() { - back = null + current = null dispatcher.addHandler(this) } override fun onForgotten() { - back?.cancel() + current?.cancel() remove() } @@ -104,26 +144,34 @@ private class PredictiveBackEventHandler( onForgotten() } - private suspend fun onEvent(event: Event) { + private suspend fun onEvent(direction: PredictiveNavDirection, event: Event) { try { - if (isBackEnabled) { + val isEnabled = + when (direction) { + PredictiveNavDirection.Back -> isBackEnabled + PredictiveNavDirection.Forward -> isForwardEnabled + } + if (isEnabled) { when (event) { - is Event.Progress -> onProgress(event.progress, event.offset) - is Event.Completed -> onCompleted() - is Event.Canceled -> onCancelled() + is Event.Progress -> onProgress(direction, event.progress, event.offset) + is Event.Completed -> onCompleted(direction) + is Event.Canceled -> onCancelled(direction) } } else { - onCancelled() + onCancelled(direction) } } catch (e: CancellationException) { - onCancelled() + onCancelled(direction) throw e } } } @InternalCircuitApi -public class PredictiveBack(scope: CoroutineScope, public val onEvent: suspend (Event) -> Unit) { +public class PredictiveNavProgress( + scope: CoroutineScope, + public val onEvent: suspend (Event) -> Unit, +) { private var initialTouch = Offset.Zero private val channel = Channel(capacity = BUFFERED) diff --git a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt index 81388ae9e3..7b8d94d85c 100644 --- a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt +++ b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt @@ -23,6 +23,10 @@ public interface GoToNavigator { public interface Navigator : GoToNavigator { public override fun goTo(screen: Screen): Boolean + public fun forward(): Boolean + + public fun backward(): Boolean + public fun pop(result: PopResult? = null): Screen? /** Returns current top most screen of backstack, or null if backstack is empty. */ @@ -111,6 +115,10 @@ public interface Navigator : GoToNavigator { public object NoOp : Navigator { override fun goTo(screen: Screen): Boolean = true + override fun forward(): Boolean = false + + override fun backward(): Boolean = false + override fun pop(result: PopResult?): Screen? = null override fun peek(): Screen? = null diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index b944e6edb9..c91d9ea2af 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -44,8 +44,10 @@ public actual fun GestureNavigationDecorationFactory( } } -internal class AndroidPredictiveBackNavDecorator(onBackInvoked: () -> Unit) : - PredictiveBackNavigationDecorator(onBackInvoked) { +internal class AndroidPredictiveBackNavDecorator( + onBackInvoked: () -> Unit, + onForwardInvoked: () -> Unit, +) : PredictiveBackNavigationDecorator(onBackInvoked, onForwardInvoked) { // Track popped zIndex so screens are layered correctly private var zIndexDepth = 0f @@ -69,6 +71,14 @@ internal class AndroidPredictiveBackNavDecorator(onBackInvoked: } .apply { targetContentZIndex = --zIndexDepth } } + AnimatedNavEvent.Forward -> { + if (showNext) { + // Handle all the animation in draggable + EnterTransition.None togetherWith ExitTransition.None + } else { + NavigatorDefaults.forward + } + } // Root reset. Crossfade AnimatedNavEvent.RootReset -> { zIndexDepth = 0f @@ -93,13 +103,16 @@ internal class AndroidPredictiveBackNavDecorator(onBackInvoked: progress = { seekableTransitionState.fraction }, ) ) { - innerContent(targetState.args.first()) + innerContent(targetState.navStack.current) } } object Factory : AnimatedNavDecorator.Factory { override fun create(navigator: Navigator): AnimatedNavDecorator { - return AndroidPredictiveBackNavDecorator(onBackInvoked = { navigator.pop() }) + return AndroidPredictiveBackNavDecorator( + onBackInvoked = { navigator.backward() }, + onForwardInvoked = { navigator.forward() }, + ) } } } diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt index 80dbe06567..d27537dbed 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt @@ -17,12 +17,14 @@ import androidx.compose.ui.geometry.Offset import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavStackList import com.slack.circuit.foundation.animation.AnimatedNavDecorator -import com.slack.circuit.foundation.internal.PredictiveBackEventHandler +import com.slack.circuit.foundation.internal.PredictiveNavDirection +import com.slack.circuit.foundation.internal.PredictiveNavEventHandler import com.slack.circuit.runtime.InternalCircuitApi import kotlin.math.abs public abstract class PredictiveBackNavigationDecorator( - private val onBackInvoked: () -> Unit + private val onBackInvoked: () -> Unit, + private val onForwardInvoked: () -> Unit, ) : AnimatedNavDecorator> { protected lateinit var seekableTransitionState: @@ -30,6 +32,7 @@ public abstract class PredictiveBackNavigationDecorator( private set protected var showPrevious: Boolean by mutableStateOf(false) + protected var showNext: Boolean by mutableStateOf(false) protected var swipeProgress: Float by mutableFloatStateOf(0f) protected var isSeeking: Boolean by mutableStateOf(false) @@ -50,7 +53,15 @@ public abstract class PredictiveBackNavigationDecorator( remember(args) { val backwardStack = args.backwardStack() if (backwardStack.size > 1) { - targetState(NavStackList(backwardStack.subList(1, args.size))) + targetState(NavStackList(backwardStack.subList(1, backwardStack.size))) + } else null + } + + val next = + remember(args) { + val forwardStack = args.forwardStack() + if (forwardStack.isNotEmpty()) { + targetState(NavStackList(forwardStack.subList(0, forwardStack.size))) } else null } @@ -63,32 +74,59 @@ public abstract class PredictiveBackNavigationDecorator( // After the current state has changed (i.e. any transition has completed), // clear out any transient state showPrevious = false + showNext = false swipeOffset = Offset.Zero } - LaunchedEffect(previous, current) { - if (previous != null) { - snapshotFlow { swipeProgress } - .collect { progress -> - if (progress != 0f) { + // todo Based on the swipe direction + LaunchedEffect(previous, current, next) { + snapshotFlow { Triple(swipeProgress, showNext, showPrevious) } + .collect { (progress, showingNext, showingPrevious) -> + val showingPrevious = + previous != null && showingPrevious && !showingNext && progress != 0f + val showingNext = next != null && showingNext && !showingPrevious && progress != 0f + when { + showingPrevious -> { isSeeking = true seekableTransitionState.seekTo(fraction = abs(progress), targetState = previous) } + showingNext -> { + isSeeking = true + seekableTransitionState.seekTo(fraction = abs(progress), targetState = next) + } } - } + } } - PredictiveBackEventHandler( - isEnabled = args.size > 1, - onBackProgress = { progress, offset -> - showPrevious = progress != 0f - swipeProgress = progress - swipeOffset = offset + + PredictiveNavEventHandler( + isBackEnabled = previous != null, + isForwardEnabled = next != null, + onProgress = { direction, progress, offset -> + when (direction) { + PredictiveNavDirection.Back -> { + showNext = false + showPrevious = progress != 0f + swipeProgress = progress + swipeOffset = offset + } + PredictiveNavDirection.Forward -> { + showPrevious = false + showNext = progress != 0f + swipeProgress = progress + swipeOffset = offset + } + } }, - onBackCancelled = { + onCancelled = { isSeeking = false seekableTransitionState.animateTo(current) }, - onBackCompleted = { onBackInvoked() }, + onCompleted = { direction -> + when (direction) { + PredictiveNavDirection.Back -> onBackInvoked() + PredictiveNavDirection.Forward -> onForwardInvoked() + } + }, ) return rememberTransition(seekableTransitionState, label = "PredictiveBackNavigationDecorator") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d15b8470d..0d3a02bb64 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ eithernet = "2.0.0" jb-compose = "1.10.0-beta01" jb-compose-adaptive = "1.1.2" jb-compose-material-icons-core = "1.7.3" -jb-compose-material3 = "1.9.3" +jb-compose-material3 = "1.9.0" jb-compose-navigationevent = "1.0.0-beta01" jb-lifecycle = "2.9.6" jdk = "23" diff --git a/samples/bottom-navigation/build.gradle.kts b/samples/bottom-navigation/build.gradle.kts index 71af6f3269..f781ef3167 100644 --- a/samples/bottom-navigation/build.gradle.kts +++ b/samples/bottom-navigation/build.gradle.kts @@ -8,7 +8,6 @@ plugins { alias(libs.plugins.compose) alias(libs.plugins.agp.application) alias(libs.plugins.kotlin.plugin.parcelize) - alias(libs.plugins.compose.hotReload) } android { diff --git a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt index b0c92b7996..b3c3af3bd6 100644 --- a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt +++ b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.core.net.toUri import androidx.core.view.WindowCompat -import com.slack.circuit.backstack.rememberSaveableBackStack +import com.slack.circuit.backstack.rememberSaveableNavStack import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator @@ -47,8 +47,8 @@ class MainActivity : AppCompatActivity() { val tabs = TabScreen.all setContent { MaterialTheme { - val backStack = rememberSaveableBackStack(ContentScreen(tabs)) - val navigator = rememberCircuitNavigator(backStack) + val navStack = rememberSaveableNavStack(ContentScreen(tabs)) + val navigator = rememberCircuitNavigator(navStack) // Build the delegate Navigator. val interceptingNavigator = rememberInterceptingNavigator( @@ -67,7 +67,7 @@ class MainActivity : AppCompatActivity() { CircuitCompositionLocals(circuit) { NavigableCircuitContent( navigator = interceptingNavigator, - backStack = backStack, + navStack = navStack, modifier = Modifier.fillMaxSize(), ) } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt index 249e393ad3..93359b59c5 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt @@ -1,3 +1,4 @@ +@file:Suppress("Deprecation") // Copyright (C) 2025 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 3e7016caab..6cc226db53 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.slack.circuit.backstack.rememberSaveableBackStack +import com.slack.circuit.backstack.rememberSaveableNavStack import com.slack.circuit.foundation.LocalCircuit import com.slack.circuit.foundation.NavEvent import com.slack.circuit.foundation.NavigableCircuitContent @@ -53,6 +53,7 @@ import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionLayout +import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult import com.slack.circuitx.navigation.intercepting.NavigationContext @@ -150,50 +151,50 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> @Composable fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElementTransitionLayout { val eventSink = rememberUpdatedState(state.eventSink) - val backStack = rememberSaveableBackStack(state.rootScreen) + val navStack = rememberSaveableNavStack(state.rootScreen) val contentInterceptor = remember { ContentInterceptor(eventSink) } val contentNavigator = - rememberCircuitNavigator(backStack, onRootPop = {}, enableBackHandler = true) + rememberCircuitNavigator(navStack, onRootPop = {}, enableBackHandler = true) val interceptingNavigator = rememberInterceptingNavigator( navigator = contentNavigator, interceptors = listOf(contentInterceptor), ) - val slideOverNavigator = rememberSlideOverNavState({ it is DetailScreen }, interceptingNavigator) Scaffold( modifier = modifier.testTag(ContentTags.TAG_SCAFFOLD).fillMaxSize(), bottomBar = { BottomTabRow( tabs = state.tabs, - rootScreen = backStack.rootRecord?.screen, - onNavEvent = { slideOverNavigator.onNavEvent(it) }, + rootScreen = navStack.rootRecord?.screen, + onNavEvent = { interceptingNavigator.onNavEvent(it) }, ) }, ) { innerPadding -> val circuit = requireNotNull(LocalCircuit.current) { "Need a circuit in a circuit" } NavigableCircuitContent( - navigator = slideOverNavigator, - backStack = backStack, + navigator = interceptingNavigator, + navStack = navStack, modifier = Modifier.padding(innerPadding).fillMaxSize(), - decoration = - remember(circuit.animatedScreenTransforms, circuit.animatedNavDecoratorFactory) { - SlideOverNavDecoration( - screenTransforms = circuit.animatedScreenTransforms, - decoratorFactory = circuit.animatedNavDecoratorFactory, - slideOverNavState = slideOverNavigator, - showInDetailPane = { it.screen is DetailScreen }, - backgroundColor = { MaterialTheme.colorScheme.background }, - scrimColor = { MaterialTheme.colorScheme.scrim }, - ) + decoratorFactory = GestureNavigationDecorationFactory(), + // decoration = + // remember(circuit.animatedScreenTransforms, circuit.animatedNavDecoratorFactory) { + // SlideOverNavDecoration( + // screenTransforms = circuit.animatedScreenTransforms, + // decoratorFactory = circuit.animatedNavDecoratorFactory, + // slideOverNavState = slideOverNavigator, + // showInDetailPane = { it.screen is DetailScreen }, + // backgroundColor = { MaterialTheme.colorScheme.background }, + // scrimColor = { MaterialTheme.colorScheme.scrim }, + // ) - // AdaptiveListDetailNavDecoration( - // screenTransforms = circuit.animatedScreenTransforms, - // normalDecoratorFactory = circuit.animatedNavDecoratorFactory, - // detailPaneDecoratorFactory = circuit.animatedNavDecoratorFactory, - // showInDetailPane = { it.screen is DetailScreen }, - // ) - }, + // AdaptiveListDetailNavDecoration( + // screenTransforms = circuit.animatedScreenTransforms, + // normalDecoratorFactory = circuit.animatedNavDecoratorFactory, + // detailPaneDecoratorFactory = circuit.animatedNavDecoratorFactory, + // showInDetailPane = { it.screen is DetailScreen }, + // ) + // }, ) } } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index 2c3815b657..622a538980 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -112,7 +112,7 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat @Composable fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = Modifier) = SharedElementTransitionScope { - val backStack = LocalNavStack.current?.toList().orEmpty() + val navStack = LocalNavStack.current?.snapshot() Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), @@ -144,12 +144,14 @@ fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = state.eventSink(TabScreenCircuit.Event.Next) } ) { - itemsIndexed(backStack) { i, item -> - Text( - text = "$i: ${item.screen}", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - ) + navStack?.let { navStack -> + itemsIndexed(navStack.toList()) { i, item -> + Text( + text = "$i: ${item.screen} ${if(navStack.currentIndex == i) "(active)" else ""}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) + } } } } From 27b2112aec9bffefaaa35b62a5ea647286d866e8 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Mon, 17 Nov 2025 15:47:04 -0800 Subject: [PATCH 38/44] Fix some things, break some more --- .../com/slack/circuit/backstack/BackStack.kt | 45 ----- .../com/slack/circuit/backstack/NavStack.kt | 135 +++----------- .../circuit/backstack/SaveableBackStack.kt | 173 ++++-------------- .../circuit/backstack/SaveableNavStack.kt | 78 ++++---- .../circuit/backstack/SaveableNavStackTest.kt | 118 ++++++------ .../circuit/foundation/AnsweringNavigator.kt | 2 +- .../circuit/foundation/CircuitContent.kt | 9 +- .../slack/circuit/foundation/NavDecoration.kt | 68 +------ .../foundation/NavigableCircuitContent.kt | 3 +- .../slack/circuit/foundation/NavigatorImpl.kt | 27 ++- .../animation/AnimatedNavDecoration.kt | 43 ++++- .../animation/AnimatedNavDecorator.kt | 4 - .../foundation/animation/AnimatedNavEvent.kt | 3 +- .../internal/PredictiveBackHandler.kt | 3 +- .../com/slack/circuit/runtime/Navigator.kt | 35 ++++ ...droidPredictiveBackNavigationDecoration.kt | 42 +++-- .../AndroidGestureNavigationStateTest.kt | 4 +- .../gesturenavigation/BackNavigationTest.kt | 2 +- .../GestureNavigationCrashTest.kt | 2 +- ...or.kt => PredictiveNavigationDecorator.kt} | 76 ++++---- .../iOSPredictiveBackNavigationDecoration.kt | 16 +- .../gesturenavigation/BackNavigationTest.kt | 2 +- .../IOSGestureNavigationStateTest.kt | 4 +- .../sample/navigation/ContentScreen.kt | 13 -- .../circuit/sample/navigation/TabScreen.kt | 52 +++++- 25 files changed, 396 insertions(+), 563 deletions(-) rename circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/{PredictiveBackNavigationDecorator.kt => PredictiveNavigationDecorator.kt} (61%) diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt index 3c5d7795dd..ed6c0c44a2 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt @@ -18,7 +18,6 @@ package com.slack.circuit.backstack import androidx.compose.runtime.Stable import androidx.compose.runtime.snapshots.Snapshot import com.slack.circuit.backstack.NavStack.Record -import com.slack.circuit.runtime.screen.Screen /** * A caller-supplied stack of [Record]s for presentation with a `Navigator`. Iteration order is @@ -29,50 +28,6 @@ import com.slack.circuit.runtime.screen.Screen */ @Stable public interface BackStack : NavStack, Iterable { - /** The number of records contained in this [BackStack] that will be seen by an iterator. */ - public override val size: Int - - /** The top-most record in the [BackStack], or `null` if the [BackStack] is empty. */ - public override val currentRecord: R? - get() = topRecord - - /** The bottom-most record in the [BackStack], or `null` if the [BackStack] is empty. */ - public override val rootRecord: R? - - /** - * Push a new [Record] onto the back stack. The new record will become the top of the stack. - * - * @param record The record to push onto the stack. - * @return If the [record] was successfully pushed onto the back stack - */ - public fun push(record: R): Boolean - - /** - * Push a new [Screen] onto the back stack. This will be enveloped in a [Record] and the new - * record will become the top of the stack. - * - * @param screen The screen to push onto the stack. - * @return If the [screen] was successfully pushed onto the back stack - */ - public fun push(screen: Screen): Boolean - - /** - * Attempt to pop the top item off of the back stack, returning the popped [Record] if popping was - * successful or `null` if no entry was popped. - */ - public fun pop(): R? - - /** - * Pop records off the top of the backstack until one is found that matches the given predicate. - */ - public fun popUntil(predicate: (R) -> Boolean): List { - return buildList { - while (topRecord?.let(predicate) == false) { - val popped = pop() ?: break - add(popped) - } - } - } @Stable public interface Record : NavStack.Record } diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt index b28bec9270..502a642ca6 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt @@ -89,7 +89,7 @@ public interface NavStack { * @param screen The screen to add * @return true if the screen was added, false if it was a duplicate of the current top */ - public fun add(screen: Screen): Boolean + public fun push(screen: Screen): Boolean /** * Adds a record to the stack. @@ -100,7 +100,7 @@ public interface NavStack { * @param record The record to add * @return true if the record was added, false if it was a duplicate of the current top */ - public fun add(record: R): Boolean + public fun push(record: R): Boolean /** * Removes and returns the current record from the stack. @@ -114,19 +114,23 @@ public interface NavStack { * * @return The removed record, or null if the stack is empty */ - public fun remove(): R? + public fun pop(): R? /** - * Moves the current position in the stack without removing any records. - * - **Direction.Forward**: Moves toward [topRecord] (newer entries) - * - **Direction.Backward**: Moves toward [rootRecord] (older entries) - * - * Use [canGoForward] and [canGoBack] to check if movement is possible. - * - * @param direction The direction to move - * @return true if the move was successful, false if already at the boundary + * Pop records off the top of the backstack until one is found that matches the given predicate. */ - public fun move(direction: Direction): Boolean + public fun popUntil(predicate: (R) -> Boolean): List { + return buildList { + while (topRecord?.let(predicate) == false) { + val popped = pop() ?: break + add(popped) + } + } + } + + public fun forward(): Boolean + + public fun backward(): Boolean /** * Creates a snapshot of the current stack state, including all entries and the current position. @@ -151,7 +155,7 @@ public interface NavStack { /** * Restores the saved state with the given [screen], adding it on top of the existing entry list. - * If you wish to replace the current entry list, you should [remove] all of the existing entries + * If you wish to replace the current entry list, you should [pop] all of the existing entries * before calling this function. * * @param screen The root screen which was previously saved using [saveState]. @@ -196,21 +200,6 @@ public interface NavStack { */ public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean - /** - * Navigation direction within a stack. - * - **Forward**: Toward [topRecord] (newer/more recent entries) - * - **Backward**: Toward [rootRecord] (older/earlier entries) - * - * This mirrors browser history navigation where Forward goes to newer pages and Backward goes to - * older pages. - */ - public enum class Direction { - /** Navigate toward the top (newer entries) of the stack. */ - Forward, - /** Navigate toward the root (older entries) of the stack. */ - Backward, - } - @Stable public interface Record { /** @@ -244,75 +233,19 @@ public interface NavStack { * ^ ^ * newest oldest * ``` - * * - `currentIndex = 0`: At the top (newest entry), no forward history * - `currentIndex = lastIndex`: At the root (oldest entry), no backward history * - `0 < currentIndex < lastIndex`: In the middle, can move both directions * - * ## Navigation View - * - * The snapshot provides convenient methods to view the stack from a navigation perspective: - * - **[forwardStack]**: Entries between current and top (newer entries you can navigate to) - * - **[backwardStack]**: Entries from current to root (includes current, older entries) - * - * ``` - * Stack: [A, B, C, D, E] (currentIndex = 2, current = C) - * ^ ^ ^ - * top cur root - * - * forwardStack() = [B, A] // Can move forward to these - * backwardStack() = [C, D, E] // Current and entries behind - * ``` - * - * ## Immutability - * - * Snapshots are immutable and will not change even if the source [NavStack] is modified. This - * makes them safe to: - * - Pass across composition boundaries - * - Store for later comparison - * - Serialize for state restoration - * - Iterate over without synchronization - * - * ## Use Cases - * - * 1. **State Persistence**: Save via [saveState] and restore via [restoreState] - * 2. **Inspection**: Examine stack contents without modifying the live stack - * 3. **Iteration**: Use the [Iterable] interface to traverse all entries - * 4. **Testing**: Verify navigation state in unit tests - * 5. **UI Display**: Show forward/backward history in navigation UI - * - * ## Example - * - * ```kotlin - * val snapshot = navStack.snapshot() - * - * // Check position - * val isAtTop = snapshot.currentIndex == 0 - * val isAtRoot = snapshot.currentIndex == snapshot.entries.lastIndex - * - * // Access entries - * val current = snapshot[snapshot.currentIndex] - * val top = snapshot[0] - * - * // Iterate over all entries (top to root) - * snapshot.forEach { record -> - * println("Screen: ${record.screen}") - * } - * - * // View navigation stacks - * val canGoForward = snapshot.forwardStack().any() - * snapshot.forwardStack().forEach { record -> - * println("Can navigate forward to: ${record.screen}") - * } - * ``` - * * @see NavStack.snapshot * @see NavStack.saveState * @see NavStack.restoreState */ @Stable public interface Snapshot : Iterable { - /** All records in the stack, ordered from top (newest, index 0) to root (oldest, last index). */ + /** + * All records in the stack, ordered from top (newest, index 0) to root (oldest, last index). + */ public val entries: List /** @@ -329,32 +262,8 @@ public interface NavStack { /** Returns an iterator over all entries from top (index 0) to root (last index). */ override fun iterator(): Iterator = entries.iterator() - /** - * Returns entries between the current position and the top, in order from nearest to top. - * These are entries you can navigate forward to (toward newer entries). - * - * Empty when [currentIndex] is 0 (at top). - * - * Example: - * ``` - * entries = [A, B, C, D], currentIndex = 2 (at C) - * forwardStack() = [B, A] // Can move forward through B to reach A - * ``` - */ - public fun forwardStack(): Iterable = entries.subList(0, currentIndex).asReversed() - - /** - * Returns entries from the current position to the root, in order from current to root. - * This includes the current entry and all entries you can navigate backward to (toward older - * entries). - * - * Example: - * ``` - * entries = [A, B, C, D], currentIndex = 1 (at B) - * backwardStack() = [B, C, D] // Current B, can move back through C to D - * ``` - */ - public fun backwardStack(): Iterable = entries.subList(currentIndex, entries.size) + public val current: R + get() = entries[currentIndex] } } diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index 081ce733af..8a2e63bab3 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -16,16 +16,11 @@ package com.slack.circuit.backstack import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.listSaver -import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.runtime.snapshots.SnapshotStateList import com.slack.circuit.backstack.SaveableBackStack.Record import com.slack.circuit.runtime.screen.Screen -import kotlin.math.min import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -40,8 +35,10 @@ import kotlin.uuid.Uuid public fun rememberSaveableBackStack( root: Screen, init: SaveableBackStack.() -> Unit = {}, -): SaveableBackStack = - rememberSaveable(root, saver = SaveableBackStack.Saver) { SaveableBackStack(root).apply(init) } +): SaveableBackStack { + val navStack = rememberSaveableNavStack(root) + return remember(navStack) { SaveableBackStack(navStack).apply { init() } } +} /** * Creates and remembers a [SaveableBackStack] filled with the given [initialScreens]. @@ -51,13 +48,8 @@ public fun rememberSaveableBackStack( @Composable public fun rememberSaveableBackStack(initialScreens: List): SaveableBackStack { require(initialScreens.isNotEmpty()) { "Initial input screens cannot be empty!" } - return rememberSaveable(initialScreens, saver = SaveableBackStack.Saver) { - SaveableBackStack().apply { - for (screen in initialScreens) { - push(screen) - } - } - } + val navStack = rememberSaveableNavStack(initialScreens) + return remember(navStack) { SaveableBackStack(navStack) } } /** @@ -66,9 +58,8 @@ public fun rememberSaveableBackStack(initialScreens: List): SaveableBack */ public class SaveableBackStack internal constructor( - // Both visible for testing - internal val entryList: SnapshotStateList = mutableStateListOf(), - internal val stateStore: MutableMap> = mutableMapOf(), + // Visible for testing + internal val delegate: SaveableNavStack = SaveableNavStack() ) : BackStack { public constructor(root: Screen) : this(Record(root)) @@ -78,112 +69,76 @@ internal constructor( } override val size: Int - get() = entryList.size + get() = delegate.size - override fun iterator(): Iterator = entryList.iterator() + override fun iterator(): Iterator = + delegate.entryList.iterator().asSequence().map { Record(it) }.iterator() - public override val topRecord: Record? - get() = entryList.firstOrNull() + override val topRecord: Record? + get() = delegate.topRecord?.let { Record(it) } - override val rootRecord: Record? - get() = entryList.lastOrNull() + override val currentRecord: Record? + get() = delegate.currentRecord?.let { Record(it) } - override fun add(screen: Screen): Boolean = push(Record(screen)) - - override fun add(record: Record): Boolean = push(record) + override val rootRecord: Record? + get() = delegate.rootRecord?.let { Record(it) } - override fun remove(): Record? = pop() + override fun forward(): Boolean = false - override fun move(direction: NavStack.Direction): Boolean = false + override fun backward(): Boolean { + return delegate.pop() != null + } public override fun push(screen: Screen): Boolean = push(Record(screen)) public override fun push(record: Record): Boolean { - val topRecord = Snapshot.withoutReadObservation { topRecord } - // Guard pushing the exact same record value to the top, records.key is always unique so verify - // the parameters individually. - return if (topRecord?.screen != record.screen || topRecord.args != record.args) { - entryList.add(0, record) - true - } else false + return delegate.push(SaveableNavStack.Record(record.screen, record.args, record.key)) } override fun pop(): Record? { - return Snapshot.withoutReadObservation { entryList.removeFirstOrNull() } + return delegate.pop()?.let { Record(it) } } override fun snapshot(): NavStack.Snapshot { - // BackStack always has current at top (index 0) - return BackStackSnapshot(entryList.toList()) + return BackStackSnapshot(delegate.snapshot()) } override fun saveState() { - val rootScreen = entryList.last().screen - stateStore[rootScreen] = entryList.toList() + delegate.saveState() } override fun restoreState(screen: Screen): Boolean { - val stored = stateStore[screen] - if (!stored.isNullOrEmpty()) { - // Add the store state into the entry list - entryList.addAll(stored) - // Clear the stored state - stateStore.remove(screen) - return true - } - return false + return delegate.restoreState(screen) } override fun peekState(): List { - return stateStore.keys.toList() + return delegate.peekState() } override fun removeState(screen: Screen): Boolean { - return stateStore.remove(screen) != null + return delegate.removeState(screen) } override fun containsRecord(record: Record, includeSaved: Boolean): Boolean { - // If it's in the main entry list, return true - if (record in entryList) return true - - if (includeSaved && stateStore.isNotEmpty()) { - // If we're checking our saved lists too, iterate through them and check - for (stored in stateStore.values) { - if (record in stored) return true - } - } - return false + return delegate.containsRecord( + SaveableNavStack.Record(record.screen, record.args, record.key), + includeSaved, + ) } override fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean { - if (depth < 0) return false - // Check in the current entry list - for (i in 0 until min(depth, entryList.size)) { - if (entryList[i].key == key) return true - } - // If includeSaved, check saved backstack states too - if (includeSaved && stateStore.isNotEmpty()) { - val storedValues = stateStore.values - for ((i, stored) in storedValues.withIndex()) { - if (i >= depth) break - // stored can mutate, so safely get the record. - if (stored.getOrNull(i)?.key == key) return true - } - } - return false + return delegate.isRecordReachable(key, depth, includeSaved) } /** * A snapshot of a back stack state. Since BackStack always has current at the top, currentIndex * is always 0. */ - public data class BackStackSnapshot(override val entries: List) : + private data class BackStackSnapshot(val other: NavStack.Snapshot) : NavStack.Snapshot { - override val currentIndex: Int = 0 - - override fun forwardStack(): Iterable = emptyList() + override val entries: List = other.entries.map { Record(it) } - override fun backwardStack(): Iterable = entries + override val currentIndex: Int = other.currentIndex } public data class Record( @@ -191,58 +146,6 @@ internal constructor( val args: Map = emptyMap(), @OptIn(ExperimentalUuidApi::class) override val key: String = Uuid.random().toString(), ) : BackStack.Record { - - internal companion object { - val Saver: Saver = - mapSaver( - save = { value -> - buildMap { - put("screen", value.screen) - put("args", value.args) - put("key", value.key) - } - }, - restore = { map -> - @Suppress("UNCHECKED_CAST") - Record( - screen = map["screen"] as Screen, - args = map["args"] as Map, - key = map["key"] as String, - ) - }, - ) - } - } - - internal companion object { - @Suppress("UNCHECKED_CAST") - val Saver = - listSaver>( - save = { value -> - buildList { - with(Record.Saver) { - // First list is the entry list - add(value.entryList.mapNotNull { save(it) }) - // Now add any stacks from the state store - value.stateStore.values.forEach { records -> add(records.mapNotNull { save(it) }) } - } - } - }, - restore = { value -> - SaveableBackStack().also { backStack -> - value.forEachIndexed { index, list -> - if (index == 0) { - // The first list is the entry list - list.mapNotNullTo(backStack.entryList) { Record.Saver.restore(it as List) } - } else { - // Any list after that is from the state store - val records = list.mapNotNull { Record.Saver.restore(it as List) } - // The key is always the root screen (i.e. last item) - backStack.stateStore[records.last().screen] = records - } - } - } - }, - ) + internal constructor(other: SaveableNavStack.Record) : this(other.screen, other.args, other.key) } } diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt index f3922f2006..7b7ffd2dc9 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import com.slack.circuit.backstack.SaveableNavStack.Record import com.slack.circuit.runtime.screen.Screen import kotlin.collections.set -import kotlin.math.min import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -43,7 +42,7 @@ public fun rememberSaveableNavStack(initialScreens: List): SaveableNavSt return rememberSaveable(initialScreens, saver = SaveableNavStack.Saver) { SaveableNavStack().apply { for (screen in initialScreens) { - add(screen) + push(screen) } } } @@ -148,7 +147,7 @@ internal constructor( public constructor(root: Screen) : this(Record(root)) public constructor(root: Record) : this() { - add(root) + push(root) // Set current to the new top currentIndex = 0 } @@ -167,15 +166,15 @@ internal constructor( override val rootRecord: Record? get() = entryList.lastOrNull() - override fun add(screen: Screen): Boolean { - return add(Record(screen)) + override fun push(screen: Screen): Boolean { + return push(Record(screen)) } - override fun add(record: Record): Boolean { - val topRecord = Snapshot.withoutReadObservation { topRecord } + override fun push(record: Record): Boolean { + val currentRecord = Snapshot.withoutReadObservation { currentRecord } // Guard pushing the exact same record value to the top, records.key is always unique so verify // the parameters individually. - return if (topRecord?.screen != record.screen || topRecord.args != record.args) { + return if (currentRecord?.screen != record.screen || currentRecord.args != record.args) { // When adding a new record, truncate any entries above the current position (forward history) if (currentIndex > 0) { // Remove all entries before currentIndex @@ -189,7 +188,7 @@ internal constructor( } else false } - override fun remove(): Record? { + override fun pop(): Record? { if (currentIndex < 0 || entryList.isEmpty()) return null return Snapshot.withMutableSnapshot { // When removing the current record, truncate any entries above the current position (forward @@ -205,23 +204,20 @@ internal constructor( } } - override fun move(direction: NavStack.Direction): Boolean { - return when (direction) { - NavStack.Direction.Forward -> { - // Move forward (toward top, decrease index) - if (currentIndex > 0) { - currentIndex-- - true - } else false - } - NavStack.Direction.Backward -> { - // Move backward (toward root, increase index) - if (currentIndex < entryList.lastIndex) { - currentIndex++ - true - } else false - } - } + override fun forward(): Boolean { + // Move forward (toward top, decrease index) + return if (currentIndex > 0) { + currentIndex-- + true + } else false + } + + override fun backward(): Boolean { + // Move backward (toward root, increase index) + return if (currentIndex < entryList.lastIndex) { + currentIndex++ + true + } else false } override fun snapshot(): NavStack.Snapshot { @@ -273,18 +269,34 @@ internal constructor( override fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean { if (depth < 0) return false // Check in the current entry list - for (i in 0 until min(depth, entryList.size)) { - if (entryList[i].key == key) return true + if (isRecordReachable(key, depth, currentIndex, entryList)) { + return true } // If includeSaved, check saved stack states too if (includeSaved && stateStore.isNotEmpty()) { - val storedSnapshots = stateStore.values - for ((i, snapshot) in storedSnapshots.withIndex()) { - if (i >= depth) break - // snapshot.entries is immutable, so safely get the record. - if (snapshot.entries.getOrNull(i)?.key == key) return true + for (snapshot in stateStore.values) { + if (isRecordReachable(key, depth, snapshot.currentIndex, snapshot.entries)) { + return true + } } } + return true + } + + private fun isRecordReachable( + key: String, + depth: Int, + index: Int, + records: List, + ): Boolean { + val min = maxOf(0, index - depth) + for (i in min until index) { + if (records[i].key == key) return true + } + val max = minOf(index + depth, records.size) + for (i in index until max) { + if (records[i].key == key) return true + } return false } diff --git a/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/SaveableNavStackTest.kt b/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/SaveableNavStackTest.kt index c1f9f7e1f3..1b50b7b696 100644 --- a/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/SaveableNavStackTest.kt +++ b/backstack/src/commonTest/kotlin/com/slack/circuit/backstack/SaveableNavStackTest.kt @@ -28,10 +28,10 @@ class SaveableNavStackTest { } @Test - fun test_add_records() { + fun test_push_records() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - assertTrue(navStack.add(TestScreen.ScreenA)) - assertTrue(navStack.add(TestScreen.ScreenB)) + assertTrue(navStack.push(TestScreen.ScreenA)) + assertTrue(navStack.push(TestScreen.ScreenB)) assertEquals(3, navStack.size) assertEquals(TestScreen.ScreenB, navStack.topRecord?.screen) @@ -44,9 +44,9 @@ class SaveableNavStackTest { @Test fun test_move_backward_and_forward() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) // [C, B, A, RootAlpha] - current at C (index 0) assertEquals(TestScreen.ScreenC, navStack.currentRecord?.screen) @@ -95,11 +95,11 @@ class SaveableNavStackTest { } @Test - fun test_add_truncates_forward_history() { + fun test_push_truncates_forward_history() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) // [C, B, A, RootAlpha] - current at C assertEquals(4, navStack.size) @@ -109,7 +109,7 @@ class SaveableNavStackTest { assertEquals(TestScreen.ScreenB, navStack.currentRecord?.screen) // Add a new screen - should truncate C - navStack.add(TestScreen.RootBeta) + navStack.push(TestScreen.RootBeta) // [RootBeta, B, A, RootAlpha] - current at Beta assertEquals(4, navStack.size) @@ -126,11 +126,11 @@ class SaveableNavStackTest { } @Test - fun test_remove() { + fun test_pop() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) // Move back to B navStack.move(NavStack.Direction.Backward) @@ -138,7 +138,7 @@ class SaveableNavStackTest { assertEquals(TestScreen.ScreenA, navStack.currentRecord?.screen) // Remove (removes A, current stays at same index pointing to Root) - val removed = navStack.remove() + val removed = navStack.pop() assertEquals(TestScreen.ScreenA, removed?.screen) assertEquals(TestScreen.RootAlpha, navStack.currentRecord?.screen) assertEquals(1, navStack.size) @@ -147,8 +147,8 @@ class SaveableNavStackTest { @Test fun test_save_and_restore_state() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) // Move back to A navStack.move(NavStack.Direction.Backward) @@ -168,18 +168,18 @@ class SaveableNavStackTest { // Clear the nav stack while (navStack.size > 0) { - navStack.remove() + navStack.pop() } assertEquals(0, navStack.size) // Add a different root - navStack.add(TestScreen.RootBeta) - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.RootBeta) + navStack.push(TestScreen.ScreenC) assertEquals(TestScreen.ScreenC, navStack.currentRecord?.screen) // Clear again and restore the saved state while (navStack.size > 0) { - navStack.remove() + navStack.pop() } assertTrue(navStack.restoreState(TestScreen.RootAlpha)) @@ -194,8 +194,8 @@ class SaveableNavStackTest { @Test fun test_saveable_save_and_restore() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) // Move back to A navStack.move(NavStack.Direction.Backward) @@ -215,8 +215,8 @@ class SaveableNavStackTest { @Test fun test_saveable_save_and_restore_with_saved_state() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) // Move back and save state navStack.move(NavStack.Direction.Backward) @@ -224,10 +224,10 @@ class SaveableNavStackTest { // Add different screens while (navStack.size > 0) { - navStack.remove() + navStack.pop() } - navStack.add(TestScreen.RootBeta) - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.RootBeta) + navStack.push(TestScreen.ScreenC) val saved = save(navStack) assertNotNull(saved) @@ -250,17 +250,17 @@ class SaveableNavStackTest { @Test fun test_guard_pushing_same_top_record() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - assertTrue(navStack.add(TestScreen.ScreenA)) - assertTrue(navStack.add(TestScreen.ScreenB)) - assertFalse(navStack.add(TestScreen.ScreenB)) + assertTrue(navStack.push(TestScreen.ScreenA)) + assertTrue(navStack.push(TestScreen.ScreenB)) + assertFalse(navStack.push(TestScreen.ScreenB)) } @Test fun test_iteration() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) val screens = navStack.map { it.screen } assertEquals( @@ -273,15 +273,15 @@ class SaveableNavStackTest { fun test_contains_record() { val navStack = SaveableNavStack(TestScreen.RootAlpha) val recordA = SaveableNavStack.Record(TestScreen.ScreenA) - navStack.add(recordA) - navStack.add(TestScreen.ScreenB) + navStack.push(recordA) + navStack.push(TestScreen.ScreenB) assertTrue(navStack.containsRecord(recordA, includeSaved = false)) // Save state and clear navStack.saveState() while (navStack.size > 0) { - navStack.remove() + navStack.pop() } assertFalse(navStack.containsRecord(recordA, includeSaved = false)) @@ -292,9 +292,9 @@ class SaveableNavStackTest { fun test_is_record_reachable() { val navStack = SaveableNavStack(TestScreen.RootAlpha) val recordA = SaveableNavStack.Record(TestScreen.ScreenA) - navStack.add(recordA) - navStack.add(TestScreen.ScreenB) - navStack.add(TestScreen.ScreenC) + navStack.push(recordA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) // Stack is [C, B, A, Root] assertTrue(navStack.isRecordReachable(recordA.key, depth = 10, includeSaved = false)) @@ -304,9 +304,9 @@ class SaveableNavStackTest { @Test fun test_snapshot_captures_current_state() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) // Move back to B navStack.move(NavStack.Direction.Backward) @@ -327,8 +327,8 @@ class SaveableNavStackTest { @Test fun test_snapshot_is_immutable() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) // Create snapshot val snapshot = navStack.snapshot() @@ -336,7 +336,7 @@ class SaveableNavStackTest { val originalIndex = snapshot.currentIndex // Modify the nav stack - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.ScreenC) navStack.move(NavStack.Direction.Backward) // Verify snapshot hasn't changed @@ -353,7 +353,7 @@ class SaveableNavStackTest { val navStack = SaveableNavStack(TestScreen.RootAlpha) // Remove everything - navStack.remove() + navStack.pop() val snapshot = navStack.snapshot() assertTrue(snapshot.entries.isEmpty()) @@ -363,8 +363,8 @@ class SaveableNavStackTest { @Test fun test_snapshot_iteration() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) val snapshot = navStack.snapshot() @@ -379,9 +379,9 @@ class SaveableNavStackTest { @Test fun test_snapshot_reflects_navigation_state() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) - navStack.add(TestScreen.ScreenC) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenC) // At top val snapshot1 = navStack.snapshot() @@ -403,8 +403,8 @@ class SaveableNavStackTest { @Test fun test_saved_state_snapshot_is_iterable() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) - navStack.add(TestScreen.ScreenB) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) navStack.saveState() val snapshot = navStack.stateStore[TestScreen.RootAlpha] @@ -418,17 +418,17 @@ class SaveableNavStackTest { } @Test - fun test_remove_at_boundaries() { + fun test_pop_at_boundaries() { val navStack = SaveableNavStack(TestScreen.RootAlpha) - navStack.add(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenA) // At top, remove forward - val removed = navStack.remove() + val removed = navStack.pop() assertEquals(TestScreen.ScreenA, removed?.screen) assertEquals(TestScreen.RootAlpha, navStack.currentRecord?.screen) // At root after removal - val removed2 = navStack.remove() + val removed2 = navStack.pop() assertEquals(TestScreen.RootAlpha, removed2?.screen) assertEquals(0, navStack.size) } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt index bbac3415e4..b7102e8091 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/AnsweringNavigator.kt @@ -139,7 +139,7 @@ public fun rememberAnsweringNavigator( object : GoToNavigator { override fun goTo(screen: Screen): Boolean { val previousTopRecord = currentBackStack.topRecord - val success = currentBackStack.add(screen) + val success = currentBackStack.push(screen) if (success) { // Clear the cached pending result from the previous top record if (previousTopRecord != null) { diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt index 94edb134f2..d456ebd94d 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import com.slack.circuit.runtime.CircuitContext import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.InternalCircuitApi +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.Navigator.StateOptions import com.slack.circuit.runtime.presenter.Presenter @@ -50,11 +51,13 @@ public fun CircuitContent( } override fun forward(): Boolean { - TODO("Not yet implemented") + onNavEvent(NavEvent.Forward) + return true } override fun backward(): Boolean { - TODO("Not yet implemented") + onNavEvent(NavEvent.Backward) + return true } override fun resetRoot(newRoot: Screen, options: StateOptions): List { @@ -70,6 +73,8 @@ public fun CircuitContent( override fun peek(): Screen = screen override fun peekBackStack(): List = listOf(screen) + + override fun peekNavStack(): NavStackList = NavStackList(listOf(screen)) } } CircuitContent(screen, navigator, modifier, circuit, unavailableContent, key) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt index e75a06b7fd..757b407c63 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -24,56 +24,6 @@ public interface NavDecoration { * A lightweight view of a navigation stack state, used by [NavDecoration] to provide context about * the current navigation position and history. * - * ## Structure - * - * `NavStackList` uses a **List+Index model** to represent navigation state: - * - **entries**: A list of items ordered from newest (index 0) to oldest (last index) - * - **currentIndex**: The position of the currently active/visible entry - * - * ## Index Semantics - * - * The list is ordered from top (newest) to root (oldest): - * ``` - * entries: [TopScreen, MiddleScreen, RootScreen] - * indices: 0 1 2 - * ^ ^ - * newest oldest - * ``` - * - `currentIndex = 0`: At the top (newest entry), no forward history - * - `currentIndex = lastIndex`: At the root (oldest entry), no backward history - * - `0 < currentIndex < lastIndex`: In the middle, between newer and older entries - * - * ## Use in NavDecoration - * - * [NavDecoration] receives a `NavStackList` to apply presentation logic based on navigation state. - * For example, a decoration might: - * - Apply different transitions when navigating forward vs backward - * - Show breadcrumbs based on the backward stack - * - Display a forward navigation UI when history is available - * - * ## Example - * - * ```kotlin - * val stackList = NavStackList( - * entries = listOf(DetailsScreen, ListScreen, HomeScreen), - * currentIndex = 1 // Currently at ListScreen - * ) - * - * // Access current and boundary entries - * val current = stackList.current // ListScreen - * val top = stackList.top // DetailsScreen (newest) - * val root = stackList.root // HomeScreen (oldest) - * - * // Check navigation position - * val canGoForward = stackList.forwardStack().isNotEmpty() // true (can go to DetailsScreen) - * val canGoBack = stackList.backwardStack().size > 1 // true (can go to HomeScreen) - * - * // Iterate forward history - * stackList.forwardStack().forEach { screen -> - * // Process DetailsScreen - * } - * ``` - * * @param T The type of entries stored in the navigation stack, typically implementing [NavArgument] * @param entries The complete list of entries, ordered from top (newest, index 0) to root (oldest, * last index) @@ -87,21 +37,18 @@ public data class NavStackList(val entries: List, val currentIndex: Int = public val size: Int get() = entries.size - /** The currently active/visible entry at [currentIndex]. */ - public val current: T - get() = entries[currentIndex] - /** The top (newest) entry in the stack at index 0. Could be in the forward history. */ public val top: T get() = entries.first() + /** The currently active/visible entry at [currentIndex]. */ + public val current: T + get() = entries[currentIndex] + /** The root (oldest) entry in the stack at the last index. */ public val root: T get() = entries.last() - /** Retrieves the entry at the given [index]. */ - public operator fun get(index: Int): T = entries[index] - /** * Returns entries between the current position and the top, in order from nearest to top. These * are entries the user can navigate forward to (toward newer entries). @@ -124,11 +71,12 @@ public data class NavStackList(val entries: List, val currentIndex: Int = * Example: * ``` * entries = [A, B, C, D], currentIndex = 1 (at B) - * backwardStack() = [B, C, D] // Current B, can move back through C to D + * backwardStack() = [C, D] // Can move back through C to D * ``` */ - // todo Should this include the current entry? - public fun backwardStack(): List = entries.subList(currentIndex, entries.size) + public fun backwardStack(): List { + return entries.subList(minOf(currentIndex + 1, entries.lastIndex), entries.size) + } } /** Argument provided to [NavDecoration] that exposes the underlying [Screen]. */ diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 712d592e6c..a91ee12608 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -597,8 +597,9 @@ public object NavigatorDefaults { // the transitionSpec recomposing. // The states are available as `targetState` and `initialState`. return when (animatedNavEvent) { - AnimatedNavEvent.Forward -> forward + AnimatedNavEvent.Forward, AnimatedNavEvent.GoTo -> forward + AnimatedNavEvent.Backward, AnimatedNavEvent.Pop -> backward AnimatedNavEvent.RootReset -> fadeIn() togetherWith fadeOut() }.using( diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt index a534722c6b..fd0d871d8b 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt @@ -20,6 +20,7 @@ import com.slack.circuit.backstack.NavStack.Record import com.slack.circuit.backstack.isAtRoot import com.slack.circuit.backstack.isEmpty import com.slack.circuit.foundation.internal.mapToImmutableList +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.Navigator.StateOptions import com.slack.circuit.runtime.screen.PopResult @@ -103,15 +104,15 @@ public fun rememberCircuitNavigator( /** * Creates a new [Navigator]. * - * @param backStack The backing [BackStack] to navigate. + * @param navStack The backing [NavStack] to navigate. * @param onRootPop Invoked when the backstack is at root (size 1) and the user presses the back * button. * @see NavigableCircuitContent */ public fun Navigator( - backStack: NavStack, + navStack: NavStack, onRootPop: (result: PopResult?) -> Unit, -): Navigator = NavigatorImpl(backStack, onRootPop) +): Navigator = NavigatorImpl(navStack, onRootPop) internal class NavigatorImpl( private val navStack: NavStack, @@ -123,15 +124,15 @@ internal class NavigatorImpl( } override fun goTo(screen: Screen): Boolean { - return navStack.add(screen) + return navStack.push(screen) } override fun forward(): Boolean { - return navStack.move(NavStack.Direction.Forward) + return navStack.forward() } override fun backward(): Boolean { - return navStack.move(NavStack.Direction.Backward) + return navStack.backward() } override fun pop(result: PopResult?): Screen? { @@ -139,13 +140,19 @@ internal class NavigatorImpl( onRootPop(result) return null } - return navStack.remove()?.screen + return navStack.pop()?.screen } override fun peek(): Screen? = navStack.currentRecord?.screen override fun peekBackStack(): List = - navStack.snapshot().backwardStack().mapToImmutableList { it.screen } + navStack + .snapshot() + .run { entries.subList(currentIndex, entries.size) } + .mapToImmutableList { it.screen } + + override fun peekNavStack(): NavStackList = + navStack.snapshot().run { NavStackList(entries.mapToImmutableList { it.screen }, currentIndex) } override fun resetRoot(newRoot: Screen, options: StateOptions): List { // Run this in a mutable snapshot (bit like a transaction) @@ -155,7 +162,7 @@ internal class NavigatorImpl( // Pop everything off the back stack val popped = buildList { while (navStack.size > 0) { - val screen = navStack.remove()?.screen ?: break + val screen = navStack.pop()?.screen ?: break add(screen) } } @@ -163,7 +170,7 @@ internal class NavigatorImpl( // If we're not restoring state, or the restore didn't work, we need to push the new root // onto the stack if (!options.restore || !navStack.restoreState(newRoot)) { - navStack.add(newRoot) + navStack.push(newRoot) } // Clear the state if requested, do this last to allow restoring the state once. diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt index b6048be50b..8212447647 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt @@ -80,7 +80,7 @@ import kotlin.reflect.KClass * ```kotlin * NavigableCircuitContent( * navigator = navigator, - * backStack = backStack, + * navStack = navStack, * decoratorFactory = remember { CustomAnimatedNavDecoratorFactory() }, * ) * ``` @@ -127,22 +127,43 @@ public class AnimatedNavDecoration( private fun AnimatedNavDecorator.transitionSpec( animatedScreenTransforms: Map, AnimatedScreenTransform> ): AnimatedContentTransitionScope.() -> ContentTransform = spec@{ + val initialStack = initialState.navStack + val targetStack = targetState.navStack + + val previous = initialStack.current + val current = targetStack.current + + val initialBackStack = initialStack.backwardStack() + val initialForwardStack = initialStack.forwardStack() + + val targetBackStack = targetStack.backwardStack() + val targetForwardStack = targetStack.forwardStack() + val animatedNavEvent = when { - targetState.navStack.root != initialState.navStack.root -> { + // Root reset happened. + initialStack.root != targetStack.root -> { AnimatedNavEvent.RootReset } - targetState.navStack.current == initialState.navStack.current -> { - // Target screen has not changed, probably we should not show any animation even if the back - // stack is changed + // Target screen has not changed, don't show an animation. + previous == current -> { return@spec EnterTransition.None togetherWith ExitTransition.None } - initialState.navStack.backwardStack().contains(targetState.navStack.current) -> { + // Navigated backward with the screen moving to the forward stack. + current in initialBackStack && + previous !in initialForwardStack && + previous in targetForwardStack -> { + AnimatedNavEvent.Backward + } + // Popped the screen off the nav stack. + current in initialBackStack && previous !in targetForwardStack -> { AnimatedNavEvent.Pop } - initialState.navStack.forwardStack().contains(targetState.navStack.current) -> { - AnimatedNavEvent.GoTo // todo New Event? + // Navigated forward with the screen moving out of the forward stack. + current in initialForwardStack && current !in targetForwardStack -> { + AnimatedNavEvent.Forward } + // Fallback to a normal GoTo. else -> { AnimatedNavEvent.GoTo } @@ -160,9 +181,11 @@ private fun AnimatedContentTransitionScope.screenSpecificOverr ): PartialContentTransform { // Read any screen specific overrides val targetAnimatedScreenTransform = - animatedScreenTransforms[targetState.navStack.current.screen::class] ?: NoOpAnimatedScreenTransform + animatedScreenTransforms[targetState.navStack.current.screen::class] + ?: NoOpAnimatedScreenTransform val initialAnimatedScreenTransform = - animatedScreenTransforms[initialState.navStack.current.screen::class] ?: NoOpAnimatedScreenTransform + animatedScreenTransforms[initialState.navStack.current.screen::class] + ?: NoOpAnimatedScreenTransform return PartialContentTransform( enter = targetAnimatedScreenTransform.run { enterTransition(animatedNavEvent) }, diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt index 785781e067..2fdea94f4c 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt @@ -123,10 +123,6 @@ public interface AnimatedNavDecorator { /** A state created for the [Transition] in [AnimatedNavDecorator.Decoration]. */ @Stable public interface AnimatedNavState { - /** The depth of the back stack at the time this state was created. */ - public val backStackDepth: Int - get() = navStack.size - navStack.currentIndex - /** Snapshot of the back stack at the time this state was created. */ public val navStack: NavStackList } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt index 1358bca795..564f17cff8 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt @@ -12,6 +12,7 @@ package com.slack.circuit.foundation.animation public enum class AnimatedNavEvent { GoTo, Pop, - Forward, RootReset, + Backward, + Forward, } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt index 9f15bf2bfe..c2dd912301 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt @@ -38,6 +38,7 @@ public fun PredictiveNavEventHandler( } SideEffect { handler.isBackEnabled = isBackEnabled + handler.isForwardEnabled = isForwardEnabled handler.onProgress = onProgress handler.onCancelled = onCancelled handler.onCompleted = onCompleted @@ -101,7 +102,7 @@ private class PredictiveNavEventHandler( override fun onForwardStarted(event: NavigationEvent) { current?.cancel() - current = PredictiveNavProgress(scope) { event -> onEvent(PredictiveNavDirection.Back, event) } + current = PredictiveNavProgress(scope) { event -> onEvent(PredictiveNavDirection.Forward, event) } } override fun onForwardProgressed(event: NavigationEvent) { diff --git a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt index 7b8d94d85c..59ab0d34b5 100644 --- a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt +++ b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt @@ -25,6 +25,7 @@ public interface Navigator : GoToNavigator { public fun forward(): Boolean + // todo PopResult? Root behavior? public fun backward(): Boolean public fun pop(result: PopResult? = null): Screen? @@ -35,6 +36,8 @@ public interface Navigator : GoToNavigator { /** Returns the current back stack. */ public fun peekBackStack(): List + public fun peekNavStack(): NavStackList + /** * Clear the existing backstack of [screens][Screen] and navigate to [newRoot]. * @@ -125,10 +128,42 @@ public interface Navigator : GoToNavigator { override fun peekBackStack(): List = emptyList() + override fun peekNavStack(): NavStackList = EmptyNavStackList + override fun resetRoot(newRoot: Screen, options: StateOptions): List = emptyList() } } +/** + * A lightweight view of a navigation stack state, used by [Navigator] to provide context about the + * current navigation position and history. + * + * @param T The type of entries stored in the navigation stack + * @param entries The complete list of entries, ordered from top (newest, index 0) to root (oldest, + * last index) + * @param currentIndex The index of the currently active/visible entry in [entries]. Defaults to 0 + * (top). + */ +public data class NavStackList(val entries: List, val currentIndex: Int = 0) { + /** The number of entries in the stack. */ + public val size: Int + get() = entries.size + + /** The top (newest) entry in the stack at index 0. Could be in the forward history. */ + public val top: T + get() = entries.first() + + /** The currently active/visible entry at [currentIndex]. */ + public val current: T + get() = entries[currentIndex] + + /** The root (oldest) entry in the stack at the last index. */ + public val root: T + get() = entries.last() +} + +internal val EmptyNavStackList = NavStackList(emptyList()) + /** Parameter based alternate for [Navigator.resetRoot]. */ public fun Navigator.resetRoot( newRoot: Screen, diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index c91d9ea2af..97ac54eef2 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -17,6 +17,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.GraphicsLayerScope @@ -39,15 +40,15 @@ public actual fun GestureNavigationDecorationFactory( fallback: AnimatedNavDecorator.Factory ): AnimatedNavDecorator.Factory { return when { - Build.VERSION.SDK_INT >= 34 -> AndroidPredictiveBackNavDecorator.Factory + Build.VERSION.SDK_INT >= 34 -> AndroidPredictiveNavDecorator.Factory else -> fallback } } -internal class AndroidPredictiveBackNavDecorator( +internal class AndroidPredictiveNavDecorator( onBackInvoked: () -> Unit, onForwardInvoked: () -> Unit, -) : PredictiveBackNavigationDecorator(onBackInvoked, onForwardInvoked) { +) : PredictiveNavigationDecorator(onBackInvoked, onForwardInvoked) { // Track popped zIndex so screens are layered correctly private var zIndexDepth = 0f @@ -56,14 +57,24 @@ internal class AndroidPredictiveBackNavDecorator( override fun AnimatedContentTransitionScope.transitionSpec( animatedNavEvent: AnimatedNavEvent ): ContentTransform { + println("PredictiveBackNavigationDecorator: transitionSpec($animatedNavEvent)") return when (animatedNavEvent) { // adding to back stack + AnimatedNavEvent.Forward -> { + if (showForward) { + // Handle all the animation in draggable + EnterTransition.None togetherWith ExitTransition.None + } else { + NavigatorDefaults.forward + } + } AnimatedNavEvent.GoTo -> { NavigatorDefaults.forward } // come back from back stack + AnimatedNavEvent.Backward, AnimatedNavEvent.Pop -> { - if (showPrevious) { + if (showBackward) { // Handle all the animation in predictiveBackMotion EnterTransition.None togetherWith ExitTransition.None } else { @@ -71,14 +82,6 @@ internal class AndroidPredictiveBackNavDecorator( } .apply { targetContentZIndex = --zIndexDepth } } - AnimatedNavEvent.Forward -> { - if (showNext) { - // Handle all the animation in draggable - EnterTransition.None togetherWith ExitTransition.None - } else { - NavigatorDefaults.forward - } - } // Root reset. Crossfade AnimatedNavEvent.RootReset -> { zIndexDepth = 0f @@ -92,9 +95,11 @@ internal class AndroidPredictiveBackNavDecorator( targetState: GestureNavTransitionHolder, innerContent: @Composable (T) -> Unit, ) { + val current = targetState.navStack.current + SideEffect { println("PredictiveBackNavigationDecorator: Decoration($current)") } Box( Modifier.predictiveBackMotion( - enabled = { showPrevious }, + enabled = { showBackward }, isSeeking = { isSeeking }, shape = MaterialTheme.shapes.extraLarge, elevation = if (SharedElementTransitionScope.isTransitionActive) 0.dp else 6.dp, @@ -109,8 +114,15 @@ internal class AndroidPredictiveBackNavDecorator( object Factory : AnimatedNavDecorator.Factory { override fun create(navigator: Navigator): AnimatedNavDecorator { - return AndroidPredictiveBackNavDecorator( - onBackInvoked = { navigator.backward() }, + return AndroidPredictiveNavDecorator( + onBackInvoked = { + // todo Handle this in nav? + if (navigator.peekNavStack().currentIndex < 1) { + navigator.pop() + } else { + navigator.backward() + } + }, onForwardInvoked = { navigator.forward() }, ) } diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/AndroidGestureNavigationStateTest.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/AndroidGestureNavigationStateTest.kt index acc6163c5c..564961bf61 100644 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/AndroidGestureNavigationStateTest.kt +++ b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/AndroidGestureNavigationStateTest.kt @@ -38,8 +38,8 @@ class AndroidGestureNavigationStateTest( composeTestRule.activityRule.scenario.performGestureNavigationBackSwipe() } - private fun decoratorFactory(navigator: Navigator): AndroidPredictiveBackNavDecorator.Factory { - return AndroidPredictiveBackNavDecorator.Factory(onBackInvoked = navigator::pop) + private fun decoratorFactory(navigator: Navigator): AndroidPredictiveNavDecorator.Factory { + return AndroidPredictiveNavDecorator.Factory(onBackInvoked = navigator::pop) } @Test diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt index 8aa4f6c53a..3b41215f1f 100644 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt +++ b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt @@ -69,7 +69,7 @@ class BackNavigationTest(private val androidNavigator: Boolean) { navigator = navigator, backStack = backStack, decoratorFactory = - remember { AndroidPredictiveBackNavDecorator.Factory(onBackInvoked = navigator::pop) }, + remember { AndroidPredictiveNavDecorator.Factory(onBackInvoked = navigator::pop) }, ) } } diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationCrashTest.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationCrashTest.kt index 065f9056a9..6ae925cc9c 100644 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationCrashTest.kt +++ b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationCrashTest.kt @@ -60,7 +60,7 @@ class GestureNavigationCrashTest { navigator = navigator, backStack = backStack, decoratorFactory = - remember { AndroidPredictiveBackNavDecorator.Factory(onBackInvoked = navigator::pop) }, + remember { AndroidPredictiveNavDecorator.Factory(onBackInvoked = navigator::pop) }, ) } } diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt similarity index 61% rename from circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt rename to circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt index d27537dbed..1ef7da7e36 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.core.Transition import androidx.compose.animation.core.rememberTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -22,7 +23,7 @@ import com.slack.circuit.foundation.internal.PredictiveNavEventHandler import com.slack.circuit.runtime.InternalCircuitApi import kotlin.math.abs -public abstract class PredictiveBackNavigationDecorator( +public abstract class PredictiveNavigationDecorator( private val onBackInvoked: () -> Unit, private val onForwardInvoked: () -> Unit, ) : AnimatedNavDecorator> { @@ -31,8 +32,8 @@ public abstract class PredictiveBackNavigationDecorator( SeekableTransitionState> private set - protected var showPrevious: Boolean by mutableStateOf(false) - protected var showNext: Boolean by mutableStateOf(false) + protected var showBackward: Boolean by mutableStateOf(false) + protected var showForward: Boolean by mutableStateOf(false) protected var swipeProgress: Float by mutableFloatStateOf(0f) protected var isSeeking: Boolean by mutableStateOf(false) @@ -48,70 +49,75 @@ public abstract class PredictiveBackNavigationDecorator( @OptIn(InternalCircuitApi::class) @Composable override fun updateTransition(args: NavStackList): Transition> { - val current = remember(args) { targetState(args) } - val previous = + val currentState = remember(args) { targetState(args) } + val backwardState = remember(args) { - val backwardStack = args.backwardStack() - if (backwardStack.size > 1) { - targetState(NavStackList(backwardStack.subList(1, backwardStack.size))) + val previousIndex = args.currentIndex + 1 + if (previousIndex < args.size) { + targetState(args.copy(currentIndex = previousIndex)) } else null } - - val next = + val forwardState = remember(args) { - val forwardStack = args.forwardStack() - if (forwardStack.isNotEmpty()) { - targetState(NavStackList(forwardStack.subList(0, forwardStack.size))) + val nextIndex = args.currentIndex - 1 + if (nextIndex > -1) { + targetState(args.copy(currentIndex = nextIndex)) } else null } - seekableTransitionState = remember { SeekableTransitionState(current) } + seekableTransitionState = remember { SeekableTransitionState(currentState) } - LaunchedEffect(current) { + LaunchedEffect(currentState) { swipeProgress = 0f isSeeking = false - seekableTransitionState.animateTo(current) + seekableTransitionState.animateTo(currentState) // After the current state has changed (i.e. any transition has completed), // clear out any transient state - showPrevious = false - showNext = false + showBackward = false + showForward = false swipeOffset = Offset.Zero } // todo Based on the swipe direction - LaunchedEffect(previous, current, next) { - snapshotFlow { Triple(swipeProgress, showNext, showPrevious) } - .collect { (progress, showingNext, showingPrevious) -> - val showingPrevious = - previous != null && showingPrevious && !showingNext && progress != 0f - val showingNext = next != null && showingNext && !showingPrevious && progress != 0f + LaunchedEffect(backwardState, currentState, forwardState) { + snapshotFlow { Triple(swipeProgress, showForward, showBackward) } + .collect { (progress, showingForward, showingBackward) -> + val showingBackward = + backwardState != null && showingBackward && !showingForward && progress != 0f + val showingForward = + forwardState != null && showingForward && !showingBackward && progress != 0f when { - showingPrevious -> { + showingBackward -> { isSeeking = true - seekableTransitionState.seekTo(fraction = abs(progress), targetState = previous) + seekableTransitionState.seekTo(fraction = abs(progress), targetState = backwardState) } - showingNext -> { + showingForward -> { isSeeking = true - seekableTransitionState.seekTo(fraction = abs(progress), targetState = next) + seekableTransitionState.seekTo(fraction = abs(progress), targetState = forwardState) } } } } + SideEffect { + println( + "PredictiveBackNavigationDecorator: ${backwardState?.navStack?.entries?.joinToString { "${it.screen::class.simpleName}_${it.key}" }}[${backwardState?.navStack?.currentIndex}] -> ${currentState.navStack.entries.joinToString { "${it.screen::class.simpleName}_${it.key}" }}[${currentState.navStack.currentIndex}]" + ) + } PredictiveNavEventHandler( - isBackEnabled = previous != null, - isForwardEnabled = next != null, + isBackEnabled = backwardState != null, + isForwardEnabled = forwardState != null, onProgress = { direction, progress, offset -> when (direction) { PredictiveNavDirection.Back -> { - showNext = false - showPrevious = progress != 0f + showForward = false + showBackward = progress != 0f swipeProgress = progress swipeOffset = offset } PredictiveNavDirection.Forward -> { - showPrevious = false - showNext = progress != 0f + showBackward = false + showForward = progress != 0f swipeProgress = progress swipeOffset = offset } @@ -119,7 +125,7 @@ public abstract class PredictiveBackNavigationDecorator( }, onCancelled = { isSeeking = false - seekableTransitionState.animateTo(current) + seekableTransitionState.animateTo(currentState) }, onCompleted = { direction -> when (direction) { diff --git a/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt index a149ec8870..4ef9ccbadc 100644 --- a/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt @@ -42,7 +42,7 @@ import kotlinx.coroutines.launch private val End: (Int) -> Int = { it } /** - * A factory that creates an [IOSPredictiveBackNavDecorator] for iOS predictive back navigation. + * A factory that creates an [IOSPredictiveNavDecorator] for iOS predictive back navigation. * * @param fallback The [AnimatedNavDecorator.Factory] to use when predictive back is not supported. * @return An [AnimatedNavDecorator.Factory] that provides iOS predictive back navigation. @@ -50,11 +50,11 @@ private val End: (Int) -> Int = { it } public actual fun GestureNavigationDecorationFactory( fallback: AnimatedNavDecorator.Factory ): AnimatedNavDecorator.Factory { - return IOSPredictiveBackNavDecorator.Factory() + return IOSPredictiveNavDecorator.Factory() } /** - * iOS implementation of [PredictiveBackNavigationDecorator] that relies on + * iOS implementation of [PredictiveNavigationDecorator] that relies on * `androidx.compose.ui.backhandler.UIKitBackGestureDispatcher` to perform the predictive back * gesture. * @@ -62,10 +62,10 @@ public actual fun GestureNavigationDecorationFactory( * the content starts from. Defaults to 0.25f (25%). * @param onBackInvoked A callback to be invoked when a back gesture is performed. */ -internal class IOSPredictiveBackNavDecorator( +internal class IOSPredictiveNavDecorator( private val enterOffsetFraction: Float = 0.25f, onBackInvoked: () -> Unit, -) : PredictiveBackNavigationDecorator(onBackInvoked) { +) : PredictiveNavigationDecorator(onBackInvoked) { // Track popped zIndex so screens are layered correctly private var zIndexDepth = 0f @@ -83,7 +83,7 @@ internal class IOSPredictiveBackNavDecorator( AnimatedNavEvent.Pop -> { slideInHorizontally { width -> -(enterOffsetFraction * width).roundToInt() } .togetherWith( - if (showPrevious) ExitTransition.None else slideOutHorizontally(targetOffsetX = End) + if (showBackward) ExitTransition.None else slideOutHorizontally(targetOffsetX = End) ) .apply { targetContentZIndex = --zIndexDepth } } @@ -106,7 +106,7 @@ internal class IOSPredictiveBackNavDecorator( targetState = targetState, transition = transition, isSeeking = { isSeeking }, - showPrevious = { showPrevious }, + showPrevious = { showBackward }, swipeOffset = { swipeOffset }, ) ) { @@ -117,7 +117,7 @@ internal class IOSPredictiveBackNavDecorator( internal class Factory(private val enterOffsetFraction: Float = 0.25f) : AnimatedNavDecorator.Factory { override fun create(navigator: Navigator): AnimatedNavDecorator { - return IOSPredictiveBackNavDecorator( + return IOSPredictiveNavDecorator( enterOffsetFraction = enterOffsetFraction, onBackInvoked = { navigator.pop() }, ) diff --git a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt index b452777b89..41801a9bb7 100644 --- a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt +++ b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/BackNavigationTest.kt @@ -100,7 +100,7 @@ class BackNavigationTest { navigator = navigator, backStack = backStack, decoratorFactory = - remember { IOSPredictiveBackNavDecorator.Factory(onBackInvoked = navigator::pop) }, + remember { IOSPredictiveNavDecorator.Factory(onBackInvoked = navigator::pop) }, ) } } diff --git a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/IOSGestureNavigationStateTest.kt b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/IOSGestureNavigationStateTest.kt index e868c637c0..6a101cf431 100644 --- a/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/IOSGestureNavigationStateTest.kt +++ b/circuitx/gesture-navigation/src/iosTest/kotlin/com/slack/circuitx/gesturenavigation/IOSGestureNavigationStateTest.kt @@ -18,8 +18,8 @@ class IOSGestureNavigationStateTest : GestureNavigationStateTest { TODO("Revisit testing with swipeRight() after the upstream navigation event changes.") } - private fun decoratorFactory(navigator: Navigator): IOSPredictiveBackNavDecorator.Factory { - return IOSPredictiveBackNavDecorator.Factory(onBackInvoked = navigator::pop) + private fun decoratorFactory(navigator: Navigator): IOSPredictiveNavDecorator.Factory { + return IOSPredictiveNavDecorator.Factory(onBackInvoked = navigator::pop) } @Ignore diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 6cc226db53..6902671d72 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.slack.circuit.backstack.rememberSaveableNavStack -import com.slack.circuit.foundation.LocalCircuit import com.slack.circuit.foundation.NavEvent import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.onNavEvent @@ -53,7 +52,6 @@ import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionLayout -import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult import com.slack.circuitx.navigation.intercepting.NavigationContext @@ -171,23 +169,12 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen ) }, ) { innerPadding -> - val circuit = requireNotNull(LocalCircuit.current) { "Need a circuit in a circuit" } NavigableCircuitContent( navigator = interceptingNavigator, navStack = navStack, modifier = Modifier.padding(innerPadding).fillMaxSize(), - decoratorFactory = GestureNavigationDecorationFactory(), // decoration = // remember(circuit.animatedScreenTransforms, circuit.animatedNavDecoratorFactory) { - // SlideOverNavDecoration( - // screenTransforms = circuit.animatedScreenTransforms, - // decoratorFactory = circuit.animatedNavDecoratorFactory, - // slideOverNavState = slideOverNavigator, - // showInDetailPane = { it.screen is DetailScreen }, - // backgroundColor = { MaterialTheme.colorScheme.background }, - // scrimColor = { MaterialTheme.colorScheme.scrim }, - // ) - // AdaptiveListDetailNavDecoration( // screenTransforms = circuit.animatedScreenTransforms, // normalDecoratorFactory = circuit.animatedNavDecoratorFactory, diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index 622a538980..a236465abf 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -12,8 +12,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -68,12 +73,21 @@ sealed interface TabScreen : Screen { object TabScreenCircuit { - data class State(val label: String, val hasDetails: Boolean, val eventSink: (Event) -> Unit) : - CircuitUiState + data class State( + val label: String, + val canGoBack: Boolean, + val canGoForward: Boolean, + val hasDetails: Boolean, + val eventSink: (Event) -> Unit, + ) : CircuitUiState sealed interface Event : CircuitUiEvent { data object Next : Event + data object Forward : Event + + data object Backward : Event + data class Details(val screen: TabScreen) : Event } } @@ -82,12 +96,18 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat Presenter { @Composable override fun present(): TabScreenCircuit.State { + val navStack = navigator.peekNavStack() return TabScreenCircuit.State( label = screen.label, + canGoBack = + navStack.entries.subList(navStack.currentIndex + 1, navStack.entries.size).isNotEmpty(), + canGoForward = navStack.entries.subList(0, navStack.currentIndex).isNotEmpty(), hasDetails = screen !is TabScreen.Screen2, ) { event -> when (event) { - TabScreenCircuit.Event.Next -> navigator.goTo(screen.next()) + is TabScreenCircuit.Event.Backward -> navigator.backward() + is TabScreenCircuit.Event.Forward -> navigator.forward() + is TabScreenCircuit.Event.Next -> navigator.goTo(screen.next()) is TabScreenCircuit.Event.Details -> navigator.goTo(DetailScreen(event.screen)) } } @@ -115,14 +135,25 @@ fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = val navStack = LocalNavStack.current?.snapshot() Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { + IconButton( + enabled = state.canGoBack, + onClick = { state.eventSink(TabScreenCircuit.Event.Backward) }, + ) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + IconButton( + enabled = state.canGoForward, + onClick = { state.eventSink(TabScreenCircuit.Event.Forward) }, + ) { + Icon(Icons.AutoMirrored.Default.ArrowForward, contentDescription = "Forward") + } Text( text = state.label, style = MaterialTheme.typography.headlineMedium, - modifier = - Modifier.testTag(ContentTags.TAG_LABEL).weight(1f).padding(top = 24.dp, bottom = 8.dp), + modifier = Modifier.testTag(ContentTags.TAG_LABEL).weight(1f), ) if (state.hasDetails) { Button( @@ -130,9 +161,10 @@ fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = onClick = { state.eventSink(TabScreenCircuit.Event.Details(screen)) }, modifier = Modifier.sharedBounds( - sharedContentState = rememberSharedContentState(key = "${screen}-details"), - animatedVisibilityScope = requireAnimatedScope(Navigation), - ), + sharedContentState = rememberSharedContentState(key = "${screen}-details"), + animatedVisibilityScope = requireAnimatedScope(Navigation), + ) + .padding(horizontal = 16.dp), ) { Text(text = "View details") } From 434f8df015f837a14c42d7e71aefef6a189c5735 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Mon, 17 Nov 2025 19:29:21 -0800 Subject: [PATCH 39/44] notes --- .../slack/circuit/foundation/NavDecoration.kt | 7 +- .../foundation/NavigableCircuitContent.kt | 130 +++++++++--------- .../animation/AnimatedNavDecoration.kt | 7 +- ...droidPredictiveBackNavigationDecoration.kt | 23 +--- .../PredictiveNavigationDecorator.kt | 21 +-- .../circuit/sample/navigation/MainActivity.kt | 10 +- 6 files changed, 91 insertions(+), 107 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt index 757b407c63..35dbfd0e79 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -3,6 +3,7 @@ package com.slack.circuit.foundation import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import com.slack.circuit.runtime.Navigator @@ -32,6 +33,7 @@ public interface NavDecoration { * @see NavDecoration * @see NavArgument */ +@Immutable public data class NavStackList(val entries: List, val currentIndex: Int = 0) { /** The number of entries in the stack. */ public val size: Int @@ -64,9 +66,8 @@ public data class NavStackList(val entries: List, val currentIndex: Int = public fun forwardStack(): List = entries.subList(0, currentIndex).asReversed() /** - * Returns entries from the current position to the root, in order from current to root. This - * includes the current entry and all entries the user can navigate backward to (toward older - * entries). + * Returns entries between the current position and the root, in order from current to root. This + * includes all entries the user can navigate backward to (toward older entries). * * Example: * ``` diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index a91ee12608..7f5b4573a2 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -29,12 +29,12 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.currentComposer import androidx.compose.runtime.currentCompositeKeyHashCode import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue @@ -258,7 +258,6 @@ public fun NavigableCircuitContent( providedValuesForNavStack(navigator.navStack, circuit.backStackLocalProviders) navDecoration.DecoratedContent(activeContentProviders, navigator, modifier) { provider -> val record = provider.record - // Remember the `providedValues` lookup because this composition can live longer than // the record is present in the backstack, if the decoration is animated for example. val values = remember(record) { providedValues[record] }?.provideValues() @@ -369,51 +368,48 @@ public class RecordContentProvider( private fun buildCircuitContentProviders( navStack: NavStack ): NavStackList> { - val navStackSnapshot = navStack.snapshot() + val composer = currentComposer val previousContentProviders = remember { mutableMapOf>() } val activeRecordKeys = remember { mutableSetOf() } - val recordKeys by - remember { mutableStateOf(emptySet()) } - .apply { value = navStackSnapshot.mapTo(mutableSetOf()) { it.key } } - val latestBackStack by rememberUpdatedState(navStack) - DisposableEffect(recordKeys) { - // Delay cleanup until the next backstack change. - // - Any record in composition is considered active - // - Any record in the backstack can be shown by a decorator - // - Any reachable record can be shown on a root reset - val contentNotInBackStack = - previousContentProviders.keys.filterNot { - it in activeRecordKeys || - it in recordKeys || - // Depth of 2 to exclude records that are late at leaving the composition. - latestBackStack.isRecordReachable(key = it, depth = 2, includeSaved = true) - } - onDispose { - // Only remove the keys that are no longer in the backstack or composition. - contentNotInBackStack - .filterNot { - latestBackStack.isRecordReachable(key = it, depth = 1, includeSaved = true) || - it in activeRecordKeys + val snapshot = navStack.snapshot() + val navList = + snapshot + .map { record -> + // Query the previous content providers map, so that we use the same + // RecordContentProvider instances across calls. + previousContentProviders.getOrPut(record.key) { + RecordContentProvider( + record = record, + content = + createRecordContent( + onActive = { activeRecordKeys.add(record.key) }, + onDispose = { activeRecordKeys.remove(record.key) }, + ), + ) } - .forEach { previousContentProviders.remove(it) } - } - } - val entries = - navStackSnapshot.map { record -> - // Query the previous content providers map, so that we use the same - // RecordContentProvider instances across calls. - previousContentProviders.getOrPut(record.key) { - RecordContentProvider( - record = record, - content = - createRecordContent( - onActive = { activeRecordKeys.add(record.key) }, - onDispose = { activeRecordKeys.remove(record.key) }, - ), - ) } - } - return NavStackList(entries, navStackSnapshot.currentIndex) + .let { NavStackList(it, snapshot.currentIndex) } + + // Remove any previous content providers that are no longer in the back stack. + DisposableEffect(snapshot) { + val cancellationHandle = + composer.scheduleFrameEndCallback { + // Any of these can be shown by the decoration, so we don't want to remove them. + val recordKeys = snapshot.mapTo(mutableSetOf()) { it.key } + previousContentProviders.keys + .filterNot { + it in activeRecordKeys || + it in recordKeys || + navStack.isRecordReachable(key = it, depth = 3, includeSaved = true) + } + .forEach { + println("Removing previous content provider for $it") + previousContentProviders.remove(it) + } + } + onDispose { cancellationHandle.cancel() } + } + return navList } @ExperimentalCircuitApi @@ -450,30 +446,34 @@ public class ContentProviderState( @OptIn(ExperimentalCircuitApi::class) private fun createRecordContent(onActive: () -> Unit, onDispose: () -> Unit) = movableContentOf> { record, contentProviderState -> - with(contentProviderState) { - val lifecycle = remember { MutableRecordLifecycle() } - SideEffect { lifecycle.isActive = lastNavigator.navStack.topRecord == record } - saveableStateHolder.SaveableStateProvider(record.registryKey) { - // Provides a RetainedStateRegistry that is maintained independently for each record while - // the record exists in the back stack. - retainedStateHolder.RetainedStateProvider(record.registryKey) { - CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { - CircuitContent( - screen = record.screen, - navigator = lastNavigator, - circuit = lastCircuit, - unavailableContent = lastUnavailableRoute, - key = record.key, - ) - } + val lifecycle = remember { MutableRecordLifecycle() } + // todo Allow this to be customized by the decoration for multiple active records. + SideEffect { + lifecycle.isActive = contentProviderState.lastNavigator.navStack.currentRecord == record + } + contentProviderState.saveableStateHolder.SaveableStateProvider(record.registryKey) { + // Provides a RetainedStateRegistry that is maintained independently for each record while + // the record exists in the back stack. + contentProviderState.retainedStateHolder.RetainedStateProvider(record.registryKey) { + CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { + CircuitContent( + screen = record.screen, + navigator = contentProviderState.lastNavigator, + circuit = contentProviderState.lastCircuit, + unavailableContent = contentProviderState.lastUnavailableRoute, + key = record.key, + ) } } - // Remove saved states for records that are no longer in the back stack - DisposableEffect(record.registryKey) { - onDispose { + } + // Remove saved states for records that are no longer in the back stack + DisposableEffect(record.registryKey) { + onDispose { + with(contentProviderState) { if (!lastNavigator.navStack.containsRecord(record, includeSaved = true)) { retainedStateHolder.removeState(record.registryKey) saveableStateHolder.removeState(record.registryKey) + println("RecordContentProvider: removedState ${record.key}") } } } @@ -481,7 +481,11 @@ private fun createRecordContent(onActive: () -> Unit, onDispose: () // Track if the movableContent is still active to correctly reuse it and not create a new one. DisposableEffect(Unit) { onActive() - onDispose { onDispose() } + println("RecordContentProvider: onActive ${record.key}") + onDispose { + onDispose() + println("RecordContentProvider: onDispose ${record.key}") + } } } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt index 8212447647..3fd7b7ec59 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt @@ -111,11 +111,16 @@ public class AnimatedNavDecoration( } with(decorator) { val transition = updateTransition(args) + // TODO Animated content only really works for a single screen at a time. We need a way to + // support enter/exit transitions for multiple screens, across the two nav stacks. transition.AnimatedContent( modifier = modifier, transitionSpec = transitionSpec(animatedScreenTransforms), + contentKey = { it.navStack.current.key }, ) { targetState -> - ProvideAnimatedTransitionScope(Navigation, this) { Decoration(targetState) { content(it) } } + ProvideAnimatedTransitionScope(Navigation, this) { + Decoration(targetState) { arg: T -> content(arg) } + } } } } diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index 97ac54eef2..8b961025cd 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -17,7 +17,6 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.GraphicsLayerScope @@ -33,7 +32,6 @@ import com.slack.circuit.foundation.animation.AnimatedNavEvent import com.slack.circuit.foundation.animation.AnimatedNavState import com.slack.circuit.runtime.InternalCircuitApi import com.slack.circuit.runtime.Navigator -import com.slack.circuit.sharedelements.SharedElementTransitionScope import kotlin.math.absoluteValue public actual fun GestureNavigationDecorationFactory( @@ -57,10 +55,10 @@ internal class AndroidPredictiveNavDecorator( override fun AnimatedContentTransitionScope.transitionSpec( animatedNavEvent: AnimatedNavEvent ): ContentTransform { - println("PredictiveBackNavigationDecorator: transitionSpec($animatedNavEvent)") return when (animatedNavEvent) { - // adding to back stack - AnimatedNavEvent.Forward -> { + // Adding to back stack + AnimatedNavEvent.Forward, + AnimatedNavEvent.GoTo -> { if (showForward) { // Handle all the animation in draggable EnterTransition.None togetherWith ExitTransition.None @@ -68,9 +66,6 @@ internal class AndroidPredictiveNavDecorator( NavigatorDefaults.forward } } - AnimatedNavEvent.GoTo -> { - NavigatorDefaults.forward - } // come back from back stack AnimatedNavEvent.Backward, AnimatedNavEvent.Pop -> { @@ -95,14 +90,12 @@ internal class AndroidPredictiveNavDecorator( targetState: GestureNavTransitionHolder, innerContent: @Composable (T) -> Unit, ) { - val current = targetState.navStack.current - SideEffect { println("PredictiveBackNavigationDecorator: Decoration($current)") } Box( Modifier.predictiveBackMotion( enabled = { showBackward }, isSeeking = { isSeeking }, shape = MaterialTheme.shapes.extraLarge, - elevation = if (SharedElementTransitionScope.isTransitionActive) 0.dp else 6.dp, + elevation = 6.dp, // if (SharedElementTransitionScope.isTransitionActive) 0.dp else 6.dp, transition = transition, offset = { swipeOffset }, progress = { seekableTransitionState.fraction }, @@ -116,12 +109,8 @@ internal class AndroidPredictiveNavDecorator( override fun create(navigator: Navigator): AnimatedNavDecorator { return AndroidPredictiveNavDecorator( onBackInvoked = { - // todo Handle this in nav? - if (navigator.peekNavStack().currentIndex < 1) { - navigator.pop() - } else { - navigator.backward() - } + // todo Handle root? + navigator.backward() }, onForwardInvoked = { navigator.forward() }, ) diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt index 1ef7da7e36..0b58399bce 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.Transition import androidx.compose.animation.core.rememberTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -52,19 +51,18 @@ public abstract class PredictiveNavigationDecorator( val currentState = remember(args) { targetState(args) } val backwardState = remember(args) { - val previousIndex = args.currentIndex + 1 - if (previousIndex < args.size) { - targetState(args.copy(currentIndex = previousIndex)) + val index = args.currentIndex + 1 + if (index < args.size) { + targetState(args.copy(currentIndex = index)) } else null } val forwardState = remember(args) { - val nextIndex = args.currentIndex - 1 - if (nextIndex > -1) { - targetState(args.copy(currentIndex = nextIndex)) + val index = args.currentIndex - 1 + if (index > -1) { + targetState(args.copy(currentIndex = index)) } else null } - seekableTransitionState = remember { SeekableTransitionState(currentState) } LaunchedEffect(currentState) { @@ -98,11 +96,6 @@ public abstract class PredictiveNavigationDecorator( } } } - SideEffect { - println( - "PredictiveBackNavigationDecorator: ${backwardState?.navStack?.entries?.joinToString { "${it.screen::class.simpleName}_${it.key}" }}[${backwardState?.navStack?.currentIndex}] -> ${currentState.navStack.entries.joinToString { "${it.screen::class.simpleName}_${it.key}" }}[${currentState.navStack.currentIndex}]" - ) - } PredictiveNavEventHandler( isBackEnabled = backwardState != null, @@ -134,6 +127,6 @@ public abstract class PredictiveNavigationDecorator( } }, ) - return rememberTransition(seekableTransitionState, label = "PredictiveBackNavigationDecorator") + return rememberTransition(seekableTransitionState, label = "PredictiveNavigationDecorator") } } diff --git a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt index b3c3af3bd6..bda6e5a194 100644 --- a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt +++ b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt @@ -9,7 +9,6 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.core.net.toUri import androidx.core.view.WindowCompat @@ -19,7 +18,6 @@ import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator import com.slack.circuit.runtime.screen.Screen import com.slack.circuitx.android.IntentScreen -import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory import com.slack.circuitx.navigation.intercepting.AndroidScreenAwareNavigationInterceptor import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult import com.slack.circuitx.navigation.intercepting.LogcatLogger @@ -45,6 +43,7 @@ class MainActivity : AppCompatActivity() { val notifier = LoggingNavigatorFailureNotifier(LogcatLogger) val tabs = TabScreen.all + val circuit = buildCircuitForTabs(tabs) setContent { MaterialTheme { val navStack = rememberSaveableNavStack(ContentScreen(tabs)) @@ -57,13 +56,6 @@ class MainActivity : AppCompatActivity() { eventListeners = eventListeners, notifier = notifier, ) - val circuit = - remember(navigator) { - buildCircuitForTabs(tabs) - .newBuilder() - .setAnimatedNavDecoratorFactory(GestureNavigationDecorationFactory()) - .build() - } CircuitCompositionLocals(circuit) { NavigableCircuitContent( navigator = interceptingNavigator, From fe6a1db501917209e31d4a3f7f03d132605c7ae6 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 18 Nov 2025 14:20:30 -0800 Subject: [PATCH 40/44] Shared NavStackList --- backstack/build.gradle.kts | 2 +- .../RememberSaveableBackstackTest.kt | 10 +- .../com/slack/circuit/backstack/BackStack.kt | 18 --- .../com/slack/circuit/backstack/NavStack.kt | 58 +-------- .../backstack/NavStackRecordLocalProvider.kt | 2 +- .../circuit/backstack/SaveableBackStack.kt | 25 +++- .../circuit/backstack/SaveableNavStack.kt | 123 +++++------------- .../circuit/foundation/CircuitContent.kt | 3 +- .../slack/circuit/foundation/NavDecoration.kt | 61 +-------- .../foundation/NavigableCircuitContent.kt | 97 +++++++++----- .../slack/circuit/foundation/NavigatorImpl.kt | 16 +-- .../animation/AnimatedNavDecoration.kt | 10 +- .../animation/AnimatedNavDecorator.kt | 2 +- .../com/slack/circuit/runtime/NavStackList.kt | 83 ++++++++++++ .../com/slack/circuit/runtime/Navigator.kt | 34 +---- ...droidPredictiveBackNavigationDecoration.kt | 2 +- .../GestureNavTransitionHolder.kt | 2 +- .../PredictiveNavigationDecorator.kt | 21 ++- .../intercepting/InterceptingNavigator.kt | 7 +- .../circuit/sample/navigation/MainActivity.kt | 1 + .../AdaptiveListDetailNavDecoration.kt | 23 ++-- .../sample/navigation/ContentScreen.kt | 1 + .../circuit/sample/navigation/TabScreen.kt | 7 +- 23 files changed, 263 insertions(+), 345 deletions(-) create mode 100644 circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt diff --git a/backstack/build.gradle.kts b/backstack/build.gradle.kts index 25df77acfa..5871747863 100644 --- a/backstack/build.gradle.kts +++ b/backstack/build.gradle.kts @@ -61,7 +61,7 @@ kotlin { api(libs.compose.runtime) api(libs.compose.ui) api(libs.coroutines) - api(projects.circuitRuntimeScreen) + api(projects.circuitRuntime) implementation(libs.compose.runtime.saveable) } } diff --git a/backstack/src/commonJvmTest/kotlin/com/slack/circuit/backstack/RememberSaveableBackstackTest.kt b/backstack/src/commonJvmTest/kotlin/com/slack/circuit/backstack/RememberSaveableBackstackTest.kt index 6d1caa2b36..2dceb7c969 100644 --- a/backstack/src/commonJvmTest/kotlin/com/slack/circuit/backstack/RememberSaveableBackstackTest.kt +++ b/backstack/src/commonJvmTest/kotlin/com/slack/circuit/backstack/RememberSaveableBackstackTest.kt @@ -20,7 +20,7 @@ class RememberSaveableBackstackTest { fun backStackStartsWithRootScreen() = runTest { moleculeFlow(RecompositionMode.Immediate) { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) - backStack.toList() + backStack.peekNavStack() } .test { assertEquals(awaitItem().first().screen, TestScreen.ScreenA) } } @@ -52,8 +52,8 @@ class RememberSaveableBackstackTest { val secondStack = awaitItem() assertNotSame(firstStack, secondStack) - assertEquals(firstStack.toList().first().screen, TestScreen.ScreenA) - assertEquals(secondStack.toList().first().screen, TestScreen.ScreenB) + assertEquals(firstStack.peekNavStack().first().screen, TestScreen.ScreenA) + assertEquals(secondStack.peekNavStack().first().screen, TestScreen.ScreenB) } } @@ -67,8 +67,8 @@ class RememberSaveableBackstackTest { val secondStack = awaitItem() assertNotSame(firstStack, secondStack) - assertEquals(firstStack.toList().first().screen, TestScreen.ScreenA) - assertEquals(secondStack.toList().first().screen, TestScreen.ScreenB) + assertEquals(firstStack.peekNavStack().first().screen, TestScreen.ScreenA) + assertEquals(secondStack.peekNavStack().first().screen, TestScreen.ScreenB) } } } diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt index ed6c0c44a2..42e7a7fb0e 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt @@ -16,7 +16,6 @@ package com.slack.circuit.backstack import androidx.compose.runtime.Stable -import androidx.compose.runtime.snapshots.Snapshot import com.slack.circuit.backstack.NavStack.Record /** @@ -31,20 +30,3 @@ public interface BackStack : NavStack, Iterable { @Stable public interface Record : NavStack.Record } - -/** `true` if the [BackStack] contains no records. [Iterable.firstOrNull] will return `null`. */ -public val BackStack.isEmpty: Boolean - get() = size == 0 - -/** `true` if the [BackStack] contains exactly one record. */ -public val BackStack.isAtRoot: Boolean - get() = size == 1 - -/** Clear any saved state from the [BackStack]. */ -public fun BackStack.clearState() { - Snapshot.withMutableSnapshot { - for (screen in peekState()) { - removeState(screen) - } - } -} diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt index 502a642ca6..7b89775cbf 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt @@ -3,6 +3,7 @@ package com.slack.circuit.backstack import androidx.compose.runtime.Stable import androidx.compose.runtime.snapshots.Snapshot import com.slack.circuit.backstack.NavStack.Record +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.screen.Screen /** @@ -133,14 +134,14 @@ public interface NavStack { public fun backward(): Boolean /** - * Creates a snapshot of the current stack state, including all entries and the current position. + * Creates a snapshot of the current stack state. * * The returned snapshot is immutable and can be used to capture the current navigation state for * inspection, serialization, or other purposes. * - * @return A [Snapshot] containing the current entries and position + * @return A [NavStackList] representing the current stack state, null if the stack is empty. */ - public fun snapshot(): Snapshot + public fun snapshot(): NavStackList? /** * Saves the current stack entry list and position in an internal state store. It can be later @@ -214,57 +215,6 @@ public interface NavStack { /** The [Screen] that should present this record. */ public val screen: Screen } - - /** - * An immutable snapshot of a navigation stack's state at a point in time. - * - * ## Structure - * - * A snapshot captures the complete state using a **List+Index model**: - * - **entries**: An immutable list of all records in the stack - * - **currentIndex**: The position of the currently active record - * - * ## Index Semantics - * - * The list is ordered from newest (index 0) to oldest (last index): - * ``` - * entries: [TopScreen, MiddleScreen, RootScreen] - * indices: 0 1 2 - * ^ ^ - * newest oldest - * ``` - * - `currentIndex = 0`: At the top (newest entry), no forward history - * - `currentIndex = lastIndex`: At the root (oldest entry), no backward history - * - `0 < currentIndex < lastIndex`: In the middle, can move both directions - * - * @see NavStack.snapshot - * @see NavStack.saveState - * @see NavStack.restoreState - */ - @Stable - public interface Snapshot : Iterable { - /** - * All records in the stack, ordered from top (newest, index 0) to root (oldest, last index). - */ - public val entries: List - - /** - * The index of the currently active record in [entries]. - * - `0`: At the top (newest entry) - * - `entries.lastIndex`: At the root (oldest entry) - * - Between: In the middle of the history - */ - public val currentIndex: Int - - /** Retrieves the record at the given [index] in [entries]. */ - public operator fun get(index: Int): R = entries[index] - - /** Returns an iterator over all entries from top (index 0) to root (last index). */ - override fun iterator(): Iterator = entries.iterator() - - public val current: R - get() = entries[currentIndex] - } } public val NavStack.currentScreen: Screen? diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStackRecordLocalProvider.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStackRecordLocalProvider.kt index d34696ad9c..9ee37e279a 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStackRecordLocalProvider.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStackRecordLocalProvider.kt @@ -43,7 +43,7 @@ public fun providedValuesForNavStack( backStackLocalProviders: List> = emptyList(), ): Map = buildMap(navStack.size) { - navStack.snapshot().forEach { record -> + navStack.snapshot()?.forEach { record -> key(record) { put( record, diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index 8a2e63bab3..9be86aed46 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -18,8 +18,8 @@ package com.slack.circuit.backstack import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.snapshots.Snapshot import com.slack.circuit.backstack.SaveableBackStack.Record +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.screen.Screen import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -99,8 +99,8 @@ internal constructor( return delegate.pop()?.let { Record(it) } } - override fun snapshot(): NavStack.Snapshot { - return BackStackSnapshot(delegate.snapshot()) + override fun snapshot(): NavStackList? { + return delegate.snapshot()?.let { BackStackList(it) } } override fun saveState() { @@ -134,11 +134,22 @@ internal constructor( * A snapshot of a back stack state. Since BackStack always has current at the top, currentIndex * is always 0. */ - private data class BackStackSnapshot(val other: NavStack.Snapshot) : - NavStack.Snapshot { - override val entries: List = other.entries.map { Record(it) } + private data class BackStackList(val other: NavStackList) : + NavStackList { - override val currentIndex: Int = other.currentIndex + override val top: Record = Record(other.top) + override val current: Record = Record(other.current) + override val root: Record = Record(other.root) + + override val forward: Iterable + get() = other.forward.map { Record(it) } + + override val backward: Iterable + get() = other.backward.map { Record(it) } + + override fun iterator(): Iterator { + return other.iterator().asSequence().map { Record(it) }.iterator() + } } public data class Record( diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt index 7b7ffd2dc9..112c4cc76d 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.snapshots.SnapshotStateList import com.slack.circuit.backstack.SaveableNavStack.Record +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.screen.Screen import kotlin.collections.set import kotlin.uuid.ExperimentalUuidApi @@ -54,89 +55,12 @@ public fun rememberSaveableNavStack(initialScreens: List): SaveableNavSt * * Unlike [SaveableBackStack], this implementation supports forward navigation, removal in both * directions, and moving through the stack without removing entries. - * - * ## Internal Structure - * - * The stack maintains an [entryList] of records and a [currentIndex] to track position: - * ``` - * entryList indices: [0, 1, 2, 3, 4, 5] - * ^ ^ ^ - * topRecord currentIndex rootRecord - * (newest) (current) (oldest) - * ``` - * - * ## Navigation Examples - * - * ### Moving Forward (toward top/newer entries): - * ``` - * Before: [A, B, C, D, E, F] - * ^ - * currentIndex = 3 - * - * move(Forward) - * - * After: [A, B, C, D, E, F] - * ^ - * currentIndex = 2 - * ``` - * - * ### Moving Backward (toward root/older entries): - * ``` - * Before: [A, B, C, D, E, F] - * ^ - * currentIndex = 2 - * - * move(Backward) - * - * After: [A, B, C, D, E, F] - * ^ - * currentIndex = 3 - * ``` - * - * ### Adding New Entry (truncates forward history): - * ``` - * Before: [A, B, C, D, E, F] - * ^ - * currentIndex = 2 (pointing to C) - * - * add(G) - * - * After: [G, C, D, E, F] - * ^ - * currentIndex = 0 (pointing to G) - * (A & B removed, G becomes new top and current) - * ``` - * - * ### Removing with Direction: - * ``` - * # Forward removal (removes current, moves toward top) - * Before: [A, B, C, D, E, F] - * ^ - * currentIndex = 3 - * - * remove(Forward) - * - * After: [A, B, C, E, F] - * ^ - * currentIndex = 2 - * - * # Backward removal (removes current, stays at same logical position) - * Before: [A, B, C, D, E, F] - * ^ - * currentIndex = 3 - * - * remove(Backward) - * - * After: [A, B, C, E, F] - * ^ - * currentIndex = 3 (now points to E) - * ``` */ public class SaveableNavStack internal constructor( // Both visible for testing internal val entryList: SnapshotStateList = mutableStateListOf(), - internal val stateStore: MutableMap> = mutableMapOf(), + internal val stateStore: MutableMap = mutableMapOf(), initialIndex: Int = -1, ) : NavStack, Iterable { @@ -220,14 +144,16 @@ internal constructor( } else false } - override fun snapshot(): NavStack.Snapshot { - return SaveableSnapshot(entryList.toList(), currentIndex) + override fun snapshot(): NavStackList? { + return if (entryList.isNotEmpty()) { + SaveableNavStackList(entryList.toList(), currentIndex) + } else null } override fun saveState() { if (entryList.isNotEmpty()) { val rootScreen = entryList.last().screen - stateStore[rootScreen] = SaveableSnapshot(entryList.toList(), currentIndex) + stateStore[rootScreen] = SaveableNavStackList(entryList.toList(), currentIndex) } } @@ -328,13 +254,30 @@ internal constructor( } } - public data class SaveableSnapshot( - override val entries: List, - override val currentIndex: Int, - ) : NavStack.Snapshot { + internal data class SaveableNavStackList(val entries: List, val currentIndex: Int) : + NavStackList { - internal companion object { - val Saver: Saver, Any> = + override val top: Record + get() = entries.first() + + override val current: Record + get() = entries[currentIndex] + + override val root: Record + get() = entries.last() + + override val forward: Iterable + get() = entries.subList(0, currentIndex).asReversed() + + override val backward: Iterable + get() = entries.subList(currentIndex + 1, entries.size) + + override fun iterator(): Iterator { + return entries.iterator() + } + + companion object { + val Saver: Saver = mapSaver( save = { value -> buildMap { @@ -344,7 +287,7 @@ internal constructor( }, restore = { map -> @Suppress("UNCHECKED_CAST") - SaveableSnapshot( + SaveableNavStackList( entries = (map["entries"] as List>).mapNotNull { Record.Saver.restore(it) }, currentIndex = map["currentIndex"] as Int, ) @@ -364,7 +307,7 @@ internal constructor( // Save the entry list with(Record.Saver) { add(value.entryList.mapNotNull { save(it) }) } // Now add any snapshots from the state store - with(SaveableSnapshot.Saver) { add(value.stateStore.values.map { save(it) }) } + with(SaveableNavStackList.Saver) { add(value.stateStore.values.map { save(it) }) } } }, restore = { value -> @@ -383,7 +326,7 @@ internal constructor( else -> { // Any list after that is from the state store (as snapshots) item - .mapNotNull { SaveableSnapshot.Saver.restore(it as List) } + .mapNotNull { SaveableNavStackList.Saver.restore(it as List) } .forEach { snapshot -> // The key is always the root screen (i.e. last item) navStack.stateStore[snapshot.entries.last().screen] = snapshot diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt index d456ebd94d..0a09f37a24 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt @@ -15,6 +15,7 @@ import com.slack.circuit.runtime.InternalCircuitApi import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.Navigator.StateOptions +import com.slack.circuit.runtime.navStackListOf import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen @@ -74,7 +75,7 @@ public fun CircuitContent( override fun peekBackStack(): List = listOf(screen) - override fun peekNavStack(): NavStackList = NavStackList(listOf(screen)) + override fun peekNavStack(): NavStackList = navStackListOf(screen) } } CircuitContent(screen, navigator, modifier, circuit, unavailableContent, key) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt index 35dbfd0e79..8318c787e5 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -3,9 +3,9 @@ package com.slack.circuit.foundation import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen @@ -21,65 +21,6 @@ public interface NavDecoration { ) } -/** - * A lightweight view of a navigation stack state, used by [NavDecoration] to provide context about - * the current navigation position and history. - * - * @param T The type of entries stored in the navigation stack, typically implementing [NavArgument] - * @param entries The complete list of entries, ordered from top (newest, index 0) to root (oldest, - * last index) - * @param currentIndex The index of the currently active/visible entry in [entries]. Defaults to 0 - * (top). - * @see NavDecoration - * @see NavArgument - */ -@Immutable -public data class NavStackList(val entries: List, val currentIndex: Int = 0) { - /** The number of entries in the stack. */ - public val size: Int - get() = entries.size - - /** The top (newest) entry in the stack at index 0. Could be in the forward history. */ - public val top: T - get() = entries.first() - - /** The currently active/visible entry at [currentIndex]. */ - public val current: T - get() = entries[currentIndex] - - /** The root (oldest) entry in the stack at the last index. */ - public val root: T - get() = entries.last() - - /** - * Returns entries between the current position and the top, in order from nearest to top. These - * are entries the user can navigate forward to (toward newer entries). - * - * Empty when [currentIndex] is 0 (at top). - * - * Example: - * ``` - * entries = [A, B, C, D], currentIndex = 2 (at C) - * forwardStack() = [B, A] // Can move forward through B to reach A - * ``` - */ - public fun forwardStack(): List = entries.subList(0, currentIndex).asReversed() - - /** - * Returns entries between the current position and the root, in order from current to root. This - * includes all entries the user can navigate backward to (toward older entries). - * - * Example: - * ``` - * entries = [A, B, C, D], currentIndex = 1 (at B) - * backwardStack() = [C, D] // Can move back through C to D - * ``` - */ - public fun backwardStack(): List { - return entries.subList(minOf(currentIndex + 1, entries.lastIndex), entries.size) - } -} - /** Argument provided to [NavDecoration] that exposes the underlying [Screen]. */ public interface NavArgument { public val screen: Screen diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 7f5b4573a2..2680df71ab 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.toString import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.slack.circuit.backstack.BackStack import com.slack.circuit.backstack.NavStack import com.slack.circuit.backstack.NavStack.Record import com.slack.circuit.backstack.ProvidedValues @@ -60,9 +61,11 @@ import com.slack.circuit.retained.rememberRetainedStateHolder import com.slack.circuit.retained.rememberRetainedStateRegistry import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.InternalCircuitApi +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.runtime.transform import kotlin.collections.mutableSetOf /** @@ -144,6 +147,30 @@ public fun NavigableCircuitContent( ) } +@OptIn(ExperimentalCircuitApi::class) +@Composable +public fun NavigableCircuitContent( + navigator: Navigator, + backStack: BackStack, + modifier: Modifier = Modifier, + circuit: Circuit = requireNotNull(LocalCircuit.current), + providedValues: Map = emptyMap(), + decoration: NavDecoration = circuit.defaultNavDecoration, + decoratorFactory: AnimatedNavDecorator.Factory? = null, + unavailableRoute: (@Composable (screen: Screen, modifier: Modifier) -> Unit) = + circuit.onUnavailableContent, +) { + NavigableCircuitContent( + navigator = rememberAnsweringResultNavigator(navigator, backStack), + modifier = modifier, + circuit = circuit, + providedValues = providedValues, + decoration = decoration, + decoratorFactory = decoratorFactory, + unavailableRoute = unavailableRoute, + ) +} + /** * An experimental variant of [NavigableCircuitContent] that takes an [AnsweringResultNavigator] * directly, which combines navigation and result handling into a single object. @@ -256,25 +283,27 @@ public fun NavigableCircuitContent( val activeContentProviders = buildCircuitContentProviders(navStack = navigator.navStack) val circuitProvidedValues = providedValuesForNavStack(navigator.navStack, circuit.backStackLocalProviders) - navDecoration.DecoratedContent(activeContentProviders, navigator, modifier) { provider -> - val record = provider.record - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val circuitProvidedValues = - remember(record) { circuitProvidedValues[record] }?.provideValues() - val providedLocals = - remember(values, circuitProvidedValues) { - (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() + if (activeContentProviders != null) { + navDecoration.DecoratedContent(activeContentProviders, navigator, modifier) { provider -> + val record = provider.record + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val circuitProvidedValues = + remember(record) { circuitProvidedValues[record] }?.provideValues() + val providedLocals = + remember(values, circuitProvidedValues) { + (values.orEmpty() + circuitProvidedValues.orEmpty()).toTypedArray() + } + val localNavStack = contentProviderState.lastNavigator.navStack + val localResultHandler = contentProviderState.lastNavigator.answeringResultHandler + CompositionLocalProvider( + LocalNavStack provides localNavStack, + LocalAnsweringResultHandler provides localResultHandler, + *providedLocals, + ) { + provider.content(record, contentProviderState) } - val localNavStack = contentProviderState.lastNavigator.navStack - val localResultHandler = contentProviderState.lastNavigator.answeringResultHandler - CompositionLocalProvider( - LocalNavStack provides localNavStack, - LocalAnsweringResultHandler provides localResultHandler, - *providedLocals, - ) { - provider.content(record, contentProviderState) } } } @@ -367,35 +396,33 @@ public class RecordContentProvider( @Composable private fun buildCircuitContentProviders( navStack: NavStack -): NavStackList> { +): NavStackList>? { val composer = currentComposer val previousContentProviders = remember { mutableMapOf>() } val activeRecordKeys = remember { mutableSetOf() } val snapshot = navStack.snapshot() val navList = - snapshot - .map { record -> - // Query the previous content providers map, so that we use the same - // RecordContentProvider instances across calls. - previousContentProviders.getOrPut(record.key) { - RecordContentProvider( - record = record, - content = - createRecordContent( - onActive = { activeRecordKeys.add(record.key) }, - onDispose = { activeRecordKeys.remove(record.key) }, - ), - ) - } + snapshot?.transform { record -> + // Query the previous content providers map, so that we use the same + // RecordContentProvider instances across calls. + previousContentProviders.getOrPut(record.key) { + RecordContentProvider( + record = record, + content = + createRecordContent( + onActive = { activeRecordKeys.add(record.key) }, + onDispose = { activeRecordKeys.remove(record.key) }, + ), + ) } - .let { NavStackList(it, snapshot.currentIndex) } + } // Remove any previous content providers that are no longer in the back stack. DisposableEffect(snapshot) { val cancellationHandle = composer.scheduleFrameEndCallback { // Any of these can be shown by the decoration, so we don't want to remove them. - val recordKeys = snapshot.mapTo(mutableSetOf()) { it.key } + val recordKeys = snapshot?.mapTo(mutableSetOf()) { it.key } ?: emptySet() previousContentProviders.keys .filterNot { it in activeRecordKeys || diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt index fd0d871d8b..f4df9e20fc 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigatorImpl.kt @@ -25,14 +25,14 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.Navigator.StateOptions import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.runtime.transform /** * Creates and remembers a new [Navigator] for navigating within [CircuitContents][CircuitContent]. * A new [Navigator] will be created if the [navStack] instance changes. * * @param navStack The backing [NavStack] to navigate. - * @param onRootPop Invoked when the backstack is at root (size 1) and the user presses the back - * button. + * @param onRootPop Invoked when the backstack [NavStack.isAtRoot] and a [Navigator.pop] is called. * @see NavigableCircuitContent */ @Composable @@ -79,12 +79,12 @@ public fun rememberCircuitNavigator( var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } NavigationBackHandler( state = rememberNavigationEventState(NavigationEventInfo.None), - isBackEnabled = enableBackHandler && enableRootBackHandler && navStack.size > 1, + isBackEnabled = enableBackHandler && enableRootBackHandler && !navStack.isAtRoot, onBackCompleted = { // We need to unload this BackHandler from the composition before the root pop is triggered so // any outer back handler will get called. So delay calling pop until after the next // composition. - if (navStack.size > 1) { + if (!navStack.isAtRoot) { navigator.pop() } else { hasPendingRootPop = true @@ -146,13 +146,9 @@ internal class NavigatorImpl( override fun peek(): Screen? = navStack.currentRecord?.screen override fun peekBackStack(): List = - navStack - .snapshot() - .run { entries.subList(currentIndex, entries.size) } - .mapToImmutableList { it.screen } + navStack.snapshot()?.backward?.mapToImmutableList { it.screen } ?: emptyList() - override fun peekNavStack(): NavStackList = - navStack.snapshot().run { NavStackList(entries.mapToImmutableList { it.screen }, currentIndex) } + override fun peekNavStack(): NavStackList? = navStack.snapshot()?.transform { it.screen } override fun resetRoot(newRoot: Screen, options: StateOptions): List { // Run this in a mutable snapshot (bit like a transaction) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt index 3fd7b7ec59..ba48b07f80 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt @@ -16,8 +16,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavDecoration -import com.slack.circuit.foundation.NavStackList import com.slack.circuit.runtime.ExperimentalCircuitApi +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope @@ -138,11 +138,11 @@ private fun AnimatedNavDecorator.transiti val previous = initialStack.current val current = targetStack.current - val initialBackStack = initialStack.backwardStack() - val initialForwardStack = initialStack.forwardStack() + val initialBackStack = initialStack.backward + val initialForwardStack = initialStack.forward - val targetBackStack = targetStack.backwardStack() - val targetForwardStack = targetStack.forwardStack() + val targetBackStack = targetStack.backward + val targetForwardStack = targetStack.forward val animatedNavEvent = when { diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt index 2fdea94f4c..62e52b0e9c 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecorator.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavDecoration -import com.slack.circuit.foundation.NavStackList +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator /** diff --git a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt new file mode 100644 index 0000000000..89c31c133d --- /dev/null +++ b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt @@ -0,0 +1,83 @@ +package com.slack.circuit.runtime + +import androidx.compose.runtime.Immutable + +@Immutable +public interface NavStackList : Iterable { + + public val top: T + + public val current: T + + public val root: T + + /** All items that are between the [current] and [top] items (inclusive). */ + public val forward: Iterable + + /** All items that are between the [current] and [root] items (inclusive). */ + public val backward: Iterable + + /** Returns an iterator over all items from [top] to [root]. */ + override fun iterator(): Iterator +} + +public fun navStackListOf(item: T): NavStackList = SingletonNavStackList(item) + +public fun navStackListOf(vararg items: T): NavStackList = navStackListOf(items.toList()) + +public fun navStackListOf(items: Iterable): NavStackList = DefaultNavStackList(items) + +public fun navStackListOf( + forward: List = emptyList(), + current: T, + backward: List = emptyList(), +): NavStackList = DefaultNavStackList(forward, current, backward) + +private data class SingletonNavStackList(val item: T) : NavStackList { + private val list = listOf(item) + override val top: T = item + override val current: T = item + override val root: T = item + override val forward: Iterable = emptyList() + override val backward: Iterable = emptyList() + + override fun iterator(): Iterator = list.iterator() +} + +private data class DefaultNavStackList( + override val forward: Iterable, + override val current: T, + override val backward: Iterable, +) : NavStackList { + + constructor(list: Iterable) : this(emptyList(), list.first(), list.drop(1)) + + private val list = forward.reversed() + current + backward + + override val top: T = list.first() + override val root: T = list.last() + + override fun iterator(): Iterator = list.iterator() +} + +public fun NavStackList.transform(transform: (T) -> R): NavStackList { + return MappingNavStackList(this, transform) +} + +private class MappingNavStackList( + private val original: NavStackList, + private val transform: (T) -> R, +) : NavStackList { + override val top: R by lazy(LazyThreadSafetyMode.NONE) { original.top.let(transform) } + override val current: R by lazy(LazyThreadSafetyMode.NONE) { original.current.let(transform) } + override val root: R by lazy(LazyThreadSafetyMode.NONE) { original.root.let(transform) } + override val forward: Iterable by + lazy(LazyThreadSafetyMode.NONE) { original.forward.map(transform) } + + override val backward: Iterable by + lazy(LazyThreadSafetyMode.NONE) { original.backward.map(transform) } + + override fun iterator(): Iterator { + return original.iterator().asSequence().map(transform).iterator() + } +} diff --git a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt index 59ab0d34b5..c198830d36 100644 --- a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt +++ b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt @@ -36,7 +36,7 @@ public interface Navigator : GoToNavigator { /** Returns the current back stack. */ public fun peekBackStack(): List - public fun peekNavStack(): NavStackList + public fun peekNavStack(): NavStackList? /** * Clear the existing backstack of [screens][Screen] and navigate to [newRoot]. @@ -128,42 +128,12 @@ public interface Navigator : GoToNavigator { override fun peekBackStack(): List = emptyList() - override fun peekNavStack(): NavStackList = EmptyNavStackList + override fun peekNavStack(): NavStackList? = null override fun resetRoot(newRoot: Screen, options: StateOptions): List = emptyList() } } -/** - * A lightweight view of a navigation stack state, used by [Navigator] to provide context about the - * current navigation position and history. - * - * @param T The type of entries stored in the navigation stack - * @param entries The complete list of entries, ordered from top (newest, index 0) to root (oldest, - * last index) - * @param currentIndex The index of the currently active/visible entry in [entries]. Defaults to 0 - * (top). - */ -public data class NavStackList(val entries: List, val currentIndex: Int = 0) { - /** The number of entries in the stack. */ - public val size: Int - get() = entries.size - - /** The top (newest) entry in the stack at index 0. Could be in the forward history. */ - public val top: T - get() = entries.first() - - /** The currently active/visible entry at [currentIndex]. */ - public val current: T - get() = entries[currentIndex] - - /** The root (oldest) entry in the stack at the last index. */ - public val root: T - get() = entries.last() -} - -internal val EmptyNavStackList = NavStackList(emptyList()) - /** Parameter based alternate for [Navigator.resetRoot]. */ public fun Navigator.resetRoot( newRoot: Screen, diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index 8b961025cd..08366978f6 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -95,7 +95,7 @@ internal class AndroidPredictiveNavDecorator( enabled = { showBackward }, isSeeking = { isSeeking }, shape = MaterialTheme.shapes.extraLarge, - elevation = 6.dp, // if (SharedElementTransitionScope.isTransitionActive) 0.dp else 6.dp, + elevation = 6.dp, transition = transition, offset = { swipeOffset }, progress = { seekableTransitionState.fraction }, diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt index 10ce9d2400..68ecbfcae7 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt @@ -4,8 +4,8 @@ package com.slack.circuitx.gesturenavigation import androidx.compose.runtime.Immutable import com.slack.circuit.foundation.NavArgument -import com.slack.circuit.foundation.NavStackList import com.slack.circuit.foundation.animation.AnimatedNavState +import com.slack.circuit.runtime.NavStackList /** * A holder class used by the `AnimatedContent` composables. This enables us to pass through all of diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt index 0b58399bce..151b2e103b 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt @@ -15,11 +15,12 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.geometry.Offset import com.slack.circuit.foundation.NavArgument -import com.slack.circuit.foundation.NavStackList import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.internal.PredictiveNavDirection import com.slack.circuit.foundation.internal.PredictiveNavEventHandler import com.slack.circuit.runtime.InternalCircuitApi +import com.slack.circuit.runtime.NavStackList +import com.slack.circuit.runtime.navStackListOf import kotlin.math.abs public abstract class PredictiveNavigationDecorator( @@ -51,16 +52,22 @@ public abstract class PredictiveNavigationDecorator( val currentState = remember(args) { targetState(args) } val backwardState = remember(args) { - val index = args.currentIndex + 1 - if (index < args.size) { - targetState(args.copy(currentIndex = index)) + val hasBackward = args.backward.iterator().hasNext() + if (hasBackward) { + val forward = listOf(args.current) + args.forward + val current = args.backward.first() + val backward = args.backward.drop(1) + targetState(navStackListOf(forward, current, backward)) } else null } val forwardState = remember(args) { - val index = args.currentIndex - 1 - if (index > -1) { - targetState(args.copy(currentIndex = index)) + val hasForward = args.forward.iterator().hasNext() + if (hasForward) { + val forward = args.forward.drop(1) + val current = args.forward.first() + val backward = listOf(args.current) + args.backward + targetState(navStackListOf(forward, current, backward)) } else null } seekableTransitionState = remember { SeekableTransitionState(currentState) } diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt index 3489a5c76c..45a517d3cc 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState +import com.slack.circuit.backstack.NavStack +import com.slack.circuit.backstack.isAtRoot import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.Navigator.StateOptions @@ -36,6 +38,7 @@ import com.slack.circuit.runtime.screen.Screen @OptIn(ExperimentalComposeUiApi::class) @Composable public fun rememberInterceptingNavigator( + navStack: NavStack<*>, navigator: Navigator, interceptors: List = emptyList(), eventListeners: List = emptyList(), @@ -53,7 +56,7 @@ public fun rememberInterceptingNavigator( ) } // Handle the back button here to get pop events from it. - if (enableBackHandler) { + if (enableBackHandler && !navStack.isAtRoot) { // Check the screen and not the record as `popRoot()` reorders the screens creating new records. // Also `popUntil` can run to a null screen, which we want to treat as the last screen. val hasScreenChanged = remember { @@ -76,7 +79,7 @@ public fun rememberInterceptingNavigator( // rememberCircuitNavigator as that calls `OnBackPressedDispatcher.onBackPressed`. We need // to unload this BackHandler from the composition before the root pop is triggered, so // delay calling pop until after the next composition. - if (navigator.peekBackStack().size > 1) { + if (!navStack.isAtRoot) { interceptingNavigator.pop() } else { hasPendingRootPop = true diff --git a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt index bda6e5a194..d0232a49e8 100644 --- a/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt +++ b/samples/bottom-navigation/src/androidMain/kotlin/com/slack/circuit/sample/navigation/MainActivity.kt @@ -51,6 +51,7 @@ class MainActivity : AppCompatActivity() { // Build the delegate Navigator. val interceptingNavigator = rememberInterceptingNavigator( + navStack = navStack, navigator = navigator, interceptors = interceptors, eventListeners = eventListeners, diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt index 93359b59c5..7fa230465f 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt @@ -1,4 +1,5 @@ @file:Suppress("Deprecation") + // Copyright (C) 2025 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.sample.navigation @@ -46,14 +47,15 @@ import androidx.window.core.layout.WindowWidthSizeClass import com.slack.circuit.foundation.DelicateCircuitFoundationApi import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavDecoration -import com.slack.circuit.foundation.NavStackList import com.slack.circuit.foundation.animation.AnimatedNavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform import com.slack.circuit.retained.rememberRetained import com.slack.circuit.retained.rememberRetainedStateHolder import com.slack.circuit.runtime.ExperimentalCircuitApi +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.navStackListOf import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.sample.navigation.ListDetailScaffoldStyle.Companion.defaultListDetailScaffoldStyle import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope @@ -169,7 +171,7 @@ class AdaptiveListDetailNavDecoration( val secondaryArgs = with(secondaryLookupTransition) { currentState[primary] ?: targetState[primary] } - val hasSecondary = !secondaryArgs?.entries.isNullOrEmpty() + val hasSecondary = secondaryArgs != null val scaffoldValue = if (hasSecondary) PrimarySecondary else Primary val scaffoldState = remember { MutableThreePaneScaffoldState(scaffoldValue) } @@ -209,7 +211,7 @@ class AdaptiveListDetailNavDecoration( ) // Prevent the detailPaneDelegate from handling the back press - val singleSecondary = hasSecondary && secondaryArgs.size == 1 + val singleSecondary = hasSecondary && secondaryArgs.singleOrNull() != null PredictiveBackHandler(enabled = singleSecondary) { progress -> try { progress.collect { backEvent -> @@ -342,27 +344,28 @@ internal fun rememberListDetailNavArguments( val primary = mutableListOf() val secondaryLookup = mutableMapOf>() val secondary = mutableListOf() - for (arg in navStackList.backwardStack()) { + for (arg in navStackList.backward) { when { showInDetailPane(arg) -> { secondary += arg } else -> { primary += arg - secondaryLookup[arg] = NavStackList(secondary.toList()) + if (secondary.isNotEmpty()) { + secondaryLookup[arg] = navStackListOf(secondary.toList()) + } secondary.clear() } } } if (primary.isEmpty()) { - NavStackList(secondary.toList()) to emptyMap() + navStackListOf(secondary.toList()) to emptyMap() } else { // Show the next secondary if it exists - navStackList - .forwardStack() + navStackList.forward .firstOrNull() ?.takeIf { showInDetailPane(it) } - ?.let { secondaryLookup.getOrPut(primary.first()) { NavStackList(listOf(it)) } } - NavStackList(primary.toList()) to secondaryLookup.toMap() + ?.let { secondaryLookup.getOrPut(primary.first()) { navStackListOf(it) } } + navStackListOf(primary.toList()) to secondaryLookup.toMap() } } diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index 6902671d72..c960154129 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -156,6 +156,7 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen val interceptingNavigator = rememberInterceptingNavigator( + navStack = navStack, navigator = contentNavigator, interceptors = listOf(contentInterceptor), ) diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index a236465abf..f211fdd9c3 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -99,9 +99,8 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat val navStack = navigator.peekNavStack() return TabScreenCircuit.State( label = screen.label, - canGoBack = - navStack.entries.subList(navStack.currentIndex + 1, navStack.entries.size).isNotEmpty(), - canGoForward = navStack.entries.subList(0, navStack.currentIndex).isNotEmpty(), + canGoBack = navStack?.backward?.firstOrNull() != null, + canGoForward = navStack?.forward?.firstOrNull() != null, hasDetails = screen !is TabScreen.Screen2, ) { event -> when (event) { @@ -179,7 +178,7 @@ fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = navStack?.let { navStack -> itemsIndexed(navStack.toList()) { i, item -> Text( - text = "$i: ${item.screen} ${if(navStack.currentIndex == i) "(active)" else ""}", + text = "$i: ${item.screen} ${if(navStack.current == item) "(active)" else ""}", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), ) From d8e05e3e68d7602c1428f3c82d8f36dfd280b72f Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 18 Nov 2025 15:05:09 -0800 Subject: [PATCH 41/44] some nav fixes --- ...droidPredictiveBackNavigationDecoration.kt | 5 +-- .../PredictiveNavigationDecorator.kt | 3 +- .../intercepting/InterceptingNavigator.kt | 4 +-- .../circuit/sample/navigation/TabScreen.kt | 35 ++++++++++--------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index 08366978f6..d5aedfac22 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -108,10 +108,7 @@ internal class AndroidPredictiveNavDecorator( object Factory : AnimatedNavDecorator.Factory { override fun create(navigator: Navigator): AnimatedNavDecorator { return AndroidPredictiveNavDecorator( - onBackInvoked = { - // todo Handle root? - navigator.backward() - }, + onBackInvoked = { navigator.pop() }, onForwardInvoked = { navigator.forward() }, ) } diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt index 151b2e103b..e7203f9122 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt @@ -106,7 +106,8 @@ public abstract class PredictiveNavigationDecorator( PredictiveNavEventHandler( isBackEnabled = backwardState != null, - isForwardEnabled = forwardState != null, + // If we're at root, disable forward so system predictive back works.. + isForwardEnabled = forwardState != null && backwardState != null, onProgress = { direction, progress, offset -> when (direction) { PredictiveNavDirection.Back -> { diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt index 45a517d3cc..7bd973a56a 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt @@ -56,7 +56,7 @@ public fun rememberInterceptingNavigator( ) } // Handle the back button here to get pop events from it. - if (enableBackHandler && !navStack.isAtRoot) { + if (enableBackHandler) { // Check the screen and not the record as `popRoot()` reorders the screens creating new records. // Also `popUntil` can run to a null screen, which we want to treat as the last screen. val hasScreenChanged = remember { @@ -73,7 +73,7 @@ public fun rememberInterceptingNavigator( var enableRootBackHandler by remember(hasScreenChanged) { mutableStateOf(true) } NavigationBackHandler( state = rememberNavigationEventState(NavigationEventInfo.None), - isBackEnabled = enableRootBackHandler, + isBackEnabled = enableRootBackHandler && !navStack.isAtRoot, onBackCompleted = { // Root pop check to prevent an infinite loop if this is used with the Android variant of // rememberCircuitNavigator as that calls `OnBackPressedDispatcher.onBackPressed`. We need diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index f211fdd9c3..bc15cbe03b 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -5,6 +5,7 @@ package com.slack.circuit.sample.navigation import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -133,33 +134,33 @@ fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = SharedElementTransitionScope { val navStack = LocalNavStack.current?.snapshot() Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - enabled = state.canGoBack, - onClick = { state.eventSink(TabScreenCircuit.Event.Backward) }, - ) { - Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") - } - IconButton( - enabled = state.canGoForward, - onClick = { state.eventSink(TabScreenCircuit.Event.Forward) }, - ) { - Icon(Icons.AutoMirrored.Default.ArrowForward, contentDescription = "Forward") + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Row(modifier = Modifier.align(Alignment.CenterStart)) { + IconButton( + enabled = state.canGoBack, + onClick = { state.eventSink(TabScreenCircuit.Event.Backward) }, + ) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + IconButton( + enabled = state.canGoForward, + onClick = { state.eventSink(TabScreenCircuit.Event.Forward) }, + ) { + Icon(Icons.AutoMirrored.Default.ArrowForward, contentDescription = "Forward") + } } Text( text = state.label, style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.testTag(ContentTags.TAG_LABEL).weight(1f), + modifier = Modifier.testTag(ContentTags.TAG_LABEL).align(Alignment.Center), ) if (state.hasDetails) { Button( colors = ButtonDefaults.outlinedButtonColors(), onClick = { state.eventSink(TabScreenCircuit.Event.Details(screen)) }, modifier = - Modifier.sharedBounds( + Modifier.align(Alignment.CenterEnd) + .sharedBounds( sharedContentState = rememberSharedContentState(key = "${screen}-details"), animatedVisibilityScope = requireAnimatedScope(Navigation), ) From b7999291615a876a05763b52a20abbf10420abea Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Tue, 18 Nov 2025 16:49:11 -0800 Subject: [PATCH 42/44] kdoc --- .../com/slack/circuit/backstack/NavStack.kt | 186 ++++++------------ .../circuit/backstack/SaveableNavStack.kt | 25 +-- .../com/slack/circuit/runtime/NavStackList.kt | 53 ++++- .../com/slack/circuit/runtime/Navigator.kt | 31 ++- 4 files changed, 155 insertions(+), 140 deletions(-) diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt index 7b89775cbf..f8efaabdee 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt @@ -7,118 +7,61 @@ import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.screen.Screen /** - * A navigation stack that supports forward navigation in addition to backward navigation. This is - * the base interface for navigation stacks in Circuit. + * A navigation stack supporting bidirectional navigation with browser-style forward/backward + * traversal. * - * ## Navigation Model + * Manages [Record]s in a list with position tracking, enabling navigation without modifying the + * stack structure. Key positions are [topRecord] (newest), [currentRecord] (active), and + * [rootRecord] (oldest). * - * NavStack maintains a list of records with a current position, supporting browser-style - * navigation: - * - **topRecord**: The newest entry (most recently added) - * - **currentRecord**: The currently visible/active entry - * - **rootRecord**: The oldest entry (initial screen) + * Supports multiple independent nav stacks (e.g., bottom nav tabs) via [saveState], [restoreState], + * [peekState], and [removeState]. State is keyed by root screen. * - * Navigation operations: - * - **add()**: Adds a new entry at the top and truncates forward history - * - **remove()**: Removes the current entry (and forward history), makes the next entry current - * - **move()**: Changes position without modifying the stack - * - **snapshot()**: Captures the current state immutably - * - * ## Example Usage - * - * ```kotlin - * val navStack = rememberSaveableNavStack(HomeScreen) - * - * // Build up a stack: [Home, Profile, Settings] - * navStack.add(ProfileScreen) - * navStack.add(SettingsScreen) - * // Stack: [Settings (current), Profile, Home] - * - * // Navigate backward through history - * navStack.move(Direction.Backward) - * // Stack: [Settings, Profile (current), Home] - * - * navStack.move(Direction.Backward) - * // Stack: [Settings, Profile, Home (current)] - * - * // Navigate forward - * navStack.move(Direction.Forward) - * // Stack: [Settings, Profile (current), Home] - * - * // Adding a new screen truncates forward history - * navStack.add(NotificationsScreen) - * // Stack: [Notifications (current), Profile, Home] - * // Settings was removed from forward history - * - * // Remove current entry (also truncates forward history if any) - * navStack.remove() - * // Stack: [Profile (current), Home] - * ``` - * - * @see SaveableNavStack for a concrete implementation + * @see SaveableNavStack for the primary implementation + * @see NavStackList for immutable snapshots */ @Stable public interface NavStack { /** The number of records in the stack. */ public val size: Int - /** - * The top-most (newest) record in the stack, or `null` if the stack is empty. This is always the - * most recently added record, regardless of the current position. - */ + /** The top-most (newest) record, or null if empty. Always the most recently added record. */ public val topRecord: R? /** - * The currently active record in the stack, or `null` if the stack is empty. This is the record - * at the current navigation position, which may differ from [topRecord] when the user has - * navigated backward. + * The currently active record, or null if empty. May differ from [topRecord] when navigated + * backward. */ public val currentRecord: R? - /** - * The bottom-most (oldest) record in the stack, or `null` if the stack is empty. This is - * typically the initial root screen of the navigation flow. - */ + /** The bottom-most (oldest) record, or null if empty. Typically the initial root screen. */ public val rootRecord: R? /** - * Adds a screen to the stack, wrapping it in a new [Record]. - * - * Implementations may truncate forward history when adding from a non-top position, similar to - * browser navigation behavior. + * Adds a screen to the stack. Truncates forward history if not at top. * - * @param screen The screen to add - * @return true if the screen was added, false if it was a duplicate of the current top + * @return true if added, false otherwise */ public fun push(screen: Screen): Boolean /** - * Adds a record to the stack. + * Adds a record to the stack. Truncates forward history if not at top. * - * Implementations may truncate forward history when adding from a non-top position, similar to - * browser navigation behavior. - * - * @param record The record to add - * @return true if the record was added, false if it was a duplicate of the current top + * @return true if added, false otherwise */ public fun push(record: R): Boolean /** - * Removes and returns the current record from the stack. - * - * When called from a non-top position, this operation: - * 1. Truncates any forward history (entries between top and current) - * 2. Removes the current record - * 3. Sets the new top as current + * Removes and returns the current record, truncating forward history. * - * This provides browser-like "remove and discard forward history" behavior. - * - * @return The removed record, or null if the stack is empty + * @return The removed record, or null if empty */ public fun pop(): R? /** - * Pop records off the top of the backstack until one is found that matches the given predicate. + * Pops records until one matches the predicate. + * + * @return List of popped records */ public fun popUntil(predicate: (R) -> Boolean): List { return buildList { @@ -129,116 +72,115 @@ public interface NavStack { } } + /** + * Move forward in navigation history towards the [topRecord]. + * + * @return true if moved, false otherwise. + */ public fun forward(): Boolean - public fun backward(): Boolean - /** - * Creates a snapshot of the current stack state. - * - * The returned snapshot is immutable and can be used to capture the current navigation state for - * inspection, serialization, or other purposes. + * Move backward in navigation history towards the [rootRecord]. * - * @return A [NavStackList] representing the current stack state, null if the stack is empty. + * @return true if moved, false otherwise. */ - public fun snapshot(): NavStackList? + public fun backward(): Boolean /** - * Saves the current stack entry list and position in an internal state store. It can be later - * restored by the root screen to [restoreState]. + * Creates an immutable snapshot of the current stack state. * - * The saved state includes both the list of records and the current position, allowing for - * complete restoration of the navigation state. - * - * This call will overwrite any existing stored state with the same root screen. + * @return [NavStackList] of current state, or null if empty. */ + public fun snapshot(): NavStackList? + + /** Saves the current stack to an internal store, keyed by the root screen. */ public fun saveState() /** - * Restores the saved state with the given [screen], adding it on top of the existing entry list. - * If you wish to replace the current entry list, you should [pop] all of the existing entries - * before calling this function. + * Restores previously saved state for the given root [screen], replacing the current stack. * - * @param screen The root screen which was previously saved using [saveState]. - * @return Returns true if there was any stack state to restore. + * @return true if state was restored, false if no saved state found */ public fun restoreState(screen: Screen): Boolean /** - * Peek at the [Screen] in the internal state store that have been saved using [saveState]. + * Returns list of root screens that have saved state. * - * @return The list of [Screen]s currently in the internal state store, will be empty if there is - * no saved state. + * @return List of screens with saved state, empty if none. */ public fun peekState(): List /** - * Removes the state associated with the given [screen] from the internal state store. + * Removes saved state for the given [screen]. * - * @return true if the state was removed, false if no state was found for the given screen. + * @return true if state was removed, false otherwise. */ public fun removeState(screen: Screen): Boolean /** - * Whether the stack contains the given [record]. + * Checks if the stack contains the given [record]. * - * @param includeSaved Whether to also check if the record is contained by any saved stack state. - * See [saveState]. + * @param includeSaved Whether to also check saved stack states */ public fun containsRecord(record: R, includeSaved: Boolean): Boolean /** - * Whether a record with the given [key] is reachable within the stack or saved state. Reachable - * means that it is either currently in the visible stack or if we popped `depth` times, it would - * be found. + * Checks if a record with the given [key] is reachable within [depth] pops from current position. * - * @param key The record's key to look for. - * @param depth How many records to consider popping from the top of the stack before considering - * the key unreachable. A depth of zero means only check the current visible stack. A depth of 1 - * means check the current visible stack plus one record popped off the top, and so on. - * @param includeSaved Whether to also check if the record is contained by any saved stack state. - * See [saveState]. + * @param key The record key to find + * @param depth Depth to search (0 = the current record, 1 = single record before and after) + * @param includeSaved Whether to also check saved states */ public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean + /** + * A record in the navigation stack, wrapping a [Screen] with a unique identity. + * + * Each record has a stable [key] for identity tracking across configuration changes and state + * restoration. + */ @Stable public interface Record { /** - * A value that identifies this record uniquely, even if it shares the same [screen] with - * another record. This key may be used by [NavStackRecordLocalProvider]s to associate - * presentation data with a record across composition recreation. - * - * [key] MUST NOT change for the life of the record. + * Unique identifier for this record. Remains stable across configuration changes and must not + * change for the life of the record. Used to associate retained and saved data with records. */ public val key: String - /** The [Screen] that should present this record. */ + /** The [Screen] that this record presents. */ public val screen: Screen } } +/** The screen of the current record, or null if empty. */ public val NavStack.currentScreen: Screen? get() = currentRecord?.screen +/** True if the stack is empty. */ public val NavStack.isEmpty: Boolean get() = size == 0 +/** The index of the last record in the stack. */ public val NavStack.lastIndex: Int get() = size - 1 +/** True if the current position is at the root. */ public val NavStack.isAtRoot: Boolean get() = currentRecord == rootRecord +/** True if the current position is at the top. */ public val NavStack.isAtTop: Boolean get() = currentRecord == topRecord +/** True if we can navigate backwards (not at root). */ public val NavStack.canGoBack: Boolean get() = currentRecord != rootRecord +/** True if we can navigate forwards (not at top). */ public val NavStack.canGoForward: Boolean get() = currentRecord != topRecord -/** Clear any saved state from the [BackStack]. */ +/** Clears all saved state from the stack. */ public fun NavStack.clearState() { Snapshot.withMutableSnapshot { for (screen in peekState()) { diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt index 112c4cc76d..83e50d1918 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt @@ -157,19 +157,20 @@ internal constructor( } } - override fun restoreState(screen: Screen): Boolean { - val stored = stateStore[screen] - if (stored != null && stored.entries.isNotEmpty()) { - // Add the stored state into the entry list - entryList.addAll(stored.entries) - // Restore the current index - currentIndex = stored.currentIndex - // Clear the stored state - stateStore.remove(screen) - return true + override fun restoreState(screen: Screen): Boolean = + Snapshot.withMutableSnapshot { + val stored = stateStore[screen] + if (stored != null && stored.entries.isNotEmpty()) { + entryList.clear() + // Add the stored state into the entry list + entryList.addAll(stored.entries) + // Restore the current index + currentIndex = stored.currentIndex + // Clear the stored state + stateStore.remove(screen) + true + } else false } - return false - } override fun peekState(): List { return stateStore.keys.toList() diff --git a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt index 89c31c133d..61447d1865 100644 --- a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt +++ b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt @@ -2,37 +2,78 @@ package com.slack.circuit.runtime import androidx.compose.runtime.Immutable +/** + * An immutable snapshot of a navigation stack with position tracking. + * + * Represents a point-in-time view of a navigation stack, capturing items and a current position. + * Provides access to [top] (newest), [current] (active), and [root] (oldest) items, plus [forward] + * and [backward] sublists for navigation history. An empty [NavStackList] is not allowed. + * + * Example with stack `[D, C, B, A]` where D is top, A is root, and current is C: + * ``` + * top: D + * forward: [D] + * current: C ← current position + * backward: [B, A] + * root: A + * ``` + * + * Can be created by the [navStackListOf] factory functions. + * + * @param T The type of items in the stack + */ @Immutable public interface NavStackList : Iterable { + /** The top (newest/most recently added) item in the stack. */ public val top: T + /** The currently active item in the stack. May differ from [top] when forward history exists. */ public val current: T + /** The root (oldest/initial) item in the stack. */ public val root: T - /** All items that are between the [current] and [top] items (inclusive). */ + /** + * Items between [current] and [top] (exclusive of current). Empty if no forward history exists. + */ public val forward: Iterable - /** All items that are between the [current] and [root] items (inclusive). */ + /** + * Items between [current] and [root] (exclusive of current). Empty if no backward history exists. + */ public val backward: Iterable /** Returns an iterator over all items from [top] to [root]. */ override fun iterator(): Iterator } +/** Creates a [NavStackList] with a single item (which is top, current, and root). */ public fun navStackListOf(item: T): NavStackList = SingletonNavStackList(item) +/** Creates a [NavStackList] from the given items, with the first item as top and last as root. */ public fun navStackListOf(vararg items: T): NavStackList = navStackListOf(items.toList()) +/** + * Creates a [NavStackList] from the given items, with the first item as top/current and last as + * root. + */ public fun navStackListOf(items: Iterable): NavStackList = DefaultNavStackList(items) +/** + * Creates a [NavStackList] with explicit forward/backward lists around a current item. + * + * @param forward Items between current and top (ordered from current toward top) + * @param current The currently active item + * @param backward Items between current and root (ordered from current toward root) + */ public fun navStackListOf( forward: List = emptyList(), current: T, backward: List = emptyList(), ): NavStackList = DefaultNavStackList(forward, current, backward) +/** Implementation for a single-item [NavStackList]. */ private data class SingletonNavStackList(val item: T) : NavStackList { private val list = listOf(item) override val top: T = item @@ -44,6 +85,7 @@ private data class SingletonNavStackList(val item: T) : NavStackList { override fun iterator(): Iterator = list.iterator() } +/** Default implementation of [NavStackList] backed by lists of items. */ private data class DefaultNavStackList( override val forward: Iterable, override val current: T, @@ -60,10 +102,17 @@ private data class DefaultNavStackList( override fun iterator(): Iterator = list.iterator() } +/** + * Transforms each item in this [NavStackList] using the given [transform] function. + * + * The transformation is applied lazily when items are accessed. Useful for mapping between + * different types (e.g., Records to Screens). + */ public fun NavStackList.transform(transform: (T) -> R): NavStackList { return MappingNavStackList(this, transform) } +/** Implementation of [NavStackList] that lazily applies a transform function to items. */ private class MappingNavStackList( private val original: NavStackList, private val transform: (T) -> R, diff --git a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt index c198830d36..ca2a968b85 100644 --- a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt +++ b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt @@ -7,13 +7,18 @@ import androidx.compose.runtime.snapshots.Snapshot import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen -/** A Navigator that only supports [goTo]. */ +/** + * A minimal navigation interface that only supports forward navigation via [goTo]. + * + * @see Navigator for the full navigation interface + */ @Stable public interface GoToNavigator { /** * Navigate to the [screen]. * - * @return If the navigator successfully went to the [screen] + * @return true if the navigator successfully navigated to the [screen], false if the navigation + * was rejected. */ public fun goTo(screen: Screen): Boolean } @@ -23,19 +28,36 @@ public interface GoToNavigator { public interface Navigator : GoToNavigator { public override fun goTo(screen: Screen): Boolean + /** + * Move forward in navigation history toward the top. + * + * @return true if moved forward, false if already at the top or no forward history exists + */ public fun forward(): Boolean - // todo PopResult? Root behavior? + /** + * Move backward in navigation history toward the root. + * + * @return true if moved backward, false if already at the root + */ + // todo PopResult? Root behavior? Should this be an option pop? public fun backward(): Boolean + /** + * Remove and return the current screen from the navigation stack. + * + * @param result Optional result to pass back to the previous screen + * @return The removed screen, or null if the stack is empty + */ public fun pop(result: PopResult? = null): Screen? - /** Returns current top most screen of backstack, or null if backstack is empty. */ + /** Returns current top most screen of backstack, or null if the navStack is empty. */ public fun peek(): Screen? /** Returns the current back stack. */ public fun peekBackStack(): List + /** Returns a snapshot of the current navigation stack, or null if empty. */ public fun peekNavStack(): NavStackList? /** @@ -115,6 +137,7 @@ public interface Navigator : GoToNavigator { } } + /** A no-op implementation of [Navigator] that performs no actual navigation. */ public object NoOp : Navigator { override fun goTo(screen: Screen): Boolean = true From 385f8b5107e4a7f9d3207da18e4593012c4aa484 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 21 Nov 2025 15:43:20 -0800 Subject: [PATCH 43/44] Keep last record in history on pop, update circuitx-navigation --- .../foundation/NavigableCircuitContent.kt | 5 +- ...droidPredictiveBackNavigationDecoration.kt | 43 +++++- .../GestureNavigationDecoration.kt | 13 +- .../PredictiveNavigationDecorator.kt | 19 +-- .../iOSPredictiveBackNavigationDecoration.kt | 18 ++- .../GestureNavigationDecoration.jvm.kt | 9 +- .../GestureNavigationDecoration.web.kt | 9 +- .../intercepting/InterceptingNavigator.kt | 146 +++++++++++++++--- .../LoggingNavigationEventListener.kt | 32 ++++ .../LoggingNavigatorFailureNotifier.kt | 8 + .../intercepting/NavigationContext.kt | 9 ++ .../intercepting/NavigationEventListener.kt | 45 +++++- .../intercepting/NavigationInterceptor.kt | 69 +++++---- .../sample/navigation/ContentScreen.kt | 30 +++- .../circuit/sample/navigation/TabScreen.kt | 30 ++-- 15 files changed, 389 insertions(+), 96 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 2680df71ab..32c546805a 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -475,9 +475,8 @@ private fun createRecordContent(onActive: () -> Unit, onDispose: () movableContentOf> { record, contentProviderState -> val lifecycle = remember { MutableRecordLifecycle() } // todo Allow this to be customized by the decoration for multiple active records. - SideEffect { - lifecycle.isActive = contentProviderState.lastNavigator.navStack.currentRecord == record - } + val currentRecord = contentProviderState.lastNavigator.navStack.currentRecord + SideEffect { lifecycle.isActive = currentRecord == record } contentProviderState.saveableStateHolder.SaveableStateProvider(record.registryKey) { // Provides a RetainedStateRegistry that is maintained independently for each record while // the record exists in the back stack. diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index d5aedfac22..b10b7d319b 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -31,22 +31,42 @@ import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedNavEvent import com.slack.circuit.foundation.animation.AnimatedNavState import com.slack.circuit.runtime.InternalCircuitApi +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import kotlin.math.absoluteValue public actual fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory + fallback: AnimatedNavDecorator.Factory, + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, ): AnimatedNavDecorator.Factory { return when { - Build.VERSION.SDK_INT >= 34 -> AndroidPredictiveNavDecorator.Factory + Build.VERSION.SDK_INT >= 34 -> + AndroidPredictiveNavDecorator.Factory( + // If we're at root, disable forward so system predictive back works... + isForwardEnabled = { isForwardEnabled(it) && !it.backward.any() }, + onForwardInvoked = onForwardInvoked, + isBackEnabled = isBackEnabled, + onBackInvoked = onBackInvoked, + ) else -> fallback } } internal class AndroidPredictiveNavDecorator( - onBackInvoked: () -> Unit, - onForwardInvoked: () -> Unit, -) : PredictiveNavigationDecorator(onBackInvoked, onForwardInvoked) { + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (NavStackList) -> Unit, + onBackInvoked: (NavStackList) -> Unit, +) : + PredictiveNavigationDecorator( + isForwardEnabled = isForwardEnabled, + isBackEnabled = isBackEnabled, + onForwardInvoked = onForwardInvoked, + onBackInvoked = onBackInvoked, + ) { // Track popped zIndex so screens are layered correctly private var zIndexDepth = 0f @@ -105,11 +125,18 @@ internal class AndroidPredictiveNavDecorator( } } - object Factory : AnimatedNavDecorator.Factory { + class Factory( + val isForwardEnabled: (NavStackList) -> Boolean, + val isBackEnabled: (NavStackList) -> Boolean, + val onForwardInvoked: (Navigator, NavStackList) -> Unit, + val onBackInvoked: (Navigator, NavStackList) -> Unit, + ) : AnimatedNavDecorator.Factory { override fun create(navigator: Navigator): AnimatedNavDecorator { return AndroidPredictiveNavDecorator( - onBackInvoked = { navigator.pop() }, - onForwardInvoked = { navigator.forward() }, + isForwardEnabled = isForwardEnabled, + isBackEnabled = isBackEnabled, + onForwardInvoked = { onForwardInvoked(navigator, it) }, + onBackInvoked = { onBackInvoked(navigator, it) }, ) } } diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt index 5ca7a77d65..54db8c8a54 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt @@ -2,8 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuitx.gesturenavigation +import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavigatorDefaults import com.slack.circuit.foundation.animation.AnimatedNavDecorator +import com.slack.circuit.runtime.NavStackList +import com.slack.circuit.runtime.Navigator /** * Returns a [AnimatedNavDecorator.Factory] implementation which support navigation through @@ -17,5 +20,13 @@ import com.slack.circuit.foundation.animation.AnimatedNavDecorator * which [GestureNavigationDecorationFactory] does not support. */ public expect fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory = NavigatorDefaults.DefaultDecoratorFactory + fallback: AnimatedNavDecorator.Factory = NavigatorDefaults.DefaultDecoratorFactory, + isForwardEnabled: (NavStackList) -> Boolean = { it.forward.any() }, + isBackEnabled: (NavStackList) -> Boolean = { it.backward.any() }, + onForwardInvoked: (Navigator, NavStackList) -> Unit = { navigator, _ -> + navigator.forward() + }, + onBackInvoked: (Navigator, NavStackList) -> Unit = { navigator, _ -> + navigator.pop() + }, ): AnimatedNavDecorator.Factory diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt index e7203f9122..6ea81157c6 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt @@ -24,8 +24,10 @@ import com.slack.circuit.runtime.navStackListOf import kotlin.math.abs public abstract class PredictiveNavigationDecorator( - private val onBackInvoked: () -> Unit, - private val onForwardInvoked: () -> Unit, + private val isForwardEnabled: (NavStackList) -> Boolean, + private val isBackEnabled: (NavStackList) -> Boolean, + private val onForwardInvoked: (NavStackList) -> Unit, + private val onBackInvoked: (NavStackList) -> Unit, ) : AnimatedNavDecorator> { protected lateinit var seekableTransitionState: @@ -52,7 +54,7 @@ public abstract class PredictiveNavigationDecorator( val currentState = remember(args) { targetState(args) } val backwardState = remember(args) { - val hasBackward = args.backward.iterator().hasNext() + val hasBackward = args.backward.any() if (hasBackward) { val forward = listOf(args.current) + args.forward val current = args.backward.first() @@ -62,7 +64,7 @@ public abstract class PredictiveNavigationDecorator( } val forwardState = remember(args) { - val hasForward = args.forward.iterator().hasNext() + val hasForward = args.forward.any() if (hasForward) { val forward = args.forward.drop(1) val current = args.forward.first() @@ -105,9 +107,8 @@ public abstract class PredictiveNavigationDecorator( } PredictiveNavEventHandler( - isBackEnabled = backwardState != null, - // If we're at root, disable forward so system predictive back works.. - isForwardEnabled = forwardState != null && backwardState != null, + isBackEnabled = isBackEnabled(currentState.navStack), + isForwardEnabled = isForwardEnabled(currentState.navStack), onProgress = { direction, progress, offset -> when (direction) { PredictiveNavDirection.Back -> { @@ -130,8 +131,8 @@ public abstract class PredictiveNavigationDecorator( }, onCompleted = { direction -> when (direction) { - PredictiveNavDirection.Back -> onBackInvoked() - PredictiveNavDirection.Forward -> onForwardInvoked() + PredictiveNavDirection.Back -> onBackInvoked(currentState.navStack) + PredictiveNavDirection.Forward -> onForwardInvoked(currentState.navStack) } }, ) diff --git a/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt index 4ef9ccbadc..f0176e2d16 100644 --- a/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt @@ -32,6 +32,7 @@ import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedNavEvent import com.slack.circuit.foundation.animation.AnimatedNavState +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import kotlin.math.roundToInt import kotlinx.coroutines.CancellationException @@ -48,7 +49,11 @@ private val End: (Int) -> Int = { it } * @return An [AnimatedNavDecorator.Factory] that provides iOS predictive back navigation. */ public actual fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory + fallback: AnimatedNavDecorator.Factory, + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, ): AnimatedNavDecorator.Factory { return IOSPredictiveNavDecorator.Factory() } @@ -114,12 +119,17 @@ internal class IOSPredictiveNavDecorator( } } - internal class Factory(private val enterOffsetFraction: Float = 0.25f) : - AnimatedNavDecorator.Factory { + internal class Factory( + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, + private val enterOffsetFraction: Float = 0.25f, + ) : AnimatedNavDecorator.Factory { override fun create(navigator: Navigator): AnimatedNavDecorator { return IOSPredictiveNavDecorator( enterOffsetFraction = enterOffsetFraction, - onBackInvoked = { navigator.pop() }, + onBackInvoked = { onBackInvoked(navigator, it) }, ) } } diff --git a/circuitx/gesture-navigation/src/jvmMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.jvm.kt b/circuitx/gesture-navigation/src/jvmMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.jvm.kt index ffb368e925..7b5633f79e 100644 --- a/circuitx/gesture-navigation/src/jvmMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.jvm.kt +++ b/circuitx/gesture-navigation/src/jvmMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.jvm.kt @@ -2,8 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuitx.gesturenavigation +import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavDecorator +import com.slack.circuit.runtime.NavStackList +import com.slack.circuit.runtime.Navigator public actual fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory + fallback: AnimatedNavDecorator.Factory, + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, ): AnimatedNavDecorator.Factory = fallback diff --git a/circuitx/gesture-navigation/src/webMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.web.kt b/circuitx/gesture-navigation/src/webMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.web.kt index c7ab3ab327..e1de56b144 100644 --- a/circuitx/gesture-navigation/src/webMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.web.kt +++ b/circuitx/gesture-navigation/src/webMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.web.kt @@ -2,8 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuitx.gesturenavigation +import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavDecorator +import com.slack.circuit.runtime.NavStackList +import com.slack.circuit.runtime.Navigator public actual fun GestureNavigationDecorationFactory( - fallback: AnimatedNavDecorator.Factory + fallback: AnimatedNavDecorator.Factory, + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, ): AnimatedNavDecorator.Factory = fallback diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt index 7bd973a56a..00d33f6caa 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt @@ -16,7 +16,9 @@ import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState import com.slack.circuit.backstack.NavStack import com.slack.circuit.backstack.isAtRoot +import com.slack.circuit.foundation.NavEvent import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.Navigator.StateOptions import com.slack.circuit.runtime.screen.PopResult @@ -120,7 +122,7 @@ public class InterceptingNavigator( ) : Navigator by delegate { override fun goTo(screen: Screen): Boolean { - val navigationContext = InterceptingNavigationContext(this) + val navigationContext = InterceptingNavigationContext(peekNavStack()) for (interceptor in interceptors) { when (val interceptedResult = interceptor.goTo(screen, navigationContext)) { is InterceptedResult.Skipped -> continue @@ -131,20 +133,22 @@ public class InterceptingNavigator( notifier?.goToFailure(interceptedResult) if (interceptedResult.consumed) return false } - is InterceptedGoToResult.Rewrite -> { - // Recurse in case another interceptor wants to intercept the new screen. - return goTo(interceptedResult.screen) + is InterceptedResult.Rewrite -> { + return rewriteBooleanResult(interceptedResult) } } } - eventListeners.forEach { it.goTo(screen, navigationContext) } + val eventNavigationContext = InterceptingNavigationContext(peekNavStack()) + eventListeners.forEach { it.goTo(screen, eventNavigationContext) } return delegate.goTo(screen) } override fun pop(result: PopResult?): Screen? { - val navigationContext = InterceptingNavigationContext(this) for (interceptor in interceptors) { - when (val interceptedResult = interceptor.pop(result, navigationContext)) { + when ( + val interceptedResult = + interceptor.pop(result, InterceptingNavigationContext(peekNavStack())) + ) { is InterceptedResult.Skipped -> continue is InterceptedResult.Success -> { if (interceptedResult.consumed) return null @@ -153,14 +157,25 @@ public class InterceptingNavigator( notifier?.popFailure(interceptedResult) if (interceptedResult.consumed) return null } + is InterceptedResult.Rewrite -> { + when (val event = interceptedResult.navEvent) { + is NavEvent.Pop -> return pop(event.result) + is NavEvent.ResetRoot -> return resetRoot(event.newRoot, event.options).lastOrNull() + is NavEvent.GoTo -> goTo(event.screen) + is NavEvent.Backward -> backward() + is NavEvent.Forward -> forward() + } + return null + } } } - eventListeners.forEach { it.pop(result, navigationContext) } + val eventNavigationContext = InterceptingNavigationContext(peekNavStack()) + eventListeners.forEach { it.pop(result, eventNavigationContext) } return delegate.pop(result) } override fun resetRoot(newRoot: Screen, options: StateOptions): List { - val navigationContext = InterceptingNavigationContext(this) + val navigationContext = InterceptingNavigationContext(peekNavStack()) for (interceptor in interceptors) { when (val interceptedResult = interceptor.resetRoot(newRoot, options, navigationContext)) { is InterceptedResult.Skipped -> continue @@ -171,16 +186,67 @@ public class InterceptingNavigator( notifier?.rootResetFailure(interceptedResult) if (interceptedResult.consumed) return emptyList() } - is InterceptedResetRootResult.Rewrite -> { - // Recurse in case another interceptor wants to intercept the new screen. - return resetRoot(interceptedResult.screen, interceptedResult.stateOptions) + is InterceptedResult.Rewrite -> { + when (val event = interceptedResult.navEvent) { + is NavEvent.ResetRoot -> return resetRoot(event.newRoot, event.options) + is NavEvent.Pop -> pop(event.result) + is NavEvent.GoTo -> goTo(event.screen) + is NavEvent.Backward -> backward() + is NavEvent.Forward -> forward() + } + return emptyList() } } } - eventListeners.forEach { it.resetRoot(newRoot, options, navigationContext) } + val eventNavigationContext = InterceptingNavigationContext(peekNavStack()) + eventListeners.forEach { it.resetRoot(newRoot, options, eventNavigationContext) } return delegate.resetRoot(newRoot, options) } + override fun forward(): Boolean { + val navigationContext = InterceptingNavigationContext(peekNavStack()) + for (interceptor in interceptors) { + when (val interceptedResult = interceptor.forward(navigationContext)) { + is InterceptedResult.Skipped -> continue + is InterceptedResult.Success -> { + if (interceptedResult.consumed) return true + } + is InterceptedResult.Failure -> { + notifier?.forwardFailure(interceptedResult) + if (interceptedResult.consumed) return false + } + is InterceptedResult.Rewrite -> { + return rewriteBooleanResult(interceptedResult) + } + } + } + val eventNavigationContext = InterceptingNavigationContext(peekNavStack()) + eventListeners.forEach { it.forward(eventNavigationContext) } + return delegate.forward() + } + + override fun backward(): Boolean { + val navigationContext = InterceptingNavigationContext(peekNavStack()) + for (interceptor in interceptors) { + when (val interceptedResult = interceptor.backward(navigationContext)) { + is InterceptedResult.Skipped -> continue + is InterceptedResult.Success -> { + if (interceptedResult.consumed) return true + } + is InterceptedResult.Failure -> { + notifier?.backwardFailure(interceptedResult) + if (interceptedResult.consumed) return false + } + is InterceptedResult.Rewrite -> { + return rewriteBooleanResult(interceptedResult) + } + } + } + val eventNavigationContext = InterceptingNavigationContext(peekNavStack()) + eventListeners.forEach { it.backward(eventNavigationContext) } + return delegate.backward() + } + /** Notifies of [NavigationInterceptor] failures. Useful for logging or analytics. */ @Immutable public interface FailureNotifier { @@ -189,29 +255,55 @@ public class InterceptingNavigator( * Notifies of a [InterceptedResult.Failure] from a [NavigationInterceptor] during a * [NavigationInterceptor.goTo]. */ - public fun goToFailure(interceptedResult: InterceptedResult.Failure) + public fun goToFailure(interceptedResult: InterceptedResult.Failure) {} /** * Notifies of a [InterceptedResult.Failure] from a [NavigationInterceptor] during a * [NavigationInterceptor.pop]. */ - public fun popFailure(interceptedResult: InterceptedResult.Failure) + public fun popFailure(interceptedResult: InterceptedResult.Failure) {} + + /** + * Notifies of a [InterceptedResult.Failure] from a [NavigationInterceptor] during a + * [NavigationInterceptor.forward]. + */ + public fun forwardFailure(interceptedResult: InterceptedResult.Failure) {} + + /** + * Notifies of a [InterceptedResult.Failure] from a [NavigationInterceptor] during a + * [NavigationInterceptor.backward]. + */ + public fun backwardFailure(interceptedResult: InterceptedResult.Failure) {} /** * Notifies of a [InterceptedResult.Failure] from a [NavigationInterceptor] during a * [NavigationInterceptor.resetRoot]. */ - public fun rootResetFailure(interceptedResult: InterceptedResult.Failure) + public fun rootResetFailure(interceptedResult: InterceptedResult.Failure) {} } } -private class InterceptingNavigationContext( - private val interceptingNavigator: InterceptingNavigator -) : NavigationContext { +private fun InterceptingNavigator.rewriteBooleanResult( + interceptedResult: InterceptedResult.Rewrite +): Boolean { + when (val event = interceptedResult.navEvent) { + is NavEvent.GoTo -> return goTo(event.screen) + is NavEvent.Backward -> return backward() + is NavEvent.Forward -> return forward() + is NavEvent.Pop -> pop(event.result) + is NavEvent.ResetRoot -> resetRoot(event.newRoot, event.options) + } + return true +} + +private class InterceptingNavigationContext(private val navStackList: NavStackList?) : + NavigationContext { - override fun peek(): Screen? = interceptingNavigator.peek() + override fun peek() = navStackList?.current - override fun peekBackStack(): List = interceptingNavigator.peekBackStack() + override fun peekBackStack() = navStackList?.backward?.toList().orEmpty() + + override fun peekNavStack() = navStackList } /** A SideEffect that notifies the [NavigationEventListener] when the backstack changes. */ @@ -221,9 +313,13 @@ private fun BackStackChangedEffect( eventListeners: List, ) { // Key using the screens as it'll be the same through rotation, as the record key will change. - val screens = navigator.peekBackStack() - rememberRetained(screens) { - val navigationContext = InterceptingNavigationContext(navigator) - eventListeners.forEach { it.onBackStackChanged(screens, navigationContext) } + val navStack = navigator.peekNavStack() + rememberRetained(navStack) { + val backStack = navStack?.backward?.toList().orEmpty() + val navigationContext = InterceptingNavigationContext(navStack) + eventListeners.forEach { + it.onBackStackChanged(backStack, navigationContext) + it.onNavStackChanged(navStack, navigationContext) + } } } diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/LoggingNavigationEventListener.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/LoggingNavigationEventListener.kt index a2898dda44..dcef6a00a2 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/LoggingNavigationEventListener.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/LoggingNavigationEventListener.kt @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuitx.navigation.intercepting +import com.slack.circuit.runtime.NavStackList +import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen @@ -13,6 +15,16 @@ public class LoggingNavigationEventListener(private val logger: NavigationLogger logger.log("Backstack changed ${backStack.joinToString { it.loggingName() ?: "" }}") } + override fun onNavStackChanged( + navStack: NavStackList?, + navigationContext: NavigationContext, + ) { + val forwardStack = navStack?.forward?.reversed()?.joinToString { it.loggingName() ?: "" } ?: "" + val backwardStack = navStack?.backward?.joinToString { it.loggingName() ?: "" } ?: "" + val currentScreen = navStack?.current?.loggingName() ?: "" + logger.log("NavStack changed [$forwardStack] $currentScreen [$backwardStack]") + } + override fun goTo(screen: Screen, navigationContext: NavigationContext) { logger.log("goTo ${screen.loggingName()}") } @@ -21,6 +33,26 @@ public class LoggingNavigationEventListener(private val logger: NavigationLogger // Logs the screen that was popped. logger.log("pop ${navigationContext.peekBackStack()?.firstOrNull()?.loggingName() ?: ""}") } + + override fun forward(navigationContext: NavigationContext) { + val screen = navigationContext.peekNavStack()?.forward?.firstOrNull()?.loggingName() ?: "" + // Logs the screen that was navigated to. + logger.log("forward $screen") + } + + override fun backward(navigationContext: NavigationContext) { + val screen = navigationContext.peekNavStack()?.backward?.firstOrNull()?.loggingName() ?: "" + // Logs the screen that was navigated to. + logger.log("backward $screen") + } + + override fun resetRoot( + newRoot: Screen, + options: Navigator.StateOptions, + navigationContext: NavigationContext, + ) { + logger.log("resetRoot ${newRoot.loggingName()} with options $options") + } } private fun Screen.loggingName() = this::class.simpleName diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/LoggingNavigatorFailureNotifier.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/LoggingNavigatorFailureNotifier.kt index 383f8b413a..297696972b 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/LoggingNavigatorFailureNotifier.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/LoggingNavigatorFailureNotifier.kt @@ -19,4 +19,12 @@ public class LoggingNavigatorFailureNotifier(private val logger: NavigationLogge override fun rootResetFailure(interceptedResult: InterceptedResult.Failure) { logger.log("rootResetFailure: $interceptedResult") } + + override fun forwardFailure(interceptedResult: InterceptedResult.Failure) { + logger.log("forwardFailure: $interceptedResult") + } + + override fun backwardFailure(interceptedResult: InterceptedResult.Failure) { + logger.log("backwardFailure: $interceptedResult") + } } diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationContext.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationContext.kt index 7612a25a40..ac161e6b96 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationContext.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationContext.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuitx.navigation.intercepting +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.screen.Screen /** @@ -20,6 +21,12 @@ public interface NavigationContext { /** Returns the full navigation backstack, or null if the backstack is unavailable. */ public fun peekBackStack(): List? + + /** + * Returns a snapshot of the current navigation stack with position tracking, or null if + * unavailable. + */ + public fun peekNavStack(): NavStackList? } /** A no-op [NavigationContext] implementation with no navigation state. */ @@ -27,4 +34,6 @@ public object NoOpNavigationContext : NavigationContext { override fun peek(): Screen? = null override fun peekBackStack(): List? = null + + override fun peekNavStack(): NavStackList? = null } diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationEventListener.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationEventListener.kt index 44e6a5e514..fbf8d7bd27 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationEventListener.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationEventListener.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuitx.navigation.intercepting +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.Navigator.StateOptions import com.slack.circuit.runtime.screen.PopResult @@ -15,6 +16,7 @@ public interface NavigationEventListener { * following operation that mutates the back stack. * * @param backStack The state of the back stack after the change. + * @param navigationContext The navigation context after the change. */ public fun onBackStackChanged( backStack: List, @@ -22,22 +24,35 @@ public interface NavigationEventListener { ) {} /** - * Called when the [InterceptingNavigator] goes to the [screen]. + * Called when a nav stack has changed. Will be called with the initial state and any other + * following operation that mutates the back stack. + * + * @param navStack The state of the nav stack after the change. + * @param navigationContext The navigation context after the change. + */ + public fun onNavStackChanged( + navStack: NavStackList?, + navigationContext: NavigationContext = NoOpNavigationContext, + ) {} + + /** + * Called before the [InterceptingNavigator] goes to the [screen]. * * This is not called if navigation was intercepted. * * @param screen The screen that was navigated to. + * @param navigationContext The navigation context before the operation was called. * @see InterceptingNavigator.goTo */ public fun goTo(screen: Screen, navigationContext: NavigationContext = NoOpNavigationContext) {} /** - * Called when the [InterceptingNavigator] pops the back stack. + * Called before the [InterceptingNavigator] pops the back stack. * * This is not called if navigation was intercepted. * - * @param peekBackStack The state of the back stack before the pop operation. * @param result The optional pop result passed to [Navigator.pop]. + * @param navigationContext The navigation context before the operation was called. * @see InterceptingNavigator.pop */ public fun pop( @@ -46,13 +61,33 @@ public interface NavigationEventListener { ) {} /** - * Called when the [InterceptingNavigator] resets the back stack to [newRoot]. + * Called before the [InterceptingNavigator] moves forward in navigation history. + * + * This is not called if navigation was intercepted. + * + * @param navigationContext The navigation context before the operation was called. + * @see InterceptingNavigator.forward + */ + public fun forward(navigationContext: NavigationContext = NoOpNavigationContext) {} + + /** + * Called before the [InterceptingNavigator] moves backward in navigation history. + * + * This is not called if navigation was intercepted. + * + * @param navigationContext The navigation context before the operation was called. + * @see InterceptingNavigator.backward + */ + public fun backward(navigationContext: NavigationContext = NoOpNavigationContext) {} + + /** + * Called before the [InterceptingNavigator] resets the back stack to [newRoot]. * * This is not called if navigation was intercepted. * - * @param peekBackStack The state of the back stack before the [resetRoot] operation. * @param newRoot The new root screen that replaces the entire back stack. * @param options State options to apply when resetting the root. + * @param navigationContext The navigation context before the operation was called. * @see InterceptingNavigator.resetRoot */ public fun resetRoot( diff --git a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationInterceptor.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationInterceptor.kt index c2fab00874..c7254549bc 100644 --- a/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationInterceptor.kt +++ b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/NavigationInterceptor.kt @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuitx.navigation.intercepting -import com.slack.circuit.runtime.Navigator +import com.slack.circuit.foundation.NavEvent import com.slack.circuit.runtime.Navigator.StateOptions import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen @@ -15,28 +15,45 @@ import com.slack.circuit.runtime.screen.Screen public interface NavigationInterceptor { /** - * Navigates to the [screen], returning a [InterceptedGoToResult] for the navigation. + * Navigates to the [screen], returning a [InterceptedResult] for the navigation. * * By default this will skip intercepting the navigation and return [Skipped]. */ public fun goTo( screen: Screen, navigationContext: NavigationContext = NoOpNavigationContext, - ): InterceptedGoToResult = Skipped + ): InterceptedResult = Skipped /** - * Navigates back in the back stack, returning a [InterceptedPopResult] for the navigation. + * Navigates back in the back stack, returning a [InterceptedResult] for the navigation. * * By default this will skip intercepting the navigation and return [Skipped]. */ public fun pop( result: PopResult?, navigationContext: NavigationContext = NoOpNavigationContext, - ): InterceptedPopResult = Skipped + ): InterceptedResult = Skipped /** - * Resets the back stack to the [newRoot], returning a [InterceptedResetRootResult] for the - * navigation. + * Moves forward in navigation history, returning a [InterceptedResult] for the navigation. + * + * By default this will skip intercepting the navigation and return [Skipped]. + */ + public fun forward( + navigationContext: NavigationContext = NoOpNavigationContext + ): InterceptedResult = Skipped + + /** + * Moves backward in navigation history, returning a [InterceptedResult] for the navigation. + * + * By default this will skip intercepting the navigation and return [Skipped]. + */ + public fun backward( + navigationContext: NavigationContext = NoOpNavigationContext + ): InterceptedResult = Skipped + + /** + * Resets the back stack to the [newRoot], returning a [InterceptedResult] for the navigation. * * By default this will skip intercepting the navigation and return [Skipped]. */ @@ -44,7 +61,7 @@ public interface NavigationInterceptor { newRoot: Screen, options: StateOptions, navigationContext: NavigationContext = NoOpNavigationContext, - ): InterceptedResetRootResult = Skipped + ): InterceptedResult = Skipped public companion object { /** @@ -60,25 +77,15 @@ public interface NavigationInterceptor { } } -/** The result of [NavigationInterceptor.goTo] being intercepted. */ -public sealed interface InterceptedGoToResult { - /** The [NavigationInterceptor] intercepted and rewrote the navigation destination. */ - public data class Rewrite(val screen: Screen) : InterceptedGoToResult -} - -/** The result of [NavigationInterceptor.resetRoot] being intercepted. */ -public sealed interface InterceptedResetRootResult { - /** The [NavigationInterceptor] intercepted and rewrote the new root screen. */ - public data class Rewrite(val screen: Screen, val stateOptions: StateOptions) : - InterceptedResetRootResult -} - -/** The result of [NavigationInterceptor.pop] being intercepted. */ -public sealed interface InterceptedPopResult +/** + * The result of the [NavigationInterceptor] intercepting navigation operations. + * + * Common base for all intercepted navigation results. + */ +public sealed interface InterceptedResult { -/** The result of the [NavigationInterceptor] intercepting [Navigator.goTo] or [Navigator.pop]. */ -public sealed interface InterceptedResult : - InterceptedGoToResult, InterceptedPopResult, InterceptedResetRootResult { + /** The [NavigationInterceptor] intercepted and rewrote the navigation destination. */ + public data class Rewrite(val navEvent: NavEvent) : InterceptedResult /** The [NavigationInterceptor] did not intercept the interaction. */ public data object Skipped : InterceptedResult @@ -97,4 +104,14 @@ public sealed interface InterceptedResult : */ public data class Failure(val consumed: Boolean, val reason: Throwable? = null) : InterceptedResult + + public companion object { + public fun Rewrite(screen: Screen): InterceptedResult = Rewrite(NavEvent.GoTo(screen)) + } } + +public typealias InterceptedGoToResult = InterceptedResult + +public typealias InterceptedPopResult = InterceptedResult + +public typealias InterceptedResetRootResult = InterceptedResult diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt index c960154129..e3bfc91353 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -48,12 +48,15 @@ import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.ExperimentalCircuitApi import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.runtime.ui.Ui import com.slack.circuit.runtime.ui.ui import com.slack.circuit.sharedelements.SharedElementTransitionLayout +import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory import com.slack.circuitx.navigation.intercepting.InterceptedGoToResult import com.slack.circuitx.navigation.intercepting.InterceptedResetRootResult +import com.slack.circuitx.navigation.intercepting.InterceptedResult import com.slack.circuitx.navigation.intercepting.NavigationContext import com.slack.circuitx.navigation.intercepting.NavigationInterceptor import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.Skipped @@ -141,6 +144,25 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> } } +private object ForwardHistoryInterceptor : NavigationInterceptor { + + override fun goTo(screen: Screen, navigationContext: NavigationContext): InterceptedResult { + val navStack = navigationContext.peekNavStack() + // If the next screen is the same as the screen we're going to just go forward to it. + return if (navStack?.forward?.firstOrNull() == screen) { + InterceptedResult.Rewrite(NavEvent.Forward) + } else Skipped + } + + override fun pop(result: PopResult?, navigationContext: NavigationContext): InterceptedResult { + val navStack = navigationContext.peekNavStack() + // Single root backstack, so keep the current screen for quick return. + return if (result == null && navStack?.backward?.singleOrNull() != null) { + InterceptedResult.Rewrite(NavEvent.Backward) // todo What should happen to pop result? + } else Skipped + } +} + @OptIn( ExperimentalCircuitApi::class, ExperimentalSharedTransitionApi::class, @@ -150,7 +172,7 @@ private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElementTransitionLayout { val eventSink = rememberUpdatedState(state.eventSink) val navStack = rememberSaveableNavStack(state.rootScreen) - val contentInterceptor = remember { ContentInterceptor(eventSink) } + val interceptors = remember { listOf(ContentInterceptor(eventSink), ForwardHistoryInterceptor) } val contentNavigator = rememberCircuitNavigator(navStack, onRootPop = {}, enableBackHandler = true) @@ -158,7 +180,7 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen rememberInterceptingNavigator( navStack = navStack, navigator = contentNavigator, - interceptors = listOf(contentInterceptor), + interceptors = interceptors, ) Scaffold( modifier = modifier.testTag(ContentTags.TAG_SCAFFOLD).fillMaxSize(), @@ -174,6 +196,8 @@ fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElemen navigator = interceptingNavigator, navStack = navStack, modifier = Modifier.padding(innerPadding).fillMaxSize(), + decoratorFactory = GestureNavigationDecorationFactory(), + // decoration = // remember(circuit.animatedScreenTransforms, circuit.animatedNavDecoratorFactory) { // AdaptiveListDetailNavDecoration( @@ -202,7 +226,7 @@ private fun BottomTabRow( .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Bottom)), ) { tabs.forEach { tab -> - val selected = tab == rootScreen + val selected = tab === rootScreen Text( text = tab.label, color = if (selected) MaterialTheme.colorScheme.onSecondary else Color.Unspecified, diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt index bc15cbe03b..c7438c19cb 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/TabScreen.kt @@ -23,16 +23,18 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.slack.circuit.foundation.DelicateCircuitFoundationApi -import com.slack.circuit.foundation.LocalNavStack import com.slack.circuit.internal.runtime.Parcelize import com.slack.circuit.runtime.CircuitContext import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.NavStackList import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen @@ -76,8 +78,7 @@ object TabScreenCircuit { data class State( val label: String, - val canGoBack: Boolean, - val canGoForward: Boolean, + val navStack: NavStackList?, val hasDetails: Boolean, val eventSink: (Event) -> Unit, ) : CircuitUiState @@ -97,11 +98,17 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat Presenter { @Composable override fun present(): TabScreenCircuit.State { + val peeked = navigator.peek() val navStack = navigator.peekNavStack() + SideEffect { + val forwardStack = navStack?.forward?.reversed()?.joinToString { it.loggingName() ?: "" } ?: "" + val backwardStack = navStack?.backward?.joinToString { it.loggingName() ?: "" } ?: "" + val currentScreen = navStack?.current?.loggingName() ?: "$peeked" + println("TabPresenter(${screen.loggingName()}) changed [$forwardStack] $currentScreen [$backwardStack]") + } return TabScreenCircuit.State( label = screen.label, - canGoBack = navStack?.backward?.firstOrNull() != null, - canGoForward = navStack?.forward?.firstOrNull() != null, + navStack = navStack, hasDetails = screen !is TabScreen.Screen2, ) { event -> when (event) { @@ -132,18 +139,17 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat @Composable fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = Modifier) = SharedElementTransitionScope { - val navStack = LocalNavStack.current?.snapshot() Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Row(modifier = Modifier.align(Alignment.CenterStart)) { IconButton( - enabled = state.canGoBack, + enabled = state.navStack?.backward?.any() == true, onClick = { state.eventSink(TabScreenCircuit.Event.Backward) }, ) { Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") } IconButton( - enabled = state.canGoForward, + enabled = state.navStack?.forward?.any() == true, onClick = { state.eventSink(TabScreenCircuit.Event.Forward) }, ) { Icon(Icons.AutoMirrored.Default.ArrowForward, contentDescription = "Forward") @@ -176,10 +182,12 @@ fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = state.eventSink(TabScreenCircuit.Event.Next) } ) { - navStack?.let { navStack -> + state.navStack?.let { navStack -> + val current = navStack.current + val top = current === screen itemsIndexed(navStack.toList()) { i, item -> Text( - text = "$i: ${item.screen} ${if(navStack.current == item) "(active)" else ""}", + text = "$i: $item ${if(current === item) "(active)" else "$top"}", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), ) @@ -198,3 +206,5 @@ class TabUiFactory(private val tabClass: KClass) : Ui.Factory { } } } + +private fun Screen.loggingName() = this::class.simpleName From defe8a8e5b5f66b64b36274c2a95dde99bb01ea0 Mon Sep 17 00:00:00 2001 From: Josh Stagg Date: Fri, 21 Nov 2025 16:09:24 -0800 Subject: [PATCH 44/44] Customizable LocalRecordLifecycle --- .../foundation/NavigableCircuitContent.kt | 19 ++++++++++----- .../circuit/foundation/RecordLifecycle.kt | 8 ++++++- .../AdaptiveListDetailNavDecoration.kt | 24 ++++++++++++++++--- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 32c546805a..8083897ad2 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -262,7 +262,10 @@ public fun NavigableCircuitContent( } } - CompositionLocalProvider(LocalRetainedStateRegistry provides outerRegistry) { + CompositionLocalProvider( + LocalRetainedStateRegistry provides outerRegistry, + LocalRecordLifecycle provides UnsetRecordLifecycle, // Set a no-op to check for later + ) { val saveableStateHolder = rememberSaveableStateHolder() val retainedStateHolder = rememberRetainedStateHolder() val contentProviderState = @@ -473,15 +476,19 @@ public class ContentProviderState( @OptIn(ExperimentalCircuitApi::class) private fun createRecordContent(onActive: () -> Unit, onDispose: () -> Unit) = movableContentOf> { record, contentProviderState -> - val lifecycle = remember { MutableRecordLifecycle() } - // todo Allow this to be customized by the decoration for multiple active records. - val currentRecord = contentProviderState.lastNavigator.navStack.currentRecord - SideEffect { lifecycle.isActive = currentRecord == record } + val outerLifecycle = LocalRecordLifecycle.current.takeUnless { it === UnsetRecordLifecycle } + val recordLifecycle = + outerLifecycle + ?: remember { MutableRecordLifecycle() } + .apply { + val currentRecord = contentProviderState.lastNavigator.navStack.currentRecord + SideEffect { isActive = currentRecord == record } + } contentProviderState.saveableStateHolder.SaveableStateProvider(record.registryKey) { // Provides a RetainedStateRegistry that is maintained independently for each record while // the record exists in the back stack. contentProviderState.retainedStateHolder.RetainedStateProvider(record.registryKey) { - CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { + CompositionLocalProvider(LocalRecordLifecycle provides recordLifecycle) { CircuitContent( screen = record.screen, navigator = contentProviderState.lastNavigator, diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt index 064fa36626..5c7a7fda0f 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.foundation +import androidx.compose.runtime.CompositionLocalAccessorScope import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -38,7 +39,12 @@ public val LocalRecordLifecycle: ProvidableCompositionLocal = staticRecordLifecycle(true) } -private fun staticRecordLifecycle(isActive: Boolean): RecordLifecycle = +public fun staticRecordLifecycle(isActive: Boolean): RecordLifecycle = object : RecordLifecycle { override val isActive: Boolean = isActive } + + +internal object UnsetRecordLifecycle : RecordLifecycle { + override val isActive: Boolean = false +} diff --git a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt index 7fa230465f..743ab96923 100644 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -45,11 +46,13 @@ import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isUnspecified import androidx.window.core.layout.WindowWidthSizeClass import com.slack.circuit.foundation.DelicateCircuitFoundationApi +import com.slack.circuit.foundation.LocalRecordLifecycle import com.slack.circuit.foundation.NavArgument import com.slack.circuit.foundation.NavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecoration import com.slack.circuit.foundation.animation.AnimatedNavDecorator import com.slack.circuit.foundation.animation.AnimatedScreenTransform +import com.slack.circuit.foundation.staticRecordLifecycle import com.slack.circuit.retained.rememberRetained import com.slack.circuit.retained.rememberRetainedStateHolder import com.slack.circuit.runtime.ExperimentalCircuitApi @@ -170,7 +173,6 @@ class AdaptiveListDetailNavDecoration( delegate.DecoratedContent(primaryArgs, navigator, modifier) { primary -> val secondaryArgs = with(secondaryLookupTransition) { currentState[primary] ?: targetState[primary] } - val hasSecondary = secondaryArgs != null val scaffoldValue = if (hasSecondary) PrimarySecondary else Primary val scaffoldState = remember { MutableThreePaneScaffoldState(scaffoldValue) } @@ -199,12 +201,28 @@ class AdaptiveListDetailNavDecoration( scaffoldState = scaffoldState, paneExpansionState = paneExpansionState, paneExpansionDragHandle = listDetailScaffoldStyle.dragHandle, - listPane = { AnimatedPane(modifier = Modifier) { content(primary) } }, + listPane = { + AnimatedPane(modifier = Modifier) { + val isActive = primaryArgs == primary + CompositionLocalProvider( + LocalRecordLifecycle provides staticRecordLifecycle(isActive) + ) { + content(primary) + } + } + }, detailPane = { AnimatedPane { if (hasSecondary) { // Stack multiple with a normal decoration - detailPaneDelegate.DecoratedContent(secondaryArgs, navigator, Modifier, content) + detailPaneDelegate.DecoratedContent(secondaryArgs, navigator, Modifier) { args -> + val isActive = secondaryArgs == args + CompositionLocalProvider( + LocalRecordLifecycle provides staticRecordLifecycle(isActive) + ) { + content(args) + } + } } } },