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)) } }