From a3d85d76a3a8ebfe38b2623c4e256654c047a6a6 Mon Sep 17 00:00:00 2001 From: Ray Ryan Date: Fri, 26 Aug 2022 09:07:59 -0700 Subject: [PATCH] API overhaul because splitting the keys and the composables don't work. All of our tests and demos were built using `String` as the key, with `content` that does nothing but render the key. This approach doesn't reflect reality very well, and masked #63, where keys for more interesting objects can get out of sync with the `content` lambda that can render them. When popping, you would wind up crashing when the up to date lambda is unable to interpret the key for the screen that is being animated away. The fix is to change the API from something that takes a list of keys and a function that can render them, to a list of model objects that themselves are able to provide `@Composable Content()`. IMHO the updated API actually feels pretty good, more like the conventional hoisted-state `@Composable Foo(model: FooModel)` idiom. (Of course I've been working on this all day, so I'm biased.) We provide a new interface: ```kotlin interface BackstackFrame { val key: K @Composable fun Content() } ``` And change the signature of the `Backstack()` function: ```kotlin fun Backstack( frames: List>, modifier: Modifier = Modifier, frameController: FrameController ) ``` Note that the param type, `K`, is still the type of the key, not the type of a particular flavor of `BackstackFrame`. This makes it easy for us to provide convenience functions to map lists of arbitrary model objects to `BackstackFrame` instances, so it's not much more verbose than it used to be to make it go. Before: ```kotlin Backstack(backstack) { screen -> when(screen) { Screen.ContactList -> ShowContactList(navigator) is Screen.ContactDetails -> ShowContact(screen.id, navigator) is Screen.EditContact -> ShowEditContact(screen.id, navigator) } } ``` After: ```kotlin Backstack( backstack.toBackstackModel { screen -> when(screen) { Screen.ContactList -> ShowContactList(navigator) is Screen.ContactDetails -> ShowContact(screen.id, navigator) is Screen.EditContact -> ShowEditContact(screen.id, navigator) } ) ``` Note that there are two flavors of `toBackstackModel`. The second one supports models with more interesting keys. ```kotlin data class Portrait( val id: Int, val url: String ) Backstack( backstack.toBackstackModel( getKey = { it.id } ) { PrettyPicture(it.url) } ) ``` Fixes #63 --- .../src/main/resources/versions.properties | 2 +- .../backstack/viewer/BackstackViewerApp.kt | 19 +++-- .../compose/backstack/xray/XrayController.kt | 21 ++--- compose-backstack/api/compose-backstack.api | 25 +++--- .../compose/backstack/BackstackStateTest.kt | 50 +++++------ .../backstack/BackstackTransitionsTest.kt | 12 ++- .../zachklipp/compose/backstack/Backstack.kt | 85 +++++++------------ .../compose/backstack/BackstackFrame.kt | 44 ++++++++++ .../compose/backstack/FrameController.kt | 24 +++--- .../compose/backstack/TransitionController.kt | 80 +++++++++-------- .../backstack/TransitionControllerTest.kt | 7 +- 11 files changed, 205 insertions(+), 164 deletions(-) create mode 100644 compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackFrame.kt diff --git a/buildSrc/src/main/resources/versions.properties b/buildSrc/src/main/resources/versions.properties index abcb262..dfe43d7 100644 --- a/buildSrc/src/main/resources/versions.properties +++ b/buildSrc/src/main/resources/versions.properties @@ -1,3 +1,3 @@ # -SNAPSHOT will automatically be appended. Pass -PisRelease=true to gradlew to release (this will # also append the current compose version number after a +). -releaseVersion=0.10.0 +releaseVersion=0.11.0 diff --git a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt index c5b47e2..a0511bd 100644 --- a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt +++ b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt @@ -41,6 +41,7 @@ import com.zachklipp.compose.backstack.BackstackTransition.Crossfade import com.zachklipp.compose.backstack.BackstackTransition.Slide import com.zachklipp.compose.backstack.defaultBackstackAnimation import com.zachklipp.compose.backstack.rememberTransitionController +import com.zachklipp.compose.backstack.toBackstackModel import com.zachklipp.compose.backstack.xray.xrayed private val DEFAULT_BACKSTACKS = listOf( @@ -147,7 +148,14 @@ private fun AppScreens(model: AppModel) { MaterialTheme(colors = lightColors()) { Backstack( - backstack = model.currentBackstack, + frames = model.currentBackstack.toBackstackModel { screen -> + AppScreen( + name = screen, + showBack = screen != model.bottomScreen, + onAdd = { model.pushScreen("$screen+") }, + onBack = model::popScreen + ) + }, frameController = rememberTransitionController( transition = model.selectedTransition.second, animationSpec = animation ?: defaultBackstackAnimation(), @@ -165,14 +173,7 @@ private fun AppScreens(model: AppModel) { modifier = Modifier .fillMaxSize() .border(width = 3.dp, color = Color.Red), - ) { screen -> - AppScreen( - name = screen, - showBack = screen != model.bottomScreen, - onAdd = { model.pushScreen("$screen+") }, - onBack = model::popScreen - ) - } + ) } } diff --git a/compose-backstack-xray/src/main/java/com/zachklipp/compose/backstack/xray/XrayController.kt b/compose-backstack-xray/src/main/java/com/zachklipp/compose/backstack/xray/XrayController.kt index 5ae6a35..bd9dc50 100644 --- a/compose-backstack-xray/src/main/java/com/zachklipp/compose/backstack/xray/XrayController.kt +++ b/compose-backstack-xray/src/main/java/com/zachklipp/compose/backstack/xray/XrayController.kt @@ -12,8 +12,9 @@ import androidx.compose.ui.graphics.DefaultCameraDistance import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import com.zachklipp.compose.backstack.BackstackFrame import com.zachklipp.compose.backstack.FrameController -import com.zachklipp.compose.backstack.FrameController.BackstackFrame +import com.zachklipp.compose.backstack.FrameController.FrameAndModifier import com.zachklipp.compose.backstack.NoopFrameController import kotlin.math.sin @@ -22,16 +23,16 @@ import kotlin.math.sin * the screens in the backstack in pseudo-3D space. The 3D stack can be navigated via touch * gestures. */ -@Composable fun FrameController.xrayed(enabled: Boolean): FrameController = - remember { XrayController() }.also { +@Composable fun FrameController.xrayed(enabled: Boolean): FrameController = + remember { XrayController() }.also { it.enabled = enabled it.wrappedController = this } -private class XrayController : FrameController { +private class XrayController : FrameController { var enabled: Boolean by mutableStateOf(false) - var wrappedController: FrameController by mutableStateOf(NoopFrameController()) + var wrappedController: FrameController by mutableStateOf(NoopFrameController()) private var offsetDpX by mutableStateOf(500.dp) private var offsetDpY by mutableStateOf(10.dp) @@ -41,7 +42,7 @@ private class XrayController : FrameController { private var alpha by mutableStateOf(.4f) private var overlayAlpha by mutableStateOf(.2f) - private var activeKeys by mutableStateOf(emptyList()) + private var activeKeys by mutableStateOf(emptyList>()) private val controlModifier = Modifier.pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ -> @@ -56,14 +57,14 @@ private class XrayController : FrameController { if (!enabled) wrappedController.activeFrames else { activeKeys.mapIndexed { index, key -> val modifier = Modifier.modifierForFrame(index, activeKeys.size, 1f) - return@mapIndexed BackstackFrame(key, modifier) + return@mapIndexed FrameAndModifier(key, modifier) } } } - override fun updateBackstack(keys: List) { - activeKeys = keys - wrappedController.updateBackstack(keys) + override fun updateBackstack(frames: List>) { + activeKeys = frames + wrappedController.updateBackstack(frames) } /** diff --git a/compose-backstack/api/compose-backstack.api b/compose-backstack/api/compose-backstack.api index c88b7fd..2d74a7e 100644 --- a/compose-backstack/api/compose-backstack.api +++ b/compose-backstack/api/compose-backstack.api @@ -1,8 +1,11 @@ +public abstract interface class com/zachklipp/compose/backstack/BackstackFrame { + public abstract fun Content (Landroidx/compose/runtime/Composer;I)V + public abstract fun getKey ()Ljava/lang/Object; +} + public final class com/zachklipp/compose/backstack/BackstackKt { - public static final fun Backstack (Ljava/util/List;Landroidx/compose/ui/Modifier;Lcom/zachklipp/compose/backstack/BackstackTransition;Landroidx/compose/animation/core/AnimationSpec;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;)V - public static final fun Backstack (Ljava/util/List;Landroidx/compose/ui/Modifier;Lcom/zachklipp/compose/backstack/BackstackTransition;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V - public static final fun Backstack (Ljava/util/List;Landroidx/compose/ui/Modifier;Lcom/zachklipp/compose/backstack/FrameController;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V - public static synthetic fun Backstack$default (Ljava/util/List;Landroidx/compose/ui/Modifier;Lcom/zachklipp/compose/backstack/BackstackTransition;Landroidx/compose/animation/core/AnimationSpec;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V + public static final fun Backstack (Ljava/util/List;Landroidx/compose/ui/Modifier;Lcom/zachklipp/compose/backstack/BackstackTransition;Landroidx/compose/runtime/Composer;II)V + public static final fun Backstack (Ljava/util/List;Landroidx/compose/ui/Modifier;Lcom/zachklipp/compose/backstack/FrameController;Landroidx/compose/runtime/Composer;II)V } public abstract interface class com/zachklipp/compose/backstack/BackstackTransition { @@ -30,15 +33,15 @@ public abstract interface class com/zachklipp/compose/backstack/FrameController public abstract fun updateBackstack (Ljava/util/List;)V } -public final class com/zachklipp/compose/backstack/FrameController$BackstackFrame { - public fun (Ljava/lang/Object;Landroidx/compose/ui/Modifier;)V - public synthetic fun (Ljava/lang/Object;Landroidx/compose/ui/Modifier;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/Object; +public final class com/zachklipp/compose/backstack/FrameController$FrameAndModifier { + public fun (Lcom/zachklipp/compose/backstack/BackstackFrame;Landroidx/compose/ui/Modifier;)V + public synthetic fun (Lcom/zachklipp/compose/backstack/BackstackFrame;Landroidx/compose/ui/Modifier;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/zachklipp/compose/backstack/BackstackFrame; public final fun component2 ()Landroidx/compose/ui/Modifier; - public final fun copy (Ljava/lang/Object;Landroidx/compose/ui/Modifier;)Lcom/zachklipp/compose/backstack/FrameController$BackstackFrame; - public static synthetic fun copy$default (Lcom/zachklipp/compose/backstack/FrameController$BackstackFrame;Ljava/lang/Object;Landroidx/compose/ui/Modifier;ILjava/lang/Object;)Lcom/zachklipp/compose/backstack/FrameController$BackstackFrame; + public final fun copy (Lcom/zachklipp/compose/backstack/BackstackFrame;Landroidx/compose/ui/Modifier;)Lcom/zachklipp/compose/backstack/FrameController$FrameAndModifier; + public static synthetic fun copy$default (Lcom/zachklipp/compose/backstack/FrameController$FrameAndModifier;Lcom/zachklipp/compose/backstack/BackstackFrame;Landroidx/compose/ui/Modifier;ILjava/lang/Object;)Lcom/zachklipp/compose/backstack/FrameController$FrameAndModifier; public fun equals (Ljava/lang/Object;)Z - public final fun getKey ()Ljava/lang/Object; + public final fun getFrame ()Lcom/zachklipp/compose/backstack/BackstackFrame; public final fun getModifier ()Landroidx/compose/ui/Modifier; public fun hashCode ()I public fun toString ()Ljava/lang/String; diff --git a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackStateTest.kt b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackStateTest.kt index 07638b6..f96c267 100644 --- a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackStateTest.kt +++ b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackStateTest.kt @@ -10,11 +10,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.rules.ActivityScenarioRule import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test @@ -24,13 +22,15 @@ class BackstackStateTest { @get:Rule val compose = createComposeRule() + private fun List.toCounters() = toBackstackModel { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText("$it: $counter", Modifier.clickable { counter++ }) + } + @Test fun screen_state_is_restored_on_pop() { val backstack = mutableStateListOf("one") compose.setContent { - Backstack(backstack, frameController = NoopFrameController()) { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText("$it: $counter", Modifier.clickable { counter++ }) - } + Backstack(backstack.toCounters(), frameController = NoopFrameController()) } // Update some state on the first screen. @@ -55,10 +55,7 @@ class BackstackStateTest { @Test fun screen_state_is_discarded_after_pop() { val backstack = mutableStateListOf("one", "two") compose.setContent { - Backstack(backstack, frameController = NoopFrameController()) { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText("$it: $counter", Modifier.clickable { counter++ }) - } + Backstack(backstack.toCounters(), frameController = NoopFrameController()) } // Update some state on the second screen. @@ -78,10 +75,7 @@ class BackstackStateTest { @Test fun screen_state_is_discarded_when_removed_from_backstack_while_hidden() { var backstack by mutableStateOf(listOf("one")) compose.setContent { - Backstack(backstack, frameController = NoopFrameController()) { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText("$it: $counter", Modifier.clickable { counter++ }) - } + Backstack(backstack.toCounters(), frameController = NoopFrameController()) } // Update some state on the first screen. @@ -112,13 +106,16 @@ class BackstackStateTest { val backstack = mutableStateListOf("one") val transcript = mutableListOf() compose.setContent { - Backstack(backstack, frameController = NoopFrameController()) { - BasicText(it) - DisposableEffect(Unit) { - transcript += "+$it" - onDispose { transcript += "-$it" } - } - } + Backstack( + backstack.toBackstackModel { + BasicText(it) + DisposableEffect(Unit) { + transcript += "+$it" + onDispose { transcript += "-$it" } + } + }, + frameController = NoopFrameController() + ) } assertThat(transcript).containsExactly("+one") @@ -143,10 +140,13 @@ class BackstackStateTest { val backstack = mutableStateListOf(Screen("one")) compose.setContent { - Backstack(backstack, frameController = NoopFrameController()) { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText("${it.name}: $counter", Modifier.clickable { counter++ }) - } + Backstack( + backstack.toBackstackModel { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText("${it.name}: $counter", Modifier.clickable { counter++ }) + }, + frameController = NoopFrameController() + ) } // Update some state on the first screen. diff --git a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackTransitionsTest.kt b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackTransitionsTest.kt index ee44875..5cd7e40 100644 --- a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackTransitionsTest.kt +++ b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackTransitionsTest.kt @@ -63,10 +63,12 @@ class BackstackTransitionsTest { assertTransition(Crossfade, forward = false) } + private fun List.toBackstack() = toBackstackModel { BasicText(it) } + private fun assertInitialStateWithSingleScreen(transition: BackstackTransition) { val originalBackstack = listOf("one") compose.setContent { - Backstack(originalBackstack, transition = transition) { BasicText(it) } + Backstack(originalBackstack.toBackstack(), transition = transition) } compose.onNodeWithText("one").assertIsDisplayed() @@ -75,7 +77,7 @@ class BackstackTransitionsTest { private fun assertInitialStateWithMultipleScreens(transition: BackstackTransition) { val originalBackstack = listOf("one", "two") compose.setContent { - Backstack(originalBackstack, transition = transition) { BasicText(it) } + Backstack(originalBackstack.toBackstack(), transition = transition) } compose.onNodeWithText("two").assertIsDisplayed() @@ -87,15 +89,17 @@ class BackstackTransitionsTest { val secondBackstack = listOf("one", "two") var backstack by mutableStateOf(if (forward) firstBackstack else secondBackstack) compose.mainClock.autoAdvance = false + compose.setContent { Backstack( - backstack, + backstack.toBackstack(), frameController = rememberTransitionController( animationSpec = animation, transition = transition ) - ) { BasicText(it) } + ) } + val initialText = if (forward) "one" else "two" val newText = if (forward) "two" else "one" diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt index 215f658..9a96303 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt @@ -2,7 +2,6 @@ package com.zachklipp.compose.backstack -import androidx.compose.animation.core.AnimationSpec import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -12,7 +11,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape -import kotlin.DeprecationLevel.ERROR /** * Identifies which direction a transition is being performed in. @@ -23,14 +21,14 @@ enum class TransitionDirection { } /** - * Renders the top of a stack of screens (as [T]s) and animates between screens when the top - * value changes. Any state used by a screen will be preserved as long as it remains in the stack - * (i.e. result of [remember] calls). + * Renders the top of a stack of screens (modeled as [BackstackFrame]s with keys of type [K]) + * and animates between screens when the top value changes. Any state used by a screen + * will be preserved as long as it remains in the stack (i.e. result of [remember] calls). * - * The [backstack] must follow some rules: + * The [frames] list must follow some rules: * - Must always contain at least one item. - * - Items in the stack must implement `equals` and not change over the lifetime of the screen. - * If an item changes, it will be considered a new screen and any state held by the screen will + * - Keys must implement `equals` and cannot change over the lifetime of the screen. + * If a key changes, it will be considered a new screen and any state held by the screen will * be lost. * - If items in the stack are reordered between compositions, the stack should not contain * duplicates. If it does, due to how `@Pivotal` works, the states of those screens will be @@ -70,32 +68,32 @@ enum class TransitionDirection { * ) * } * - * Backstack(backstack) { screen -> - * when(screen) { - * Screen.ContactList -> ShowContactList(navigator) - * is Screen.ContactDetails -> ShowContact(screen.id, navigator) - * is Screen.EditContact -> ShowEditContact(screen.id, navigator) + * Backstack( + * backstack.toBackstackModel { screen -> + * when(screen) { + * Screen.ContactList -> ShowContactList(navigator) + * is Screen.ContactDetails -> ShowContact(screen.id, navigator) + * is Screen.EditContact -> ShowEditContact(screen.id, navigator) + * } * } - * } + * ) * } * ``` * - * @param backstack The stack of screen values. + * @param frames The stack of screen values. * @param modifier [Modifier] that will be applied to the container of screens. Neither affects nor * is affected by transition animations. * @param frameController The [FrameController] that manages things like transition animations. * Use [rememberTransitionController] for a reasonable default, or use the overload of this function * that takes a [BackstackTransition] instead. - * @param content Called with each element of [backstack] to render it. */ @Composable -fun Backstack( - backstack: List, +fun Backstack( + frames: List>, modifier: Modifier = Modifier, - frameController: FrameController, - content: @Composable (T) -> Unit + frameController: FrameController ) { - val stateHolder = rememberSaveableScreenStateHolder() + val stateHolder = rememberSaveableScreenStateHolder() // Notify the frame controller that the backstack has changed to allow it to do stuff like start // animating transitions. This call should eventually cause activeFrames to change, but that might @@ -106,22 +104,22 @@ fun Backstack( // However, we do need to give the controller the chance to initialize itself with the initial // stack before we ask for its activeFrames, so this is a lazy way to do both that and subsequent // updates. - frameController.updateBackstack(backstack) + frameController.updateBackstack(frames) // Actually draw the screens. Box(modifier = modifier.clip(RectangleShape)) { // The frame controller is in complete control of what we actually show. The activeFrames // property should be backed by a snapshot state object, so this will recompose automatically // if the controller changes its frames. - frameController.activeFrames.forEach { (item, frameControlModifier) -> + frameController.activeFrames.forEach { (frame, frameControlModifier) -> // Even if screens are moved around within the list, as long as they're invoked through the // exact same sequence of source locations from within this key lambda, they will keep their // state. - key(item) { + key(frame.key) { // This call must be inside the key(){} wrapper. - stateHolder.SaveableStateProvider(item) { + stateHolder.SaveableStateProvider(frame.key) { Box(frameControlModifier) { - content(item) + frame.Content() } } } @@ -131,45 +129,28 @@ fun Backstack( // Remove stale state from keys no longer in the backstack, but only once the composition has // successfully completed. SideEffect { - stateHolder.removeStaleKeys(backstack) + stateHolder.removeStaleKeys(frames.map { it.key }) } } /** - * Renders the top of a stack of screens (as [T]s) and animates between screens when the top - * value changes. Any state used by a screen will be preserved as long as it remains in the stack - * (i.e. result of [remember] calls). + * Renders the top of a stack of screens (modeled as [BackstackFrame]s with keys of type [K]) + * and animates between screens when the top value changes. Any state used by a screen + * will be preserved as long as it remains in the stack (i.e. result of [remember] calls). * * See the documentation on [Backstack] for more information. * - * @param backstack The stack of screen values. + * @param frames The stack of screen models. * @param modifier [Modifier] that will be applied to the container of screens. Neither affects nor * is affected by transition animations. * @param transition The [BackstackTransition] to use to animate screen transitions. For more, * call [rememberTransitionController] and pass it to the overload of this function that takes a * [FrameController] directly. - * @param content Called with each element of [backstack] to render it. */ -@Composable fun Backstack( - backstack: List, - modifier: Modifier = Modifier, - transition: BackstackTransition = BackstackTransition.Slide, - content: @Composable (T) -> Unit -) { - Backstack(backstack, modifier, rememberTransitionController(transition), content) -} - -@Suppress("DeprecatedCallableAddReplaceWith", "UNUSED_PARAMETER") -@Deprecated("Use a different overload.", level = ERROR) -fun Backstack( - backstack: List, +@Composable fun Backstack( + frames: List>, modifier: Modifier = Modifier, - transition: BackstackTransition = BackstackTransition.Slide, - animationBuilder: AnimationSpec? = null, - onTransitionStarting: ((from: List, to: List, TransitionDirection) -> Unit)? = null, - onTransitionFinished: (() -> Unit)? = null, - inspectionParams: Any? = null, - drawScreen: @Composable (T) -> Unit + transition: BackstackTransition = BackstackTransition.Slide ) { - throw UnsupportedOperationException("This function exists only for migration assistance.") + Backstack(frames, modifier, rememberTransitionController(transition)) } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackFrame.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackFrame.kt new file mode 100644 index 0000000..aa0180b --- /dev/null +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackFrame.kt @@ -0,0 +1,44 @@ +package com.zachklipp.compose.backstack + +import androidx.compose.runtime.Composable + +/** + * Models a frame in a Backstack, with a unique [key] to identify it, + * and a [Content] function to display it. + * + * Use [toBackstackModel] to associate any [List] of UI models with + * with [Composable] code that can display them, suitable for use + * with [Backstack]. + */ +interface BackstackFrame { + val key: K + @Composable fun Content() +} + +inline fun BackstackFrame( + model: M, + key: K, + crossinline content: @Composable (M) -> Unit +): BackstackFrame = object : BackstackFrame { + override val key = key + + @Composable override fun Content() { + content(model) + } +} + +inline fun List.toBackstackModel( + crossinline content: @Composable (M) -> Unit +): List>{ + return toBackstackModel( + getKey = { it }, + content = content + ) +} + +inline fun List.toBackstackModel( + getKey: (M) -> K, + crossinline content: @Composable (M) -> Unit +): List> { + return map { BackstackFrame(it, getKey(it), content) } +} diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/FrameController.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/FrameController.kt index 510fde6..ea206fe 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/FrameController.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/FrameController.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier -import com.zachklipp.compose.backstack.FrameController.BackstackFrame +import com.zachklipp.compose.backstack.FrameController.FrameAndModifier /** * A stable object that processes changes to a [Backstack]'s list of screen keys, determining which @@ -22,7 +22,7 @@ import com.zachklipp.compose.backstack.FrameController.BackstackFrame * for a while, even after they're removed from the backstack, in order to animate their removal. */ @Stable -interface FrameController { +interface FrameController { /** * The frames that are currently being active. All active frames will be composed. When a frame @@ -31,7 +31,7 @@ interface FrameController { * Should be backed by either a [MutableState] or a [SnapshotStateList]. This property * will not be read until after [updateBackstack] is called at least once. */ - val activeFrames: List> + val activeFrames: List> /** * Notifies the controller that a new backstack was passed in. This method must initialize @@ -43,17 +43,17 @@ interface FrameController { * or update any state that is not backed by snapshot state objects (such as [MutableState]s, * lists created by [mutableStateListOf], etc.). * - * @param keys The latest backstack passed to [Backstack]. Will always contain at least one + * @param frames The latest backstack passed to [Backstack]. Will always contain at least one * element. */ - fun updateBackstack(keys: List) + fun updateBackstack(frames: List>) /** * A frame controlled by a [FrameController], to be shown by [Backstack]. */ @Immutable - data class BackstackFrame( - val key: T, + data class FrameAndModifier( + val frame: BackstackFrame, val modifier: Modifier = Modifier ) } @@ -62,15 +62,15 @@ interface FrameController { * Returns a [FrameController] that always just shows the top frame without any special effects. */ @Suppress("UNCHECKED_CAST") -fun NoopFrameController(): FrameController = NoopFrameController as FrameController +fun NoopFrameController(): FrameController = NoopFrameController as FrameController private object NoopFrameController : FrameController { - private var topFrame by mutableStateOf?>(null) + private var topFrame by mutableStateOf?>(null) - override val activeFrames: List> + override val activeFrames: List> get() = topFrame?.let { listOf(it) } ?: emptyList() - override fun updateBackstack(keys: List) { - topFrame = BackstackFrame(keys.last()) + override fun updateBackstack(frames: List>) { + topFrame = FrameAndModifier(frames.last()) } } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/TransitionController.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/TransitionController.kt index 0fb9866..2da8dc2 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/TransitionController.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/TransitionController.kt @@ -1,5 +1,6 @@ package com.zachklipp.compose.backstack +import android.annotation.SuppressLint import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.PRIVATE import androidx.compose.animation.core.Animatable @@ -19,13 +20,15 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.platform.LocalContext -import com.zachklipp.compose.backstack.FrameController.BackstackFrame +import com.zachklipp.compose.backstack.FrameController.FrameAndModifier import com.zachklipp.compose.backstack.TransitionDirection.Backward import com.zachklipp.compose.backstack.TransitionDirection.Forward import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect +typealias OnTransitionStarting = + (from: List>, to: List>, TransitionDirection) -> Unit + /** * Returns the default [AnimationSpec] used for [rememberTransitionController]. */ @@ -45,14 +48,14 @@ import kotlinx.coroutines.flow.collect * @param onTransitionStarting Callback that will be invoked before starting each transition. * @param onTransitionFinished Callback that will be invoked after each transition finishes. */ -@Composable fun rememberTransitionController( +@Composable fun rememberTransitionController( transition: BackstackTransition = BackstackTransition.Slide, animationSpec: AnimationSpec = defaultBackstackAnimation(), - onTransitionStarting: (from: List, to: List, TransitionDirection) -> Unit = { _, _, _ -> }, + onTransitionStarting: OnTransitionStarting = { _, _, _ -> }, onTransitionFinished: () -> Unit = {}, -): FrameController { +): FrameController { val scope = rememberCoroutineScope() - return remember { TransitionController(scope) }.also { + return remember { TransitionController(scope) }.also { it.transition = transition it.animationSpec = animationSpec it.onTransitionStarting = onTransitionStarting @@ -70,24 +73,23 @@ import kotlinx.coroutines.flow.collect * @param scope The [CoroutineScope] used for animations. */ @VisibleForTesting(otherwise = PRIVATE) -internal class TransitionController( +internal class TransitionController( private val scope: CoroutineScope -) : FrameController { +) : FrameController { /** * Holds information about an in-progress transition. */ @Immutable - private data class ActiveTransition( - val fromFrame: BackstackFrame, - val toFrame: BackstackFrame, + private data class ActiveTransition( + val fromFrame: FrameAndModifier, + val toFrame: FrameAndModifier, val popping: Boolean ) var transition: BackstackTransition? by mutableStateOf(null) var animationSpec: AnimationSpec? by mutableStateOf(null) - var onTransitionStarting: ((from: List, to: List, TransitionDirection) -> Unit)? - by mutableStateOf(null) + var onTransitionStarting: OnTransitionStarting? by mutableStateOf(null) var onTransitionFinished: (() -> Unit)? by mutableStateOf(null) /** @@ -97,26 +99,26 @@ internal class TransitionController( * a forwards or backwards animation. It's a [MutableState] because it is used to derive the value * for [activeFrames], and so it needs to be observable. */ - private var displayedKeys: List by mutableStateOf(emptyList()) + private var displayedFrames: List> by mutableStateOf(emptyList()) /** The latest list of keys seen by [updateBackstack]. */ - private var targetKeys by mutableStateOf(emptyList()) + private var targetFrames by mutableStateOf(emptyList>()) /** * Set to a non-null value only when actively animating between screens as the result of a call * to [updateBackstack]. This is a [MutableState] because it's used to derive the value of * [activeFrames], and so it needs to be observable. */ - private var activeTransition: ActiveTransition? by mutableStateOf(null) + private var activeTransition: ActiveTransition? by mutableStateOf(null) - override val activeFrames: List> by derivedStateOf { + override val activeFrames: List> by derivedStateOf { activeTransition?.let { transition -> if (transition.popping) { listOf(transition.toFrame, transition.fromFrame) } else { listOf(transition.fromFrame, transition.toFrame) } - } ?: listOf(BackstackFrame(displayedKeys.last())) + } ?: listOf(FrameAndModifier(displayedFrames.last())) } /** @@ -127,42 +129,43 @@ internal class TransitionController( suspend fun runTransitionAnimations() { // This flow handles backpressure by conflating: if targetKeys is changed multiple times while // an animation is running, we'll only get a single emission when it finishes. - snapshotFlow { targetKeys }.collect { targetKeys -> - if (displayedKeys.last() == targetKeys.last()) { + snapshotFlow { targetFrames }.collect { targetFrames -> + if (displayedFrames.last().key == targetFrames.last().key) { // The visible screen didn't change, so we don't need to animate, but we need to update our // active list for the next time we check for navigation direction. - displayedKeys = targetKeys + displayedFrames = targetFrames return@collect } // The top of the stack was changed, so animate to the new top. - animateTransition(fromKeys = displayedKeys, toKeys = targetKeys) + animateTransition(fromFrames = displayedFrames, toFrames = targetFrames) } } - override fun updateBackstack(keys: List) { + override fun updateBackstack(frames: List>) { // Always remember the latest stack, so if this call is happening during a transition we can // detect that when the transition finishes and start the next transition. - targetKeys = keys + targetFrames = frames // This is the first update, so we don't animate, and need to show the backstack as-is // immediately. - if (displayedKeys.isEmpty()) { - displayedKeys = keys + if (displayedFrames.isEmpty()) { + displayedFrames = frames } } /** * Called when [updateBackstack] gets a new backstack with a new top frame while idle, or after a - * transition if the [targetKeys]' top is not [displayedKeys]' top. + * transition if the [targetFrames]' top is not [displayedFrames]' top. */ - @OptIn(ExperimentalCoroutinesApi::class) - private suspend fun animateTransition(fromKeys: List, toKeys: List) { + private suspend fun animateTransition( + fromFrames: List>, toFrames: List> + ) { check(activeTransition == null) { "Can only start transitioning while idle." } - val fromKey = fromKeys.last() - val toKey = toKeys.last() - val popping = toKey in fromKeys + val fromFrame = fromFrames.last() + val toFrame = toFrames.last() + val popping = fromFrames.firstOrNull { it.key == toFrame.key } != null val progress = Animatable(0f) val fromVisibility = derivedStateOf { 1f - progress.value } @@ -170,11 +173,14 @@ internal class TransitionController( // Wrap modifier functions in each their own recompose scope so that if they read the visibility // (or any other state) directly, the modified node will actually be updated. + @SuppressLint("UnnecessaryComposedModifier") val fromModifier = Modifier.composed { with(transition!!) { modifierForScreen(fromVisibility, isTop = popping) } } + + @SuppressLint("UnnecessaryComposedModifier") val toModifier = Modifier.composed { with(transition!!) { modifierForScreen(toVisibility, isTop = !popping) @@ -182,15 +188,15 @@ internal class TransitionController( } activeTransition = ActiveTransition( - fromFrame = BackstackFrame(fromKey, fromModifier), - toFrame = BackstackFrame(toKey, toModifier), + fromFrame = FrameAndModifier(fromFrame, fromModifier), + toFrame = FrameAndModifier(toFrame, toModifier), popping = popping ) - val oldActiveKeys = displayedKeys - displayedKeys = targetKeys + val oldActiveFrames = displayedFrames + displayedFrames = targetFrames - onTransitionStarting!!(oldActiveKeys, displayedKeys, if (popping) Backward else Forward) + onTransitionStarting!!(oldActiveFrames, displayedFrames, if (popping) Backward else Forward) progress.animateTo(1f, animationSpec!!) activeTransition = null onTransitionFinished!!() diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/TransitionControllerTest.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/TransitionControllerTest.kt index b331779..04101df 100644 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/TransitionControllerTest.kt +++ b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/TransitionControllerTest.kt @@ -3,7 +3,7 @@ package com.zachklipp.compose.backstack import androidx.compose.animation.core.TweenSpec import com.google.common.truth.Truth.assertThat import com.zachklipp.compose.backstack.BackstackTransition.Crossfade -import com.zachklipp.compose.backstack.FrameController.BackstackFrame +import com.zachklipp.compose.backstack.FrameController.FrameAndModifier import kotlinx.coroutines.CoroutineScope import org.junit.Test import kotlin.coroutines.EmptyCoroutineContext @@ -22,7 +22,8 @@ class TransitionControllerTest { } @Test fun `initial update sets activeFrames`() { - controller.updateBackstack(listOf("hello")) - assertThat(controller.activeFrames).containsExactly(BackstackFrame("hello")) + val frame = BackstackFrame("hello", "hello") {} + controller.updateBackstack(listOf(frame)) + assertThat(controller.activeFrames).containsExactly(FrameAndModifier(frame)) } }