diff --git a/.gitignore b/.gitignore index 8b50663db..204d911de 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 diff --git a/backstack/build.gradle.kts b/backstack/build.gradle.kts index 25df77acf..587174786 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/dependencies/androidReleaseRuntimeClasspath.txt b/backstack/dependencies/androidReleaseRuntimeClasspath.txt index d19b60e06..184477802 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,7 +68,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/backstack/dependencies/jvmRuntimeClasspath.txt b/backstack/dependencies/jvmRuntimeClasspath.txt index 3eae39e8f..d6c4da460 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 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 6d1caa2b3..2dceb7c96 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 67c5ac8dd..42e7a7fb0 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt @@ -16,144 +16,17 @@ 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.runtime.screen.Screen +import com.slack.circuit.backstack.NavStack.Record /** * 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 - - /** - * 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) - } - } - } - - /** - * 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 - } -} - -/** `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 +public interface BackStack : NavStack, Iterable { -/** Clear any saved state from the [BackStack]. */ -public fun BackStack.clearState() { - Snapshot.withMutableSnapshot { - for (screen in peekState()) { - removeState(screen) - } - } + @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 new file mode 100644 index 000000000..f8efaabde --- /dev/null +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/NavStack.kt @@ -0,0 +1,190 @@ +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 + +/** + * A navigation stack supporting bidirectional navigation with browser-style forward/backward + * traversal. + * + * 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). + * + * Supports multiple independent nav stacks (e.g., bottom nav tabs) via [saveState], [restoreState], + * [peekState], and [removeState]. State is keyed by root screen. + * + * @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, or null if empty. Always the most recently added record. */ + public val topRecord: R? + + /** + * The currently active record, or null if empty. May differ from [topRecord] when navigated + * backward. + */ + public val currentRecord: R? + + /** The bottom-most (oldest) record, or null if empty. Typically the initial root screen. */ + public val rootRecord: R? + + /** + * Adds a screen to the stack. Truncates forward history if not at top. + * + * @return true if added, false otherwise + */ + public fun push(screen: Screen): Boolean + + /** + * Adds a record to the stack. Truncates forward history if not at top. + * + * @return true if added, false otherwise + */ + public fun push(record: R): Boolean + + /** + * Removes and returns the current record, truncating forward history. + * + * @return The removed record, or null if empty + */ + public fun pop(): R? + + /** + * Pops records until one matches the predicate. + * + * @return List of popped records + */ + public fun popUntil(predicate: (R) -> Boolean): List { + return buildList { + while (topRecord?.let(predicate) == false) { + val popped = pop() ?: break + add(popped) + } + } + } + + /** + * Move forward in navigation history towards the [topRecord]. + * + * @return true if moved, false otherwise. + */ + public fun forward(): Boolean + + /** + * Move backward in navigation history towards the [rootRecord]. + * + * @return true if moved, false otherwise. + */ + public fun backward(): Boolean + + /** + * Creates an immutable snapshot of the current stack state. + * + * @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 previously saved state for the given root [screen], replacing the current stack. + * + * @return true if state was restored, false if no saved state found + */ + public fun restoreState(screen: Screen): Boolean + + /** + * Returns list of root screens that have saved state. + * + * @return List of screens with saved state, empty if none. + */ + public fun peekState(): List + + /** + * Removes saved state for the given [screen]. + * + * @return true if state was removed, false otherwise. + */ + public fun removeState(screen: Screen): Boolean + + /** + * Checks if the stack contains the given [record]. + * + * @param includeSaved Whether to also check saved stack states + */ + public fun containsRecord(record: R, includeSaved: Boolean): Boolean + + /** + * Checks if a record with the given [key] is reachable within [depth] pops from current position. + * + * @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 { + /** + * 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 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 + +/** Clears all saved state from the stack. */ +public fun NavStack.clearState() { + Snapshot.withMutableSnapshot { + for (screen in peekState()) { + removeState(screen) + } + } +} 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 a1e1cb6a9..9ee37e279 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/Navigation.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/Navigation.kt deleted file mode 100644 index 40a817a8a..000000000 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/Navigation.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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. - */ -package com.slack.circuit.backstack - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.ui.Modifier -import com.slack.circuit.runtime.screen.Screen - -/** Presentation logic for currently visible routes of a navigable UI. */ -@Stable -public interface NavDecoration { - @Composable - public fun DecoratedContent( - args: List, - modifier: Modifier, - content: @Composable (T) -> Unit, - ) -} - -/** Argument provided to [NavDecoration] that exposes the underlying [Screen]. */ -public interface NavArgument { - public val screen: 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 b33727ba3..9be86aed4 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.NavStackList 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,92 +69,87 @@ 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 currentRecord: Record? + get() = delegate.currentRecord?.let { Record(it) } override val rootRecord: Record? - get() = entryList.lastOrNull() + get() = delegate.rootRecord?.let { Record(it) } - public override fun push(screen: Screen): Boolean { - return push(screen, emptyMap()) - } + override fun forward(): Boolean = false - public fun push(screen: Screen, args: Map): Boolean { - return push(Record(screen, args)) + 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(): NavStackList? { + return delegate.snapshot()?.let { BackStackList(it) } } 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 delegate.isRecordReachable(key, depth, includeSaved) + } + + /** + * A snapshot of a back stack state. Since BackStack always has current at the top, currentIndex + * is always 0. + */ + private data class BackStackList(val other: NavStackList) : + NavStackList { + + 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() } - return false } public data class Record( @@ -171,58 +157,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 new file mode 100644 index 000000000..83e50d191 --- /dev/null +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableNavStack.kt @@ -0,0 +1,343 @@ +package com.slack.circuit.backstack + +import androidx.compose.runtime.Composable +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.SaveableNavStack.Record +import com.slack.circuit.runtime.NavStackList +import com.slack.circuit.runtime.screen.Screen +import kotlin.collections.set +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * 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] 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. + */ +public class SaveableNavStack +internal constructor( + // 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) + // Set current to the new top + currentIndex = 0 + } + + override val size: Int + get() = entryList.size + + override fun iterator(): Iterator = entryList.iterator() + + override val topRecord: Record? + get() = entryList.firstOrNull() + + override val currentRecord: Record? + get() = entryList.getOrNull(currentIndex) + + override val rootRecord: Record? + get() = entryList.lastOrNull() + + override fun push(screen: Screen): Boolean { + return push(Record(screen)) + } + + 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 (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 + repeat(currentIndex) { entryList.removeAt(0) } + } + // Add the new record at the top + entryList.add(0, record) + // Set current to the new top + currentIndex = 0 + true + } else false + } + + 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 + // history) + if (currentIndex > 0) { + // Remove all entries before currentIndex + repeat(currentIndex) { entryList.removeAt(0) } + } + // Set current to the new top + currentIndex = 0 + // Remove the current record + entryList.removeAt(0) + } + } + + 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(): NavStackList? { + return if (entryList.isNotEmpty()) { + SaveableNavStackList(entryList.toList(), currentIndex) + } else null + } + + override fun saveState() { + if (entryList.isNotEmpty()) { + val rootScreen = entryList.last().screen + stateStore[rootScreen] = SaveableNavStackList(entryList.toList(), currentIndex) + } + } + + 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 + } + + 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 { + // 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 snapshots too, iterate through them and check + for (snapshot in stateStore.values) { + if (record in snapshot.entries) return true + } + } + return false + } + + override fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean { + if (depth < 0) return false + // Check in the current entry list + if (isRecordReachable(key, depth, currentIndex, entryList)) { + return true + } + // If includeSaved, check saved stack states too + if (includeSaved && stateStore.isNotEmpty()) { + 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 + } + + 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, + ) + }, + ) + } + } + + internal data class SaveableNavStackList(val entries: List, val currentIndex: Int) : + NavStackList { + + 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 { + with(Record.Saver) { put("entries", value.entries.mapNotNull { save(it) }) } + put("currentIndex", value.currentIndex) + } + }, + restore = { map -> + @Suppress("UNCHECKED_CAST") + SaveableNavStackList( + entries = (map["entries"] as List>).mapNotNull { Record.Saver.restore(it) }, + currentIndex = map["currentIndex"] as Int, + ) + }, + ) + } + } + + internal companion object { + @Suppress("UNCHECKED_CAST") + val Saver = + listSaver>( + save = { value -> + buildList { + // 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(SaveableNavStackList.Saver) { add(value.stateStore.values.map { save(it) }) } + } + }, + restore = { value -> + var currentIndex = -1 + SaveableNavStack().also { navStack -> + 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 { 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 + } + } + } + } + navStack.currentIndex = currentIndex + } + }, + ) + } +} 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 000000000..1b50b7b69 --- /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_push_records() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + assertTrue(navStack.push(TestScreen.ScreenA)) + assertTrue(navStack.push(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.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) + 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_push_truncates_forward_history() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(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.push(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_pop() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(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.pop() + 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.push(TestScreen.ScreenA) + navStack.push(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.pop() + } + assertEquals(0, navStack.size) + + // Add a different root + 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.pop() + } + 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.push(TestScreen.ScreenA) + navStack.push(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.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + + // Move back and save state + navStack.move(NavStack.Direction.Backward) + navStack.saveState() + + // Add different screens + while (navStack.size > 0) { + navStack.pop() + } + navStack.push(TestScreen.RootBeta) + navStack.push(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.push(TestScreen.ScreenA)) + assertTrue(navStack.push(TestScreen.ScreenB)) + assertFalse(navStack.push(TestScreen.ScreenB)) + } + + @Test + fun test_iteration() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(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.push(recordA) + navStack.push(TestScreen.ScreenB) + + assertTrue(navStack.containsRecord(recordA, includeSaved = false)) + + // Save state and clear + navStack.saveState() + while (navStack.size > 0) { + navStack.pop() + } + + 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.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)) + assertFalse(navStack.isRecordReachable(recordA.key, depth = 1, includeSaved = false)) + } + + @Test + fun test_snapshot_captures_current_state() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(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.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + + // Create snapshot + val snapshot = navStack.snapshot() + val originalSize = snapshot.entries.size + val originalIndex = snapshot.currentIndex + + // Modify the nav stack + navStack.push(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.pop() + + val snapshot = navStack.snapshot() + assertTrue(snapshot.entries.isEmpty()) + assertEquals(0, snapshot.currentIndex) + } + + @Test + fun test_snapshot_iteration() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + navStack.push(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.push(TestScreen.ScreenA) + navStack.push(TestScreen.ScreenB) + navStack.push(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.push(TestScreen.ScreenA) + navStack.push(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_pop_at_boundaries() { + val navStack = SaveableNavStack(TestScreen.RootAlpha) + navStack.push(TestScreen.ScreenA) + + // At top, remove forward + val removed = navStack.pop() + assertEquals(TestScreen.ScreenA, removed?.screen) + assertEquals(TestScreen.RootAlpha, navStack.currentRecord?.screen) + + // At root after removal + val removed2 = navStack.pop() + 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/build.gradle.kts b/build.gradle.kts index d5d3dfd5f..cc002e4e4 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() @@ -124,11 +123,10 @@ 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/circuit-foundation/build.gradle.kts b/circuit-foundation/build.gradle.kts index 42d6ccab8..721d8520e 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/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-foundation/dependencies/androidReleaseRuntimeClasspath.txt index f15e93d62..3997e6764 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,13 @@ 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.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 androidx.savedstate:savedstate-compose-android @@ -69,7 +83,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 +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 @@ -87,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 ad0adb782..31b1dc566 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,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-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt b/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt index 26699e531..1809a0b04 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 c2472680c..2428e5e35 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,8 +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 @@ -13,18 +13,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 [NavStack] 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 44c07f1b2..66aeb63a0 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 01f5c7c62..e6c205633 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 940770bc4..0ed502f53 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 b3e162c56..9c571437a 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 5b5a61dfe..23565045f 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,12 +6,17 @@ 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 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 @@ -54,7 +59,7 @@ class NavigatorBackHandlerTest { } CircuitCompositionLocals(circuit) { val backStack = rememberSaveableBackStack(TestScreen.ScreenA) - navigator = rememberCircuitNavigator(backStack = backStack) + navigator = rememberCircuitNavigator(navStack = backStack) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } @@ -76,13 +81,15 @@ 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) { - BackHandler(enabled = true) { outerBackCount++ } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + outerBackCount++ + } val backStack = rememberSaveableBackStack(TestScreen.ScreenA) - navigator = rememberCircuitNavigator(backStack = backStack) + navigator = rememberCircuitNavigator(navStack = backStack) NavigableCircuitContent(navigator = navigator, backStack = backStack) } } @@ -104,13 +111,15 @@ 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) { - BackHandler(enabled = true) { outerBackCount++ } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + 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 b4dc512d2..cb3856b28 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 2921b65e1..e7b2525ce 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 9b8e92342..ebadda344 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 70da6492a..d0445309d 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 64167035c..b7102e809 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 @@ -28,7 +29,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 +52,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) } @@ -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 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 f3f82d048..f5db834b6 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,12 +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.NavDecoration +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 @@ -106,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) @@ -189,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() { @@ -291,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) @@ -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 49fd1d70a..0a09f37a2 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,8 +12,10 @@ 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.navStackListOf import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen @@ -49,6 +51,16 @@ public fun CircuitContent( return true } + override fun forward(): Boolean { + onNavEvent(NavEvent.Forward) + return true + } + + override fun backward(): Boolean { + onNavEvent(NavEvent.Backward) + return true + } + override fun resetRoot(newRoot: Screen, options: StateOptions): List { onNavEvent(NavEvent.ResetRoot(newRoot, options)) return emptyList() @@ -62,6 +74,8 @@ public fun CircuitContent( override fun peek(): Screen = screen override fun peekBackStack(): List = 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 new file mode 100644 index 000000000..8318c787e --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavDecoration.kt @@ -0,0 +1,28 @@ +// Copyright (C) 2022 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +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.NavStackList +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.screen.Screen + +/** Presentation logic for currently visible routes of a navigable UI. */ +@Stable +public interface NavDecoration { + @Composable + public fun DecoratedContent( + args: NavStackList, + navigator: Navigator, + modifier: Modifier, + content: @Composable (T) -> Unit, + ) +} + +/** 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/NavEvent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt index 4506e52e0..fc69e9572 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 818dc5d1e..8083897ad 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,14 +26,15 @@ 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.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 @@ -42,12 +43,11 @@ 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.NavArgument -import com.slack.circuit.backstack.NavDecoration +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 @@ -61,9 +61,12 @@ 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 /** * A composable that provides the core navigation and state management for Circuit-based navigation @@ -87,9 +90,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) * } * ``` * @@ -101,7 +104,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 @@ -120,6 +123,30 @@ import com.slack.circuit.runtime.screen.Screen * @see rememberSaveableBackStack for creating a backstack * @see rememberCircuitNavigator for creating a navigator */ +@OptIn(ExperimentalCircuitApi::class) +@Composable +public fun NavigableCircuitContent( + navigator: Navigator, + navStack: NavStack, + 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, navStack), + modifier = modifier, + circuit = circuit, + providedValues = providedValues, + decoration = decoration, + decoratorFactory = decoratorFactory, + unavailableRoute = unavailableRoute, + ) +} + @OptIn(ExperimentalCircuitApi::class) @Composable public fun NavigableCircuitContent( @@ -153,15 +180,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) * } * ``` @@ -189,7 +216,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 @@ -235,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 = @@ -253,29 +283,30 @@ 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) - 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() + providedValuesForNavStack(navigator.navStack, circuit.backStackLocalProviders) + 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 localBackstack = contentProviderState.lastNavigator.backStack - val localResultHandler = contentProviderState.lastNavigator.answeringResultHandler - CompositionLocalProvider( - LocalBackStack provides localBackstack, - LocalAnsweringResultHandler provides localResultHandler, - *providedLocals, - ) { - provider.content(record, contentProviderState) } } } @@ -290,7 +321,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 @@ -303,11 +334,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) } } @@ -315,7 +346,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? { @@ -323,8 +354,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) } @@ -343,6 +374,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 @@ -364,50 +398,48 @@ public class RecordContentProvider( @ExperimentalCircuitApi @Composable private fun buildCircuitContentProviders( - backStack: BackStack -): List> { + navStack: NavStack +): NavStackList>? { + val composer = currentComposer 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) - 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) + val snapshot = navStack.snapshot() + val navList = + 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) }, + ), + ) } - 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 - } - .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) }, - ), - ) } + + // 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 } ?: emptySet() + 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 @@ -444,31 +476,37 @@ public class ContentProviderState( @OptIn(ExperimentalCircuitApi::class) private fun createRecordContent(onActive: () -> Unit, onDispose: () -> Unit) = movableContentOf> { record, contentProviderState -> - with(contentProviderState) { - val lifecycle = - remember { MutableRecordLifecycle() } - .apply { isActive = lastNavigator.backStack.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 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 recordLifecycle) { + 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 { - if (!lastNavigator.backStack.containsRecord(record, includeSaved = true)) { + } + // 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}") } } } @@ -476,7 +514,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}") + } } } @@ -496,7 +538,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() } /** @@ -567,16 +611,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)) } @@ -588,7 +634,9 @@ public object NavigatorDefaults { // the transitionSpec recomposing. // The states are available as `targetState` and `initialState`. return when (animatedNavEvent) { + AnimatedNavEvent.Forward, AnimatedNavEvent.GoTo -> forward + AnimatedNavEvent.Backward, AnimatedNavEvent.Pop -> backward AnimatedNavEvent.RootReset -> fadeIn() togetherWith fadeOut() }.using( @@ -603,7 +651,7 @@ public object NavigatorDefaults { targetState: DefaultAnimatedState, innerContent: @Composable (T) -> Unit, ) { - innerContent(targetState.args.first()) + innerContent(targetState.navStack.current) } } @@ -611,17 +659,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 @@ -629,7 +678,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 6a37fd67d..f4df9e20f 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,40 +12,43 @@ 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 com.slack.circuit.backstack.BackStack -import com.slack.circuit.backstack.BackStack.Record +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.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 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 [backStack] instance changes. + * A new [Navigator] will be created if the [navStack] instance changes. * - * @param backStack The backing [BackStack] to navigate. - * @param onRootPop Invoked when the backstack is at root (size 1) and the user presses the back - * button. + * @param navStack The backing [NavStack] to navigate. + * @param onRootPop Invoked when the backstack [NavStack.isAtRoot] and a [Navigator.pop] is called. * @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]. + * handling back events with a [NavigationBackHandler]. * - * @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 +58,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 { @@ -74,13 +77,14 @@ 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 && !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 (backStack.size > 1) { + if (!navStack.isAtRoot) { navigator.pop() } else { hasPendingRootPop = true @@ -100,57 +104,73 @@ 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: BackStack, + navStack: NavStack, onRootPop: (result: PopResult?) -> Unit, -): Navigator = NavigatorImpl(backStack, onRootPop) +): Navigator = NavigatorImpl(navStack, 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.push(screen) + } + + override fun forward(): Boolean { + return navStack.forward() + } + + override fun backward(): Boolean { + return navStack.backward() } override fun pop(result: PopResult?): Screen? { - if (backStack.isAtRoot) { + if (navStack.isAtRoot) { onRootPop(result) return null } - return backStack.pop()?.screen + return navStack.pop()?.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()?.backward?.mapToImmutableList { it.screen } ?: emptyList() + + 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) 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.pop()?.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.push(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 +183,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/RecordLifecycle.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt index 064fa3662..5c7a7fda0 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/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt index 5112fd063..5f5dd4fe1 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 4327d9adc..9f845bd80 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/animation/AnimatedNavDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavDecoration.kt index e8eb47eaf..ba48b07f8 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,11 @@ 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.NavStackList +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 @@ -78,7 +80,7 @@ import kotlin.reflect.KClass * ```kotlin * NavigableCircuitContent( * navigator = navigator, - * backStack = backStack, + * navStack = navStack, * decoratorFactory = remember { CustomAnimatedNavDecoratorFactory() }, * ) * ``` @@ -97,21 +99,28 @@ public class AnimatedNavDecoration( @Composable public override fun DecoratedContent( - args: List, + args: NavStackList, + 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) + // 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) } + } } } } @@ -123,17 +132,46 @@ 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.backward + val initialForwardStack = initialStack.forward + + val targetBackStack = targetStack.backward + val targetForwardStack = targetStack.forward + 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 { + // Root reset happened. + initialStack.root != targetStack.root -> { + AnimatedNavEvent.RootReset + } + // Target screen has not changed, don't show an animation. + previous == current -> { + return@spec EnterTransition.None togetherWith ExitTransition.None + } + // 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 + } + // 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 + } } val baseTransform = transitionSpec(animatedNavEvent) @@ -148,9 +186,11 @@ 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 422a0ed96..62e52b0e9 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,9 +9,10 @@ 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.runtime.screen.Screen +import com.slack.circuit.foundation.NavArgument +import com.slack.circuit.foundation.NavDecoration +import com.slack.circuit.runtime.NavStackList +import com.slack.circuit.runtime.Navigator /** * [AnimatedNavDecorator] is used by [AnimatedNavDecoration] as a way to provide hooks into its @@ -94,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( @@ -115,25 +116,13 @@ public interface AnimatedNavDecorator { @Stable public interface Factory { - public fun create(): AnimatedNavDecorator + public fun create(navigator: Navigator): 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 - /** 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/animation/AnimatedNavEvent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/animation/AnimatedNavEvent.kt index a90214f66..564f17cff 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 @@ -13,4 +13,6 @@ public enum class AnimatedNavEvent { GoTo, Pop, RootReset, + Backward, + Forward, } 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 862c8c7de..f8703c4da 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/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 000000000..c2dd91230 --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/PredictiveBackHandler.kt @@ -0,0 +1,215 @@ +// 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.PredictiveNavProgress.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 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) { + PredictiveNavEventHandler(isBackEnabled, isForwardEnabled, scope, dispatcher) + } + SideEffect { + handler.isBackEnabled = isBackEnabled + handler.isForwardEnabled = isForwardEnabled + handler.onProgress = onProgress + handler.onCancelled = onCancelled + handler.onCompleted = onCompleted + } +} + +public enum class PredictiveNavDirection { + Back, + Forward, +} + +@OptIn(InternalCircuitApi::class) +private class PredictiveNavEventHandler( + isBackEnabled: Boolean, + isForwardEnabled: Boolean, + private val scope: CoroutineScope, + private val dispatcher: NavigationEventDispatcher, +) : + RememberObserver, + NavigationEventHandler( + initialInfo = NavigationEventInfo.None, + isBackEnabled = isBackEnabled, + isForwardEnabled = isForwardEnabled, + ) { + + var current: PredictiveNavProgress? = null + + var onProgress: suspend (PredictiveNavDirection, Float, Offset) -> Unit = { _, _, _ -> } + var onCancelled: suspend (PredictiveNavDirection) -> Unit = {} + var onCompleted: suspend (PredictiveNavDirection) -> Unit = {} + + override fun onBackStarted(event: NavigationEvent) { + current?.cancel() + current = PredictiveNavProgress(scope) { event -> onEvent(PredictiveNavDirection.Back, 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 + } + current?.send(Event.Progress(progress, offset)) + } + + override fun onBackCancelled() { + current?.send(Event.Canceled) + current = null + } + + override fun onBackCompleted() { + 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.Forward, 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()) + } + current?.send(Event.Completed) + current = null + } + + override fun onRemembered() { + current = null + dispatcher.addHandler(this) + } + + override fun onForgotten() { + current?.cancel() + remove() + } + + override fun onAbandoned() { + onForgotten() + } + + private suspend fun onEvent(direction: PredictiveNavDirection, event: Event) { + try { + val isEnabled = + when (direction) { + PredictiveNavDirection.Back -> isBackEnabled + PredictiveNavDirection.Forward -> isForwardEnabled + } + if (isEnabled) { + when (event) { + is Event.Progress -> onProgress(direction, event.progress, event.offset) + is Event.Completed -> onCompleted(direction) + is Event.Canceled -> onCancelled(direction) + } + } else { + onCancelled(direction) + } + } catch (e: CancellationException) { + onCancelled(direction) + throw e + } + } +} + +@InternalCircuitApi +public class PredictiveNavProgress( + 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/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 eb25318bd..000000000 --- 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() } -} 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 b57d58cc0..f2b87b10f 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 1386606fb..fdc574ba9 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 6ab50cfca..eba206738 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 090c5bf89..e876337c3 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/circuit-overlay/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-overlay/dependencies/androidReleaseRuntimeClasspath.txt index cf8f63ac2..eb7074c38 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,7 +76,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-overlay/dependencies/jvmRuntimeClasspath.txt b/circuit-overlay/dependencies/jvmRuntimeClasspath.txt index e69c1e542..3fbe52703 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 diff --git a/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt index 72783c84c..60d1c2dde 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 d96ac0790..b0423cf0f 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 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 dee2e4a4a..9754c028a 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 } diff --git a/circuit-runtime-ui/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-runtime-ui/dependencies/androidReleaseRuntimeClasspath.txt index 3cba257e5..60d85c84d 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,7 +68,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-runtime-ui/dependencies/jvmRuntimeClasspath.txt b/circuit-runtime-ui/dependencies/jvmRuntimeClasspath.txt index 3eae39e8f..d6c4da460 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 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 000000000..61447d186 --- /dev/null +++ b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/NavStackList.kt @@ -0,0 +1,132 @@ +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 + + /** + * Items between [current] and [top] (exclusive of current). Empty if no forward history exists. + */ + public val forward: Iterable + + /** + * 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 + 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() +} + +/** Default implementation of [NavStackList] backed by lists of items. */ +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() +} + +/** + * 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, +) : 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 81388ae9e..ca2a968b8 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,14 +28,38 @@ 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 + + /** + * 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? + /** * Clear the existing backstack of [screens][Screen] and navigate to [newRoot]. * @@ -108,15 +137,22 @@ 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 + override fun forward(): Boolean = false + + override fun backward(): Boolean = false + override fun pop(result: PopResult?): Screen? = null override fun peek(): Screen? = null override fun peekBackStack(): List = emptyList() + override fun peekNavStack(): NavStackList? = null + override fun resetRoot(newRoot: Screen, options: StateOptions): List = emptyList() } } diff --git a/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt index cf8f63ac2..eb7074c38 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,7 +76,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-shared-elements/dependencies/jvmRuntimeClasspath.txt b/circuit-shared-elements/dependencies/jvmRuntimeClasspath.txt index e69c1e542..3fbe52703 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 diff --git a/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-test/dependencies/androidReleaseRuntimeClasspath.txt index 553b4b4ef..daeedd6f2 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,13 @@ 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.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 androidx.savedstate:savedstate-compose-android @@ -69,7 +83,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 +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 @@ -91,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 b2a68f57e..a80047a9f 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,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/build.gradle.kts b/circuitx/effects/build.gradle.kts index 250ed57f2..eea31fa4e 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/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/effects/dependencies/androidReleaseRuntimeClasspath.txt index 754561025..237056de7 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,13 @@ 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.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 androidx.savedstate:savedstate-compose-android @@ -69,7 +83,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 +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 @@ -87,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 ad0adb782..31b1dc566 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,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/build.gradle.kts b/circuitx/gesture-navigation/build.gradle.kts index 36fdcbbb4..92b944a62 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/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/gesture-navigation/dependencies/androidReleaseRuntimeClasspath.txt index 6a38692b4..5db2c1457 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,13 @@ 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.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 androidx.savedstate:savedstate-compose-android @@ -74,7 +90,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 +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 ad0adb782..31b1dc566 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,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/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index 14b4853da..b10b7d319 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,27 +25,48 @@ 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.sharedelements.SharedElementTransitionScope +import com.slack.circuit.runtime.NavStackList +import com.slack.circuit.runtime.Navigator import kotlin.math.absoluteValue public actual fun GestureNavigationDecorationFactory( fallback: AnimatedNavDecorator.Factory, - onBackInvoked: () -> Unit, + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, ): AnimatedNavDecorator.Factory { return when { - Build.VERSION.SDK_INT >= 34 -> AndroidPredictiveBackNavDecorator.Factory(onBackInvoked) + 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 AndroidPredictiveBackNavDecorator(onBackInvoked: () -> Unit) : - PredictiveBackNavigationDecorator(onBackInvoked) { +internal class AndroidPredictiveNavDecorator( + 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 @@ -57,13 +76,20 @@ internal class AndroidPredictiveBackNavDecorator(onBackInvoked: animatedNavEvent: AnimatedNavEvent ): ContentTransform { return when (animatedNavEvent) { - // adding to back stack + // Adding to back stack + AnimatedNavEvent.Forward, AnimatedNavEvent.GoTo -> { - NavigatorDefaults.forward + if (showForward) { + // Handle all the animation in draggable + EnterTransition.None togetherWith ExitTransition.None + } else { + 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 { @@ -86,22 +112,32 @@ internal class AndroidPredictiveBackNavDecorator(onBackInvoked: ) { Box( Modifier.predictiveBackMotion( - enabled = { showPrevious }, + enabled = { showBackward }, isSeeking = { isSeeking }, shape = MaterialTheme.shapes.extraLarge, - elevation = if (SharedElementTransitionScope.isTransitionActive) 0.dp else 6.dp, + elevation = 6.dp, transition = transition, offset = { swipeOffset }, progress = { seekableTransitionState.fraction }, ) ) { - innerContent(targetState.args.first()) + innerContent(targetState.navStack.current) } } - class Factory(private val onBackInvoked: () -> Unit) : AnimatedNavDecorator.Factory { - override fun create(): AnimatedNavDecorator { - return AndroidPredictiveBackNavDecorator(onBackInvoked = onBackInvoked) + 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( + isForwardEnabled = isForwardEnabled, + isBackEnabled = isBackEnabled, + onForwardInvoked = { onForwardInvoked(navigator, it) }, + onBackInvoked = { onBackInvoked(navigator, it) }, + ) } } } 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 acc6163c5..564961bf6 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 affb2031b..3b41215f1 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 @@ -51,20 +54,22 @@ 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() } + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + navigator.pop() + } } NavigableCircuitContent( 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 172189d58..6ae925cc9 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,12 +55,12 @@ class GestureNavigationCrashTest { setContent { CircuitCompositionLocals(circuit) { val backStack = rememberSaveableBackStack(TestScreen.RootAlpha) - navigator = rememberCircuitNavigator(backStack = backStack) + navigator = rememberCircuitNavigator(navStack = backStack) NavigableCircuitContent( 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/GestureNavTransitionHolder.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt index 05120eb31..68ecbfcae 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,15 +3,15 @@ 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 +import com.slack.circuit.runtime.NavStackList /** * A holder class used by the `AnimatedContent` composables. This enables us to pass through all of * the necessary information as an argument, which is optimal for `AnimatedContent`. */ @Immutable -internal 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/GestureNavigationDecoration.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt index a629622e1..54db8c8a5 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 @@ -15,10 +18,15 @@ 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, + 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/PredictiveBackNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt deleted file mode 100644 index cc12217c3..000000000 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveBackNavigationDecorator.kt +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (C) 2025 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuitx.gesturenavigation - -import androidx.compose.animation.core.SeekableTransitionState -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.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 kotlin.math.abs -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch - -internal abstract class PredictiveBackNavigationDecorator( - private val onBackInvoked: () -> Unit -) : AnimatedNavDecorator> { - - protected lateinit var seekableTransitionState: - SeekableTransitionState> - private set - - protected var showPrevious: Boolean by mutableStateOf(false) - private set - - 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 - - override fun targetState(args: List): GestureNavTransitionHolder { - return GestureNavTransitionHolder(args) - } - - @OptIn(ExperimentalComposeUiApi::class) - @Composable - override fun updateTransition(args: List): Transition> { - val scope = rememberStableCoroutineScope() - val current = remember(args) { targetState(args) } - val previous = - remember(args) { - if (args.size > 1) { - targetState(args.subList(1, args.size)) - } else null - } - - seekableTransitionState = remember { SeekableTransitionState(current) } - - LaunchedEffect(current) { - swipeProgress = 0f - isSeeking = false - seekableTransitionState.animateTo(current) - // After the current state has changed (i.e. any transition has completed), - // clear out any transient state - showPrevious = false - swipeOffset = Offset.Zero - } - - LaunchedEffect(previous, current) { - if (previous != null) { - snapshotFlow { swipeProgress } - .collect { progress -> - if (progress != 0f) { - isSeeking = true - seekableTransitionState.seekTo(fraction = abs(progress), targetState = previous) - } - } - } - } - - 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() }, - ) - } - 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/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt new file mode 100644 index 000000000..6ea81157c --- /dev/null +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/PredictiveNavigationDecorator.kt @@ -0,0 +1,141 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuitx.gesturenavigation + +import androidx.compose.animation.core.SeekableTransitionState +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.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.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( + 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: + SeekableTransitionState> + private set + + 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) + private set + + protected var swipeOffset: Offset by mutableStateOf(Offset.Zero) + private set + + override fun targetState(args: NavStackList): GestureNavTransitionHolder { + return GestureNavTransitionHolder(args) + } + + @OptIn(InternalCircuitApi::class) + @Composable + override fun updateTransition(args: NavStackList): Transition> { + val currentState = remember(args) { targetState(args) } + val backwardState = + remember(args) { + val hasBackward = args.backward.any() + 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 hasForward = args.forward.any() + 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) } + + LaunchedEffect(currentState) { + swipeProgress = 0f + isSeeking = false + seekableTransitionState.animateTo(currentState) + // After the current state has changed (i.e. any transition has completed), + // clear out any transient state + showBackward = false + showForward = false + swipeOffset = Offset.Zero + } + + // todo Based on the swipe direction + 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 { + showingBackward -> { + isSeeking = true + seekableTransitionState.seekTo(fraction = abs(progress), targetState = backwardState) + } + showingForward -> { + isSeeking = true + seekableTransitionState.seekTo(fraction = abs(progress), targetState = forwardState) + } + } + } + } + + PredictiveNavEventHandler( + isBackEnabled = isBackEnabled(currentState.navStack), + isForwardEnabled = isForwardEnabled(currentState.navStack), + onProgress = { direction, progress, offset -> + when (direction) { + PredictiveNavDirection.Back -> { + showForward = false + showBackward = progress != 0f + swipeProgress = progress + swipeOffset = offset + } + PredictiveNavDirection.Forward -> { + showBackward = false + showForward = progress != 0f + swipeProgress = progress + swipeOffset = offset + } + } + }, + onCancelled = { + isSeeking = false + seekableTransitionState.animateTo(currentState) + }, + onCompleted = { direction -> + when (direction) { + PredictiveNavDirection.Back -> onBackInvoked(currentState.navStack) + PredictiveNavDirection.Forward -> onForwardInvoked(currentState.navStack) + } + }, + ) + return rememberTransition(seekableTransitionState, label = "PredictiveNavigationDecorator") + } +} 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 a55a3ab64..cd3696bb8 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/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/iosMain/kotlin/com/slack/circuitx/gesturenavigation/iOSPredictiveBackNavigationDecoration.kt index badbd711e..f0176e2d1 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,12 @@ 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.NavStackList +import com.slack.circuit.runtime.Navigator import kotlin.math.roundToInt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.collectLatest @@ -41,21 +43,23 @@ 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. - * @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, + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, ): AnimatedNavDecorator.Factory { - return IOSPredictiveBackNavDecorator.Factory(onBackInvoked = onBackInvoked) + 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. * @@ -63,10 +67,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 @@ -84,7 +88,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 } } @@ -107,7 +111,7 @@ internal class IOSPredictiveBackNavDecorator( targetState = targetState, transition = transition, isSeeking = { isSeeking }, - showPrevious = { showPrevious }, + showPrevious = { showBackward }, swipeOffset = { swipeOffset }, ) ) { @@ -116,13 +120,16 @@ internal class IOSPredictiveBackNavDecorator( } internal class Factory( + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, private val enterOffsetFraction: Float = 0.25f, - private val onBackInvoked: () -> Unit, ) : AnimatedNavDecorator.Factory { - override fun create(): AnimatedNavDecorator { - return IOSPredictiveBackNavDecorator( + override fun create(navigator: Navigator): AnimatedNavDecorator { + return IOSPredictiveNavDecorator( enterOffsetFraction = enterOffsetFraction, - onBackInvoked = onBackInvoked, + onBackInvoked = { onBackInvoked(navigator, it) }, ) } } 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 86c4a5723..41801a9bb 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 @@ -84,19 +86,21 @@ class BackNavigationTest { if (useIntegratedBackHandler) { navigator = rememberCircuitNavigator( - backStack = backStack, + navStack = backStack, onRootPop = {}, enableBackHandler = true, ) } else { - navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) - BackHandler { navigator.pop() } + navigator = rememberCircuitNavigator(navStack = backStack, onRootPop = {}) + NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None)) { + navigator.pop() + } } NavigableCircuitContent( navigator = navigator, backStack = backStack, decoratorFactory = - remember { IOSPredictiveBackNavDecorator.Factory(onBackInvoked = navigator::pop) }, + remember { IOSPredictiveNavDecorator.Factory(onBackInvoked = navigator::pop) }, ) } } @@ -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/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 e868c637c..6a101cf43 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/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 424b20028..7b5633f79 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,9 +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, - onBackInvoked: () -> Unit, + 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 c4f6c2d12..e1de56b14 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,9 +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, - onBackInvoked: () -> Unit, + isForwardEnabled: (NavStackList) -> Boolean, + isBackEnabled: (NavStackList) -> Boolean, + onForwardInvoked: (Navigator, NavStackList) -> Unit, + onBackInvoked: (Navigator, NavStackList) -> Unit, ): AnimatedNavDecorator.Factory = fallback diff --git a/circuitx/navigation/build.gradle.kts b/circuitx/navigation/build.gradle.kts index d88f8a561..dc7160af9 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/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/navigation/dependencies/androidReleaseRuntimeClasspath.txt index 754561025..237056de7 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,13 @@ 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.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 androidx.savedstate:savedstate-compose-android @@ -69,7 +83,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 +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 @@ -87,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 ad0adb782..31b1dc566 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,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/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt b/circuitx/navigation/src/commonMain/kotlin/com/slack/circuitx/navigation/intercepting/InterceptingNavigator.kt index 43aa0d0d2..00d33f6ca 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,8 +11,14 @@ 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.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 @@ -34,6 +40,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(), @@ -66,18 +73,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 && !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 + // to unload this BackHandler from the composition before the root pop is triggered, so + // delay calling pop until after the next composition. + if (!navStack.isAtRoot) { + interceptingNavigator.pop() + } else { + hasPendingRootPop = true + enableRootBackHandler = false + } + }, + ) if (hasPendingRootPop) { SideEffect { interceptingNavigator.pop() @@ -111,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 @@ -122,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 @@ -144,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 @@ -162,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 { @@ -180,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. */ @@ -212,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 a2898dda4..dcef6a00a 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 383f8b413..297696972 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 7612a25a4..ac161e6b9 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 44e6a5e51..fbf8d7bd2 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 c2fab0087..c7254549b 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/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 01d006ced..8400a4710 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,10 @@ 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.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 @@ -15,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 @@ -38,20 +46,21 @@ class NavigatorBackHandlerTest { @Test fun nestedNavigatorRootPopBackHandler() { 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, + 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( - backStack = backStack, + navStack = backStack, onRootPop = { BackDispatcher.onBack() }, enableBackHandler = true, ) @@ -78,19 +87,21 @@ class NavigatorBackHandlerTest { @Test fun nestedNavigatorRootDispatchedBackHandler() { 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, + 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( - backStack = backStack, + navStack = backStack, onRootPop = { BackDispatcher.onBack() }, enableBackHandler = true, ) @@ -120,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 6fb17ddd9..78a2fad08 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/dependencies/androidReleaseRuntimeClasspath.txt b/circuitx/overlays/dependencies/androidReleaseRuntimeClasspath.txt index 6a38692b4..5db2c1457 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,13 @@ 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.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 androidx.savedstate:savedstate-compose-android @@ -74,7 +90,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 +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 87d2f814c..c4d6ed247 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,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/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt b/circuitx/overlays/src/commonMain/kotlin/com/slack/circuitx/overlays/BottomSheetOverlay.kt index 4c5700c9b..3b0df1c22 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 a7a2491d9..997aa5341 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 91d357816..0d3a02bb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,27 +1,28 @@ [versions] -androidx-activity = "1.11.0" +agp = "8.13.1" +androidx-activity = "1.12.0-rc01" androidx-annotation = "1.9.1" androidx-appcompat = "1.7.1" androidx-browser = "1.9.0" androidx-compose = "1.9.4" -agp = "8.13.1" anvil = "0.4.1" atomicfu = "0.29.0" benchmark = "1.4.1" coil = "3.3.0" -compose-hotReload = "1.0.0-rc03" -compose-runtime = "1.9.4" # Keep in sync with the androidx version used by compose-jb until 1.10 -compose-jb = "1.9.3" -compose-jb-material3 = "1.9.0" -compose-jb-material-icons-core = "1.7.3" +compose-runtime = "1.10.0-beta01" # Keep in sync with the androidx version used by jb-compose 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-adaptive = "1.1.2" +jb-compose-material-icons-core = "1.7.3" +jb-compose-material3 = "1.9.0" +jb-compose-navigationevent = "1.0.0-beta01" +jb-lifecycle = "2.9.6" jdk = "23" jvmTarget = "11" -publishedJvmTarget = "11" kct = "0.11.0" kotlin = "2.2.21" kotlinInject = "0.8.0" @@ -33,7 +34,6 @@ ksp = "2.3.2" ktfmt = "0.59" ktor = "3.3.2" leakcanary = "2.14" -lifecycle-jb = "2.9.6" mavenPublish = "0.35.0" molecule = "2.2.0" okhttp = "5.3.0" @@ -54,8 +54,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-hotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hotReload" } +compose = { id = "org.jetbrains.compose", 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" } @@ -117,20 +116,24 @@ 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-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" } +compose-material-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "jb-compose-material3" } +compose-material-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "jb-compose-adaptive" } +compose-material-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "jb-compose-adaptive" } +compose-navigationevent = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "jb-compose-navigationevent" } compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose-runtime" } -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-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" } @@ -184,8 +187,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" diff --git a/kotlin-js-store/package-lock.json b/kotlin-js-store/package-lock.json index 88dff2960..3b837382f 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 92acae436..e4c5a1e6f 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": { diff --git a/samples/bottom-navigation/build.gradle.kts b/samples/bottom-navigation/build.gradle.kts index 455638874..f781ef316 100644 --- a/samples/bottom-navigation/build.gradle.kts +++ b/samples/bottom-navigation/build.gradle.kts @@ -35,6 +35,9 @@ kotlin { implementation(libs.compose.foundation) implementation(libs.compose.material.icons) implementation(libs.compose.material.material3) + implementation(libs.compose.material.material3.adaptive) + implementation(libs.compose.material.material3.adaptive.layout) + implementation(libs.compose.ui.backhandler) implementation(projects.circuitFoundation) implementation(projects.circuitx.gestureNavigation) implementation(projects.circuitx.navigation) 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 21a0d7863..d0232a49e 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,16 +9,15 @@ 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 -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 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 @@ -44,29 +43,26 @@ class MainActivity : AppCompatActivity() { val notifier = LoggingNavigatorFailureNotifier(LogcatLogger) val tabs = TabScreen.all + val circuit = buildCircuitForTabs(tabs) setContent { MaterialTheme { - val backStack = rememberSaveableBackStack(tabs.first()) - val navigator = rememberCircuitNavigator(backStack) + val navStack = rememberSaveableNavStack(ContentScreen(tabs)) + val navigator = rememberCircuitNavigator(navStack) // Build the delegate Navigator. val interceptingNavigator = rememberInterceptingNavigator( + navStack = navStack, navigator = navigator, interceptors = interceptors, eventListeners = eventListeners, notifier = notifier, ) - val circuit = - remember(navigator) { - buildCircuitForTabs(tabs) - .newBuilder() - .setAnimatedNavDecoratorFactory( - GestureNavigationDecorationFactory(onBackInvoked = { interceptingNavigator.pop() }) - ) - .build() - } CircuitCompositionLocals(circuit) { - ContentScaffold(backStack, interceptingNavigator, tabs, Modifier.fillMaxSize()) + NavigableCircuitContent( + navigator = interceptingNavigator, + 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 new file mode 100644 index 000000000..743ab9692 --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/AdaptiveListDetailNavDecoration.kt @@ -0,0 +1,389 @@ +@file:Suppress("Deprecation") + +// Copyright (C) 2025 Slack Technologies, LLC +// 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 +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 +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.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 +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 +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 +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.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 +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 +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope +import kotlin.reflect.KClass +import kotlinx.coroutines.CancellationException + +@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, + ) + +object AdaptiveListDetailAnimatedScope : AnimatedScope + +/** + * 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 AdaptiveListDetailNavDecoration( + screenTransforms: Map, AnimatedScreenTransform>, + normalDecoratorFactory: AnimatedNavDecorator.Factory, + detailPaneDecoratorFactory: AnimatedNavDecorator.Factory, + private val showInDetailPane: (NavArgument) -> Boolean, + private val listDetailScaffoldStyle: (Density, WindowAdaptiveInfo) -> ListDetailScaffoldStyle = + ::defaultListDetailScaffoldStyle, +) : NavDecoration { + + private val delegate = + AnimatedNavDecoration( + animatedScreenTransforms = screenTransforms, + decoratorFactory = normalDecoratorFactory, + ) + + private val detailPaneDelegate = + AnimatedNavDecoration( + animatedScreenTransforms = emptyMap(), + decoratorFactory = detailPaneDecoratorFactory, + ) + + @OptIn(DelicateCircuitFoundationApi::class) + @Composable + override fun DecoratedContent( + args: NavStackList, + navigator: Navigator, + modifier: Modifier, + content: @Composable (T) -> Unit, + ) { + 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 -> + ProvideAnimatedTransitionScope(AdaptiveListDetailAnimatedScope, this) { + if (shouldUsePaneLayout) { + // todo I had expected the `PaneContent` retains to work with just the navigable_registry + rememberRetainedStateHolder().RetainedStateProvider("list-detail-${args.current.key}") { + ListDetailContent( + args = args, + navigator = navigator, + listDetailScaffoldStyle = paneScaffoldStyle, + modifier = modifier, + content = content, + ) + } + } else { + delegate.DecoratedContent( + args = args, + navigator = navigator, + modifier = modifier, + content = content, + ) + } + } + } + } + + @OptIn( + ExperimentalMaterial3AdaptiveApi::class, // For ListDetailPaneScaffold + ExperimentalComposeUiApi::class, // For PredictiveBackHandler + ) + @Composable + private fun ListDetailContent( + args: NavStackList, + navigator: Navigator, + listDetailScaffoldStyle: ListDetailScaffoldStyle, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit, + ) { + val (primaryArgs, secondaryLookup) = rememberListDetailNavArguments(args, showInDetailPane) + val secondaryLookupTransition = updateTransition(secondaryLookup) + 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) } + + var initialAnchorIndex by rememberRetained { mutableIntStateOf(-1) } + val paneExpansionState = + rememberPaneExpansionState( + key = scaffoldValue.paneExpansionStateKey, + anchors = listDetailScaffoldStyle.anchors, + initialAnchoredIndex = initialAnchorIndex, + ) + DisposableEffect(paneExpansionState.currentAnchor) { + initialAnchorIndex = + listDetailScaffoldStyle.anchors.indexOf(paneExpansionState.currentAnchor) + onDispose {} + } + LaunchedEffect(Unit) { + if (listDetailScaffoldStyle.anchors.isNotEmpty() && initialAnchorIndex != -1) { + paneExpansionState.animateTo(listDetailScaffoldStyle.anchors[initialAnchorIndex]) + } + } + LaunchedEffect(scaffoldValue) { scaffoldState.animateTo(scaffoldValue) } + ListDetailPaneScaffold( + modifier = Modifier.fillMaxSize(), + directive = listDetailScaffoldStyle.directive, + scaffoldState = scaffoldState, + paneExpansionState = paneExpansionState, + paneExpansionDragHandle = listDetailScaffoldStyle.dragHandle, + 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) { args -> + val isActive = secondaryArgs == args + CompositionLocalProvider( + LocalRecordLifecycle provides staticRecordLifecycle(isActive) + ) { + content(args) + } + } + } + } + }, + ) + + // Prevent the detailPaneDelegate from handling the back press + val singleSecondary = hasSecondary && secondaryArgs.singleOrNull() != null + PredictiveBackHandler(enabled = singleSecondary) { progress -> + try { + progress.collect { backEvent -> + scaffoldState.seekTo(backEvent.progress, Primary, isPredictiveBackInProgress = true) + } + navigator.pop() + } catch (_: CancellationException) { + scaffoldState.snapTo(PrimarySecondary) + } + } + } + } +} + +/** + * 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() + } +} + +/** + * Strategy that partitions navigation arguments into primary (list) and secondary (detail) panes + * based on the [showInDetailPane] predicate. + */ +@Composable +internal fun rememberListDetailNavArguments( + navStackList: NavStackList, + showInDetailPane: (T) -> Boolean, +): Pair, Map>> = + remember(navStackList) { + val primary = mutableListOf() + val secondaryLookup = mutableMapOf>() + val secondary = mutableListOf() + for (arg in navStackList.backward) { + when { + showInDetailPane(arg) -> { + secondary += arg + } + else -> { + primary += arg + if (secondary.isNotEmpty()) { + secondaryLookup[arg] = navStackListOf(secondary.toList()) + } + secondary.clear() + } + } + } + if (primary.isEmpty()) { + navStackListOf(secondary.toList()) to emptyMap() + } else { + // Show the next secondary if it exists + navStackList.forward + .firstOrNull() + ?.takeIf { showInDetailPane(it) } + ?.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/ContentCircuit.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentCircuit.kt index 7493083d7..144052abe 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,15 +3,20 @@ 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() .apply { + addPresenterFactory(ContentPresenter.Factory) + addUiFactory(ContentUiFactory) for (tab in tabs) { addPresenterFactory(TabPresenter.Factory(tab::class)) addUiFactory(TabUiFactory(tab::class)) } + addPresenterFactory(DetailPresenter.Factory) + addUiFactory(DetailUiFactory) } - .setAnimatedNavDecoratorFactory(CrossFadeNavDecoratorFactory()) + .setAnimatedNavDecoratorFactory(GestureNavigationDecorationFactory()) .build() } 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 cada912b4..000000000 --- a/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScaffold.kt +++ /dev/null @@ -1,100 +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.BackStack -import com.slack.circuit.backstack.SaveableBackStack.Record -import com.slack.circuit.foundation.NavigableCircuitContent -import com.slack.circuit.runtime.Navigator -import com.slack.circuit.runtime.Navigator.StateOptions - -@Composable -fun ContentScaffold( - backStack: BackStack, - navigator: Navigator, - tabs: List, - 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: List, - backStack: BackStack, - 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, StateOptions.SaveAndRestore) } - .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 000000000..e3bfc9135 --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/ContentScreen.kt @@ -0,0 +1,257 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// 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 +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.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +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.rememberSaveableNavStack +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.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 +import com.slack.circuitx.navigation.intercepting.NavigationInterceptor.Companion.SuccessConsumed +import com.slack.circuitx.navigation.intercepting.rememberInterceptingNavigator + +@Parcelize data class ContentScreen(val tabs: List) : Screen + +data class ContentState( + val tabs: List, + 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 + } + } +} + +private class ContentInterceptor(private val eventSink: State<(ContentEvent) -> Unit>) : + NavigationInterceptor { + override fun resetRoot( + newRoot: Screen, + options: Navigator.StateOptions, + navigationContext: NavigationContext, + ): InterceptedResetRootResult { + return when (newRoot) { + is TabScreen, + is DetailScreen -> { + Skipped + } + else -> { + eventSink.value(ContentEvent.OnNavEvent(NavEvent.ResetRoot(newRoot, options))) + SuccessConsumed + } + } + } + + override fun goTo(screen: Screen, navigationContext: NavigationContext): InterceptedGoToResult { + return when (screen) { + is TabScreen, + is DetailScreen -> { + Skipped + } + else -> { + eventSink.value(ContentEvent.OnNavEvent(NavEvent.GoTo(screen))) + SuccessConsumed + } + } + } +} + +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, + ExperimentalMaterial3AdaptiveApi::class, +) +@Composable +fun ContentUi(state: ContentState, modifier: Modifier = Modifier) = SharedElementTransitionLayout { + val eventSink = rememberUpdatedState(state.eventSink) + val navStack = rememberSaveableNavStack(state.rootScreen) + val interceptors = remember { listOf(ContentInterceptor(eventSink), ForwardHistoryInterceptor) } + val contentNavigator = + rememberCircuitNavigator(navStack, onRootPop = {}, enableBackHandler = true) + + val interceptingNavigator = + rememberInterceptingNavigator( + navStack = navStack, + navigator = contentNavigator, + interceptors = interceptors, + ) + Scaffold( + modifier = modifier.testTag(ContentTags.TAG_SCAFFOLD).fillMaxSize(), + bottomBar = { + BottomTabRow( + tabs = state.tabs, + rootScreen = navStack.rootRecord?.screen, + onNavEvent = { interceptingNavigator.onNavEvent(it) }, + ) + }, + ) { innerPadding -> + NavigableCircuitContent( + navigator = interceptingNavigator, + navStack = navStack, + modifier = Modifier.padding(innerPadding).fillMaxSize(), + decoratorFactory = GestureNavigationDecorationFactory(), + + // decoration = + // remember(circuit.animatedScreenTransforms, circuit.animatedNavDecoratorFactory) { + // AdaptiveListDetailNavDecoration( + // screenTransforms = circuit.animatedScreenTransforms, + // normalDecoratorFactory = circuit.animatedNavDecoratorFactory, + // detailPaneDecoratorFactory = circuit.animatedNavDecoratorFactory, + // showInDetailPane = { it.screen is DetailScreen }, + // ) + // }, + ) + } +} + +@Composable +private fun BottomTabRow( + tabs: List, + 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/CrossFadeNavDecorator.kt b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/CrossFadeNavDecorator.kt deleted file mode 100644 index 668eb902b..000000000 --- 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 new file mode 100644 index 000000000..df008a3c5 --- /dev/null +++ b/samples/bottom-navigation/src/commonMain/kotlin/com/slack/circuit/sample/navigation/DetailScreen.kt @@ -0,0 +1,150 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// 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 +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.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 +import com.slack.circuit.sharedelements.SharedElementTransitionScope + +@Parcelize data class DetailScreen(val primary: TabScreen) : Screen + +data class DetailState( + val label: String, + val description: String, + val eventSink: (DetailEvent) -> Unit, +) : CircuitUiState + +sealed interface DetailEvent : CircuitUiEvent { + data object Close : DetailEvent +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun DetailUi(state: DetailState, screen: DetailScreen, modifier: Modifier = Modifier) = + SharedElementTransitionScope { + Card( + 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), + // ) + ) { + 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, screen, 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 d9a53ffef..c7438c19c 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,31 +2,46 @@ // 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.Box 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.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 +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.LocalBackStack 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 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 @@ -61,10 +76,21 @@ sealed interface TabScreen : Screen { object TabScreenCircuit { - data class State(val label: String, val eventSink: (Event) -> Unit) : CircuitUiState + data class State( + val label: String, + val navStack: NavStackList?, + 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 } } @@ -72,9 +98,24 @@ class TabPresenter(private val screen: TabScreen, private val navigator: Navigat Presenter { @Composable override fun present(): TabScreenCircuit.State { - return TabScreenCircuit.State(label = screen.label) { event -> + 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, + navStack = navStack, + 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)) } } } @@ -94,43 +135,76 @@ 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)) { - 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), - ) - LazyColumn( - modifier = - Modifier.fillMaxSize().testTag(ContentTags.TAG_CONTENT).clickable { - state.eventSink(TabScreenCircuit.Event.Next) +fun TabUI(state: TabScreenCircuit.State, screen: TabScreen, modifier: Modifier = Modifier) = + SharedElementTransitionScope { + 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.navStack?.backward?.any() == true, + onClick = { state.eventSink(TabScreenCircuit.Event.Backward) }, + ) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + IconButton( + enabled = state.navStack?.forward?.any() == true, + onClick = { state.eventSink(TabScreenCircuit.Event.Forward) }, + ) { + Icon(Icons.AutoMirrored.Default.ArrowForward, contentDescription = "Forward") + } } - ) { - itemsIndexed(backStack) { i, item -> Text( - text = "$i: ${item.screen}", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + text = state.label, + style = MaterialTheme.typography.headlineMedium, + 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.align(Alignment.CenterEnd) + .sharedBounds( + sharedContentState = rememberSharedContentState(key = "${screen}-details"), + animatedVisibilityScope = requireAnimatedScope(Navigation), + ) + .padding(horizontal = 16.dp), + ) { + Text(text = "View details") + } + } + } + LazyColumn( + modifier = + Modifier.fillMaxSize().testTag(ContentTags.TAG_CONTENT).clickable { + state.eventSink(TabScreenCircuit.Event.Next) + } + ) { + state.navStack?.let { navStack -> + val current = navStack.current + val top = current === screen + itemsIndexed(navStack.toList()) { i, item -> + Text( + text = "$i: $item ${if(current === item) "(active)" else "$top"}", + 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 } } } + +private fun Screen.loggingName() = this::class.simpleName 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 db1e84d4b..2b2ef5625 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,8 +12,8 @@ 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 @OptIn(ExperimentalSharedTransitionApi::class) @@ -24,7 +24,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 @@ -32,9 +32,11 @@ fun main() { val interceptingNavigator = rememberInterceptingNavigator(navigator = navigator, interceptors = interceptors) CircuitCompositionLocals(circuit) { - SharedElementTransitionLayout { - ContentScaffold(backStack, interceptingNavigator, tabs, Modifier.fillMaxSize()) - } + NavigableCircuitContent( + navigator = interceptingNavigator, + backStack = backStack, + modifier = Modifier.fillMaxSize(), + ) } } } diff --git a/samples/counter/apps/build.gradle.kts b/samples/counter/apps/build.gradle.kts index 33abb0a02..e4af6a429 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/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 db0b68cdc..ff535df96 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 diff --git a/samples/star/build.gradle.kts b/samples/star/build.gradle.kts index ce5c0c613..e5f75c8b0 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 { @@ -48,6 +47,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 +58,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 ceeceb17f..485dfd494 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) } } 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 d0e17e69e..5e60df185 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 99b42b773..34a7c644c 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) }, ) 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 2a2198c05..6a10fc9d8 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 a72bcee4d..318ecd70a 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)),