diff --git a/buildSrc/src/main/java/default-android-config.gradle.kts b/buildSrc/src/main/java/default-android-config.gradle.kts index 2ddc85d..20433a9 100644 --- a/buildSrc/src/main/java/default-android-config.gradle.kts +++ b/buildSrc/src/main/java/default-android-config.gradle.kts @@ -23,7 +23,10 @@ configure { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - buildFeatures.compose = true + buildFeatures.apply { + buildConfig = false + compose = true + } composeOptions { kotlinCompilerVersion = Versions.kotlin diff --git a/compose-backstack-viewer/api/compose-backstack-viewer.api b/compose-backstack-viewer/api/compose-backstack-viewer.api index b405749..c4784b0 100644 --- a/compose-backstack-viewer/api/compose-backstack-viewer.api +++ b/compose-backstack-viewer/api/compose-backstack-viewer.api @@ -2,13 +2,6 @@ public final class com/zachklipp/compose/backstack/viewer/BackstackViewerAppKt { public static final fun BackstackViewerApp (Ljava/util/List;Ljava/util/List;Landroidx/compose/runtime/Composer;II)V } -public final class com/zachklipp/compose/backstack/viewer/BuildConfig { - public static final field BUILD_TYPE Ljava/lang/String; - public static final field DEBUG Z - public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; - public fun ()V -} - public final class com/zachklipp/compose/backstack/viewer/ComposableSingletons$AppScreenKt { public static final field INSTANCE Lcom/zachklipp/compose/backstack/viewer/ComposableSingletons$AppScreenKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; diff --git a/compose-backstack-viewer/build.gradle.kts b/compose-backstack-viewer/build.gradle.kts index 6e55df2..f8940fe 100644 --- a/compose-backstack-viewer/build.gradle.kts +++ b/compose-backstack-viewer/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { compileOnly(Dependencies.Compose.tooling) api(project(":compose-backstack")) + api(project(":compose-backstack-xray")) implementation(Dependencies.AndroidX.appcompat) implementation(Dependencies.Compose.icons) 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 c32331c..b181c92 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 @@ -42,7 +42,9 @@ import com.zachklipp.compose.backstack.Backstack import com.zachklipp.compose.backstack.BackstackTransition import com.zachklipp.compose.backstack.BackstackTransition.Crossfade import com.zachklipp.compose.backstack.BackstackTransition.Slide -import com.zachklipp.compose.backstack.InspectionGestureDetector +import com.zachklipp.compose.backstack.defaultBackstackAnimation +import com.zachklipp.compose.backstack.rememberTransitionController +import com.zachklipp.compose.backstack.xray.xrayed private val DEFAULT_BACKSTACKS = listOf( listOf("one"), @@ -143,31 +145,29 @@ private fun AppControls(model: AppModel) { @Composable private fun AppScreens(model: AppModel) { val animation = if (model.slowAnimations) { - remember { - TweenSpec(durationMillis = 2000) - } + remember { TweenSpec(durationMillis = 2000) } } else null MaterialTheme(colors = lightColors()) { - InspectionGestureDetector(enabled = model.inspectionEnabled) { inspectionParams -> Backstack( backstack = model.currentBackstack, - transition = model.selectedTransition.second, - animationBuilder = animation, + frameController = rememberTransitionController( + transition = model.selectedTransition.second, + animationSpec = animation ?: defaultBackstackAnimation(), + onTransitionStarting = { from, to, direction -> + println( + """ + Transitioning $direction: + from: $from + to: $to + """.trimIndent() + ) + }, + onTransitionFinished = { println("Transition finished.") } + ).xrayed(model.inspectionEnabled), modifier = Modifier .fillMaxSize() .border(width = 3.dp, color = Color.Red), - inspectionParams = inspectionParams, - onTransitionStarting = { from, to, direction -> - println( - """ - Transitioning $direction: - from: $from - to: $to - """.trimIndent() - ) - }, - onTransitionFinished = { println("Transition finished.") } ) { screen -> AppScreen( name = screen, @@ -178,7 +178,6 @@ private fun AppScreens(model: AppModel) { } } } -} @Composable private fun RadioButton( diff --git a/compose-backstack-xray/README.md b/compose-backstack-xray/README.md new file mode 100644 index 0000000..ad5e47a --- /dev/null +++ b/compose-backstack-xray/README.md @@ -0,0 +1 @@ +Workaround for Dokka bug. See https://github.com/Kotlin/dokka/issues/1265. diff --git a/compose-backstack-xray/api/compose-backstack-xray.api b/compose-backstack-xray/api/compose-backstack-xray.api new file mode 100644 index 0000000..e4e4e05 --- /dev/null +++ b/compose-backstack-xray/api/compose-backstack-xray.api @@ -0,0 +1,4 @@ +public final class com/zachklipp/compose/backstack/xray/XrayControllerKt { + public static final fun xrayed (Lcom/zachklipp/compose/backstack/FrameController;ZLandroidx/compose/runtime/Composer;I)Lcom/zachklipp/compose/backstack/FrameController; +} + diff --git a/compose-backstack-xray/build.gradle.kts b/compose-backstack-xray/build.gradle.kts new file mode 100644 index 0000000..3938222 --- /dev/null +++ b/compose-backstack-xray/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") + id("default-android-config") + kotlin("android") + id("org.jetbrains.dokka") + id("publish-to-maven") +} + +android { + lintOptions { + // Workaround for lint bug. + disable += "InvalidFragmentVersionForActivityResult" + } +} + +dependencies { + api(project(":compose-backstack")) + + implementation(Dependencies.Compose.foundation) + implementation(Dependencies.Compose.tooling) + + testImplementation(Dependencies.Test.junit) + testImplementation(Dependencies.Test.truth) + + androidTestImplementation(Dependencies.AndroidX.junitExt) + androidTestImplementation(Dependencies.Compose.test) +} diff --git a/compose-backstack-xray/consumer-rules.pro b/compose-backstack-xray/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/compose-backstack-xray/proguard-rules.pro b/compose-backstack-xray/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/compose-backstack-xray/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/compose-backstack-xray/src/androidTest/AndroidManifest.xml b/compose-backstack-xray/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..001fddc --- /dev/null +++ b/compose-backstack-xray/src/androidTest/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/compose-backstack-xray/src/androidTest/res/values/styles.xml b/compose-backstack-xray/src/androidTest/res/values/styles.xml new file mode 100644 index 0000000..f0cebe7 --- /dev/null +++ b/compose-backstack-xray/src/androidTest/res/values/styles.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose-backstack-xray/src/main/AndroidManifest.xml b/compose-backstack-xray/src/main/AndroidManifest.xml new file mode 100644 index 0000000..131f39f --- /dev/null +++ b/compose-backstack-xray/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + 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 new file mode 100644 index 0000000..5ae6a35 --- /dev/null +++ b/compose-backstack-xray/src/main/java/com/zachklipp/compose/backstack/xray/XrayController.kt @@ -0,0 +1,125 @@ +package com.zachklipp.compose.backstack.xray + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +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.FrameController +import com.zachklipp.compose.backstack.FrameController.BackstackFrame +import com.zachklipp.compose.backstack.NoopFrameController +import kotlin.math.sin + +/** + * Returns a [FrameController] that wraps this [FrameController] and, when [enabled], displays all + * 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 { + it.enabled = enabled + it.wrappedController = this + } + +private class XrayController : FrameController { + + var enabled: Boolean by mutableStateOf(false) + var wrappedController: FrameController by mutableStateOf(NoopFrameController()) + + private var offsetDpX by mutableStateOf(500.dp) + private var offsetDpY by mutableStateOf(10.dp) + private var rotationX by mutableStateOf(0f) + private var rotationY by mutableStateOf(10f) + private var scaleFactor by mutableStateOf(.5f) + private var alpha by mutableStateOf(.4f) + private var overlayAlpha by mutableStateOf(.2f) + + private var activeKeys by mutableStateOf(emptyList()) + + private val controlModifier = Modifier.pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scaleFactor *= zoom + // Dragging left-and-right rotates around the vertical Y axis. + rotationY += pan.x / 5f + } + } + + // Use derivedStateOf to cache the mapped list. + override val activeFrames by derivedStateOf { + if (!enabled) wrappedController.activeFrames else { + activeKeys.mapIndexed { index, key -> + val modifier = Modifier.modifierForFrame(index, activeKeys.size, 1f) + return@mapIndexed BackstackFrame(key, modifier) + } + } + } + + override fun updateBackstack(keys: List) { + activeKeys = keys + wrappedController.updateBackstack(keys) + } + + /** + * Calculates a [Modifier] to apply to a screen when in inspection mode. + * + * The top screen will be drawn without any transformations. + * All other screens will be drawn as a 3D stack. + */ + private fun Modifier.modifierForFrame( + frameIndex: Int, + frameCount: Int, + visibility: Float + ): Modifier { + // Draw the top screen as an overlay so it's clear where touch targets are. Once + // compose supports transforming inputs as well as outputs, the top screen can + // participate in scaling/rotation too. + val isTop = frameIndex == frameCount - 1 + return graphicsLayer { + // drawLayer will scale around the center of the bounds, so we need to offset relative + // to that so the entire stack stays centered. + val centerOffset = + // Don't need to adjust the pivot point if there's only one screen. + if (frameCount == 1) 0f + // Add -1 + visibility so new screens animate "out of" the previous one. + else (frameIndex - 1f + visibility) - frameCount / 3f + + val scale = if (isTop) 1f else scaleFactor + scaleX = scale + scaleY = scale + + translationX = (if (isTop) 0.dp else { + // Adjust by screenCount to squeeze more in as the count increases. + val densityFactor = 10f / frameCount + // Adjust X offset by sin(rotation) so it looks 3D. + val xRotation = sin(Math.toRadians(this@XrayController.rotationY.toDouble())).toFloat() + (offsetDpX * centerOffset * scale * densityFactor * xRotation) + }).toPx() + translationY = (if (isTop) 0.dp else offsetDpY * centerOffset * scale).toPx() + + rotationX = if (isTop) 0f else this@XrayController.rotationX + rotationY = if (isTop) 0f else this@XrayController.rotationY + + // This is the only transformation applied to the top screen, so it has some extra logic. + alpha = when { + // If there's only one screen in the stack, don't transform it at all. + frameCount == 1 -> 1f + isTop -> overlayAlpha + else -> this@XrayController.alpha + // Adjust alpha by visibility to make transition less jarring when adding/removing + // screens. + } * visibility + + cameraDistance = DefaultCameraDistance * 3 + }.then( + // This is the top screen, it's the overlay and holds the control gesture. + if (isTop) controlModifier else Modifier + ) + } +} diff --git a/compose-backstack/api/compose-backstack.api b/compose-backstack/api/compose-backstack.api index fa65426..c88b7fd 100644 --- a/compose-backstack/api/compose-backstack.api +++ b/compose-backstack/api/compose-backstack.api @@ -1,66 +1,58 @@ -public final class com/zachklipp/compose/backstack/BackstackInspectorKt { - public static final fun constrained (Lcom/zachklipp/compose/backstack/InspectionParams;)Lcom/zachklipp/compose/backstack/InspectionParams; -} - 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;Lcom/zachklipp/compose/backstack/InspectionParams;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/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 abstract interface class com/zachklipp/compose/backstack/BackstackTransition { - public abstract fun modifierForScreen (FZ)Landroidx/compose/ui/Modifier; + public abstract fun modifierForScreen (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/State;Z)Landroidx/compose/ui/Modifier; } public final class com/zachklipp/compose/backstack/BackstackTransition$Crossfade : com/zachklipp/compose/backstack/BackstackTransition { public static final field $stable I public static final field INSTANCE Lcom/zachklipp/compose/backstack/BackstackTransition$Crossfade; - public fun modifierForScreen (FZ)Landroidx/compose/ui/Modifier; + public fun modifierForScreen (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/State;Z)Landroidx/compose/ui/Modifier; } public final class com/zachklipp/compose/backstack/BackstackTransition$Slide : com/zachklipp/compose/backstack/BackstackTransition { public static final field $stable I public static final field INSTANCE Lcom/zachklipp/compose/backstack/BackstackTransition$Slide; - public fun modifierForScreen (FZ)Landroidx/compose/ui/Modifier; -} - -public final class com/zachklipp/compose/backstack/BuildConfig { - public static final field BUILD_TYPE Ljava/lang/String; - public static final field DEBUG Z - public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; - public fun ()V + public fun modifierForScreen (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/State;Z)Landroidx/compose/ui/Modifier; } -public final class com/zachklipp/compose/backstack/ChildSavedStateRegistryKt { - public static final fun ChildSavedStateRegistry (ZLandroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/saveable/SaveableStateRegistry; +public final class com/zachklipp/compose/backstack/BackstackTransitionKt { + public static final fun modifierForScreen (Lcom/zachklipp/compose/backstack/BackstackTransition;Landroidx/compose/runtime/State;Z)Landroidx/compose/ui/Modifier; } -public final class com/zachklipp/compose/backstack/InspectionGestureDetectorKt { - public static final fun InspectionGestureDetector (ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V +public abstract interface class com/zachklipp/compose/backstack/FrameController { + public abstract fun getActiveFrames ()Ljava/util/List; + public abstract fun updateBackstack (Ljava/util/List;)V } -public final class com/zachklipp/compose/backstack/InspectionParams { - public synthetic fun (FFFFFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (FFFFFFFLkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1-D9Ej5fM ()F - public final fun component2-D9Ej5fM ()F - public final fun component3 ()F - public final fun component4 ()F - public final fun component5 ()F - public final fun component6 ()F - public final fun component7 ()F - public final fun copy-pBklqvs (FFFFFFF)Lcom/zachklipp/compose/backstack/InspectionParams; - public static synthetic fun copy-pBklqvs$default (Lcom/zachklipp/compose/backstack/InspectionParams;FFFFFFFILjava/lang/Object;)Lcom/zachklipp/compose/backstack/InspectionParams; +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 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 fun equals (Ljava/lang/Object;)Z - public final fun getOffsetX-D9Ej5fM ()F - public final fun getOffsetY-D9Ej5fM ()F - public final fun getOpacity ()F - public final fun getOverlayOpacity ()F - public final fun getRotationXDegrees ()F - public final fun getRotationYDegrees ()F - public final fun getScale ()F + public final fun getKey ()Ljava/lang/Object; + public final fun getModifier ()Landroidx/compose/ui/Modifier; public fun hashCode ()I public fun toString ()Ljava/lang/String; } +public final class com/zachklipp/compose/backstack/FrameControllerKt { + public static final fun NoopFrameController ()Lcom/zachklipp/compose/backstack/FrameController; +} + +public final class com/zachklipp/compose/backstack/TransitionControllerKt { + public static final fun defaultBackstackAnimation (Landroidx/compose/runtime/Composer;I)Landroidx/compose/animation/core/AnimationSpec; + public static final fun rememberTransitionController (Lcom/zachklipp/compose/backstack/BackstackTransition;Landroidx/compose/animation/core/AnimationSpec;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)Lcom/zachklipp/compose/backstack/FrameController; +} + public final class com/zachklipp/compose/backstack/TransitionDirection : java/lang/Enum { public static final field Backward Lcom/zachklipp/compose/backstack/TransitionDirection; public static final field Forward Lcom/zachklipp/compose/backstack/TransitionDirection; 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 new file mode 100644 index 0000000..b47214d --- /dev/null +++ b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackStateTest.kt @@ -0,0 +1,138 @@ +package com.zachklipp.compose.backstack + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +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.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test + +class BackstackStateTest { + + @get:Rule + val compose = createComposeRule() + + @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++ }) + } + } + + // Update some state on the first screen. + compose.onNodeWithText("one: 0").assertIsDisplayed().performClick() + compose.onNodeWithText("one: 1").assertIsDisplayed() + + // Navigate forward to another screen. + backstack += "two" + compose.waitForIdle() + + compose.onNodeWithText("one", substring = true).assertDoesNotExist() + compose.onNodeWithText("two: 0").assertIsDisplayed() + + // Navigate back. + backstack -= "two" + compose.waitForIdle() + + // Make sure the state was restored. + compose.onNodeWithText("one: 1").assertIsDisplayed() + } + + @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++ }) + } + } + + // Update some state on the second screen. + compose.onNodeWithText("two: 0").assertIsDisplayed().performClick() + compose.onNodeWithText("two: 1").assertIsDisplayed() + + // Navigate backwards then forwards again to the same second screen. + backstack -= "two" + compose.waitForIdle() + backstack += "two" + compose.waitForIdle() + + compose.onNodeWithText("two: 0").assertIsDisplayed() + compose.onNodeWithText("two: 1").assertDoesNotExist() + } + + @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++ }) + } + } + + // Update some state on the first screen. + compose.onNodeWithText("one: 0").assertIsDisplayed().performClick() + compose.onNodeWithText("one: 1").assertIsDisplayed() + + // Navigate forward to another screen. + backstack = listOf("one", "two") + compose.waitForIdle() + + // Remove one from the backstack - this should clear its saved state, even though it's currently + // hidden. + backstack = listOf("two") + compose.waitForIdle() + + // Add it back so we can navigate back, then do so. + backstack = listOf("one", "two") + compose.waitForIdle() + backstack = listOf("one") + compose.waitForIdle() + + // Make sure the state was restored. + compose.onNodeWithText("one: 0").assertIsDisplayed() + compose.onNodeWithText("one: 1").assertDoesNotExist() + } + + @Test fun screens_are_skipped() { + val backstack = mutableStateListOf("one") + val transcript = mutableListOf() + compose.setContent { + Backstack(backstack, frameController = NoopFrameController()) { + BasicText(it) + DisposableEffect(Unit) { + transcript += "+$it" + onDispose { transcript += "-$it" } + } + } + } + + assertThat(transcript).containsExactly("+one") + transcript.clear() + + // Navigate forward. + backstack += "two" + compose.waitForIdle() + + assertThat(transcript).containsExactly("-one", "+two") + transcript.clear() + + // Navigate back again. + backstack -= "two" + compose.waitForIdle() + + assertThat(transcript).containsExactly("-two", "+one") + } +} diff --git a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackTransitionsTest.kt similarity index 58% rename from compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt rename to compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackTransitionsTest.kt index fb329be..ee44875 100644 --- a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt +++ b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackTransitionsTest.kt @@ -16,7 +16,7 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class BackstackComposableTest { +class BackstackTransitionsTest { @get:Rule val compose = createComposeRule() @@ -44,13 +44,23 @@ class BackstackComposableTest { } @Test - fun transition_slide() { - assertTransition(Slide) + fun transition_slide_forward() { + assertTransition(Slide, forward = true) } @Test - fun transition_crossfade() { - assertTransition(Crossfade) + fun transition_crossfade_forward() { + assertTransition(Crossfade, forward = true) + } + + @Test + fun transition_slide_backward() { + assertTransition(Slide, forward = false) + } + + @Test + fun transition_crossfade_backward() { + assertTransition(Crossfade, forward = false) } private fun assertInitialStateWithSingleScreen(transition: BackstackTransition) { @@ -72,42 +82,46 @@ class BackstackComposableTest { compose.onNodeWithText("one").assertDoesNotExist() } - private fun assertTransition(transition: BackstackTransition) { - val originalBackstack = listOf("one") - val destinationBackstack = listOf("one", "two") - var backstack by mutableStateOf(originalBackstack) + private fun assertTransition(transition: BackstackTransition, forward: Boolean) { + val firstBackstack = listOf("one") + val secondBackstack = listOf("one", "two") + var backstack by mutableStateOf(if (forward) firstBackstack else secondBackstack) compose.mainClock.autoAdvance = false compose.setContent { - Backstack( - backstack, - animationBuilder = animation, + Backstack( + backstack, + frameController = rememberTransitionController( + animationSpec = animation, transition = transition - ) { BasicText(it) } + ) + ) { BasicText(it) } } + val initialText = if (forward) "one" else "two" + val newText = if (forward) "two" else "one" - compose.onNodeWithText("one").assertIsDisplayed() - compose.onNodeWithText("two").assertDoesNotExist() + compose.onNodeWithText(initialText).assertIsDisplayed() + compose.onNodeWithText(newText).assertDoesNotExist() compose.runOnUiThread { - backstack = destinationBackstack + backstack = if (forward) secondBackstack else firstBackstack } - compose.onNodeWithText("one").assertIsDisplayed() - compose.onNodeWithText("two").assertDoesNotExist() + compose.onNodeWithText(initialText).assertIsDisplayed() + compose.onNodeWithText(newText).assertDoesNotExist() compose.mainClock.advanceTimeBy(250) - compose.onNodeWithText("one").assertIsDisplayed() - compose.onNodeWithText("two").assertIsDisplayed() + compose.onNodeWithText(initialText).assertIsDisplayed() + compose.onNodeWithText(newText).assertIsDisplayed() compose.mainClock.advanceTimeBy(750) - compose.onNodeWithText("one").assertIsDisplayed() - compose.onNodeWithText("two").assertIsDisplayed() + compose.onNodeWithText(initialText).assertIsDisplayed() + compose.onNodeWithText(newText).assertIsDisplayed() compose.mainClock.advanceTimeBy(1000) - compose.onNodeWithText("one").assertDoesNotExist() - compose.onNodeWithText("two").assertIsDisplayed() + compose.onNodeWithText(initialText).assertDoesNotExist() + compose.onNodeWithText(newText).assertIsDisplayed() } } 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 861c653..9a1b3a7 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,27 +2,20 @@ package com.zachklipp.compose.backstack -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationEndReason -import androidx.compose.animation.core.AnimationEndReason.Finished import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.TweenSpec import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalContext -import com.zachklipp.compose.backstack.TransitionDirection.Backward -import com.zachklipp.compose.backstack.TransitionDirection.Forward -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch - -/** Used to hide screens when not transitioning. */ -internal val HIDDEN_MODIFIER = Modifier.alpha(0f) +import kotlin.DeprecationLevel.ERROR /** * Identifies which direction a transition is being performed in. @@ -32,28 +25,6 @@ enum class TransitionDirection { Backward } -/** - * Wraps each screen composable with the transition modifier derived from the current animation - * progress. - */ -private data class ScreenWrapper( - val key: T, - val transition: @Composable (progress: Float, @Composable () -> Unit) -> Unit -) - -internal data class ScreenProperties( - val modifier: Modifier, - val isVisible: Boolean -) - -private val DefaultBackstackAnimation: AnimationSpec - @Composable get() { - val context = LocalContext.current - return TweenSpec( - durationMillis = context.resources.getInteger(android.R.integer.config_shortAnimTime) - ) - } - /** * 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 @@ -61,24 +32,23 @@ private val DefaultBackstackAnimation: AnimationSpec * * The [backstack] must follow some rules: * - Must always contain at least one item. - * - Elements in the stack must implement `equals` and not change over the lifetime of the screen. - * If the key changes, it will be considered a new screen and any state held by the screen will + * - 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 * 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 * lost if they are moved around. If the list contains duplicates, an [IllegalArgumentException] * will be thrown. * - * This composable does not actually provide any navigation functionality – it just renders - * transitions between stacks of screens. It can be plugged into your navigation library of choice, - * or just used on its own with a simple list of screens. + * This composable does not actually provide any navigation functionality – it just manages state, + * and delegates to [FrameController]s to do things like animate screen transitions. It can be + * plugged into your navigation library of choice, or just used on its own with a simple list of + * screens. * - * ## Instance state caching + * ## Saveable state caching * - * Screens that contain persistable state using the (i.e. via - * [savedInstanceState][androidx.compose.runtime.savedinstancestate.savedInstanceState]) will - * automatically have that state saved when they are hidden, and restored the next time they're - * shown. + * Screens that contain persistable state using [rememberSaveable] will automatically have that + * state saved when they are hidden, and restored the next time they're shown. * * ## Example * @@ -95,7 +65,7 @@ private val DefaultBackstackAnimation: AnimationSpec * ) * * @Composable fun App() { - * var backstack by state { listOf(Screen.ContactList) } + * var backstack by remember { mutableStateOf(listOf(Screen.ContactList)) } * val navigator = remember { * Navigator( * push = { backstack += it }, @@ -116,214 +86,112 @@ private val DefaultBackstackAnimation: AnimationSpec * @param backstack 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 transition The [BackstackTransition] that defines how to animate between screens when - * [backstack] changes. [BackstackTransition] contains a few simple pre-fab transitions. - * @param animationBuilder Defines the curve and speed of transition animations. - * @param onTransitionStarting Callback that will be invoked before starting each transition. - * @param onTransitionFinished Callback that will be invoked after each transition finishes. - * @param inspectionParams Optional [InspectionParams] that, when not null, enables inspection mode, - * which will draw all the screens in the backstack as a translucent 3D stack. You can wrap your - * backstack with [InspectionGestureDetector] to automatically generate [InspectionParams] - * controlled by touch gestures. - * @param drawScreen Called with each element of [backstack] to render it. + * @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. */ @OptIn(ExperimentalCoroutinesApi::class) @Composable fun Backstack( backstack: List, modifier: Modifier = Modifier, - transition: BackstackTransition = BackstackTransition.Slide, - animationBuilder: AnimationSpec? = null, - onTransitionStarting: ((from: List, to: List, TransitionDirection) -> Unit)? = null, - onTransitionFinished: (() -> Unit)? = null, - inspectionParams: InspectionParams? = null, - drawScreen: @Composable (T) -> Unit + frameController: FrameController, + content: @Composable (T) -> Unit ) { - require(backstack.isNotEmpty()) { "Backstack must contain at least 1 screen." } - require(backstack.distinct().size == backstack.size) { - "Backstack must not contain duplicates: $backstack" - } - val scope = rememberCoroutineScope() - - // When transitioning, contains a stable cache of the screens actually being displayed. Will not - // change even if backstack changes during the transition. - var activeKeys by remember { mutableStateOf(backstack) } - // The "top" screen being transitioned to. Used at the end of the transition to detect if the - // backstack changed and needs another transition immediately. - var targetTop by remember { mutableStateOf(backstack.last()) } - // Wrap all items to draw in a list, so that they will all share a constant "compositional - // position", which allows us to use @Pivotal machinery to preserve state. - var activeStackDrawers by remember { mutableStateOf(emptyList>()) } - // Defines the progress of the current transition animation in terms of visibility of the top - // screen. 1 means top screen is visible, 0 means top screen is entirely hidden. Must be 1 when - // no transition in progress. - val transitionProgress = remember { Animatable(1f) } - // Null means not transitioning. - var direction by remember { mutableStateOf(null) } - // Callback passed to animations to cleanup after the transition is done. - val onTransitionEnd = remember { - { reason: AnimationEndReason, _: Float -> - if (reason == Finished) { - direction = null - scope.launch { - transitionProgress.snapTo(1f) - onTransitionFinished?.invoke() - } - } - } - } - val animation = animationBuilder ?: DefaultBackstackAnimation - val inspector = remember { BackstackInspector(scope) } - inspector.params = inspectionParams + val stateHolder = rememberSaveableStateHolder() + val stateCustodian = remember { BackstackStateCustodian(stateHolder) } - if (direction == null && activeKeys != backstack) { - // Not in the middle of a transition and we got a new backstack. - // This will also run after a transition, to clean up old keys out of the temporary backstack. - - if (backstack.last() == targetTop) { - // Don't need to transition, but some hidden keys changed to so we need to update the active - // list to ensure hidden screens that no longer exist are torn down. - activeKeys = backstack - } else { - // Remember the top we're transitioning to so we don't re-transition afterwards if we're - // showing the same top. - targetTop = backstack.last() - - // If the new top is in the old backstack, then it has probably already been seen, so the - // navigation is logically backwards, even if the new backstack actually contains more - // screens. - direction = if (targetTop in activeKeys) Backward else Forward - - // Mutate the stack for the transition so the keys that need to be temporarily shown are in - // the right place. - val oldTop = activeKeys.last() - val newKeys = backstack.toMutableList() - if (direction == Backward) { - // We need to put the current screen on the top of the new active stack so it will animate - // out. - newKeys += oldTop - - // When going back the top screen needs to start off as visible. - // Need to start the coroutine undispatched so the snap happens before the frame is drawn. - scope.launch(start = UNDISPATCHED) { - transitionProgress.snapTo(1f) - val result = transitionProgress.animateTo(0f, animationSpec = animation) - onTransitionEnd(result.endReason, result.endState.value) - } - } else { - // If the current screen is not the new second-last screen, we need to move it to that - // position so it animates out when going forward. This is true whether or not the current - // screen is actually in the new backstack at all. - newKeys -= targetTop - newKeys -= oldTop - newKeys += oldTop - newKeys += targetTop - - // When going forward, the top screen needs to start off as invisible. - scope.launch(start = UNDISPATCHED) { - transitionProgress.snapTo(0f) - val result = transitionProgress.animateTo(1f, animationSpec = animation) - onTransitionEnd(result.endReason, result.endState.value) - } - } - onTransitionStarting?.invoke(activeKeys, backstack, direction!!) - activeKeys = newKeys - } - } - - // Only refresh the wrappers when the keys or opacity actually change. - // We need to regenerate these if the keys in the backstack change even if the top doesn't change - // because we need to dispose of old screens that are no longer rendered. + // 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 + // not happen immediately. // - // Note: This block must not contain any control flow logic that causes the screen composables - // to be invoked from different source locations. If it does, those screens will lose all their - // state as soon as a different branch is taken. See @Pivotal for more information. - activeStackDrawers = remember(activeKeys, transition) { - activeKeys.mapIndexed { index, key -> - // This wrapper composable will remain in the composition as long as its key is - // in the backstack. So we can use remember here to hold state that should persist - // even when the screen is hidden. - ScreenWrapper(key) { progress, children -> - // Inspector and transition are mutually exclusive. - val screenProperties = if (inspector.isInspectionActive) { - calculateInspectionModifier(inspector, index, activeKeys.size, progress) - } else { - calculateRegularModifier(transition, index, activeKeys.size, progress) - } - - // This must be called even if the screen is not visible, so the screen's state gets - // cached before it's removed from the composition. - val savedStateRegistry = ChildSavedStateRegistry(screenProperties.isVisible) - - if (!screenProperties.isVisible) { - // Remove the screen from the composition. - // This must be done after updating the savedState visibility so it has a chance - // to query providers before they're unregistered. - return@ScreenWrapper - } - - CompositionLocalProvider(LocalSaveableStateRegistry provides savedStateRegistry) { - Box(screenProperties.modifier) { children() } - } - } - } + // Note: It's probably bad that this call is not done in a side effect. If the composition fails, + // the controller won't know about it and will continue animating or whatever it was doing. + // 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) + + // Remove stale state from keys no longer in the backstack, but only once the composition has been + // committed. + SideEffect { + stateCustodian.removeStaleKeys(backstack) } // Actually draw the screens. Box(modifier = modifier.clip(RectangleShape)) { - activeStackDrawers.forEach { (item, transition) -> + // 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) -> // Key is a convenience helper that treats its arguments as @Pivotal. This is how state // preservation is implemented. 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) { - transition(transitionProgress.value) { drawScreen(item) } + // This function will automatically save and restore saveable state when it's skipped or + // composed again. + stateHolder.SaveableStateProvider(item) { + Box(frameControlModifier) { + content(item) + } + } } } } } -internal fun calculateRegularModifier( - transition: BackstackTransition, - index: Int, - count: Int, - progress: Float -): ScreenProperties { - val visibility = when (index) { - // transitionProgress always corresponds directly to visibility of the top screen. - count - 1 -> progress - // The second-to-top screen has the inverse visibility of the top screen. - count - 2 -> 1f - progress - // All other screens should not be drawn at all. They're only kept around to maintain - // their composable state. - else -> 0f - } +/** + * 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). + * + * See the documentation on [Backstack] for more information. + * + * @param backstack 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 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) +} - val screenModifier = when (visibility) { - 0f -> HIDDEN_MODIFIER - 1f -> Modifier - else -> transition.modifierForScreen(visibility, index == count - 1) - } - return ScreenProperties( - modifier = screenModifier, - isVisible = visibility != 0f - ) +@Suppress("DeprecatedCallableAddReplaceWith", "UNUSED_PARAMETER") +@Deprecated("Use a different overload.", level = ERROR) +fun Backstack( + backstack: 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 +) { + throw UnsupportedOperationException("This function exists only for migration assistance.") } -@Composable -private fun calculateInspectionModifier( - inspector: BackstackInspector, - index: Int, - count: Int, - progress: Float -): ScreenProperties { - val visibility = when (index) { - count - 1 -> progress - // All previous screens are always visible in inspection mode. - else -> 1f +/** + * Wrapper around a [SaveableStateHolder] that removes keys after they're removed from the + * backstack. + */ +private class BackstackStateCustodian(private val holder: SaveableStateHolder) { + + private var knownKeys = emptySet() + + fun removeStaleKeys(backstack: List) { + val staleKeys = knownKeys - backstack + knownKeys = backstack.toSet() + staleKeys.forEach(holder::removeState) } - return ScreenProperties( - modifier = inspector.inspectScreen(index, count, visibility), - isVisible = true - ) } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackInspector.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackInspector.kt deleted file mode 100644 index 3306423..0000000 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackInspector.kt +++ /dev/null @@ -1,216 +0,0 @@ -@file:Suppress("UNUSED_PARAMETER") - -package com.zachklipp.compose.backstack - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FloatSpringSpec -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import java.lang.Math.toRadians -import kotlin.math.sin - -/** - * Values used to control the display of the backstack in inspection mode. - * - * To keep the display usable, values will be constrained using the [constrained] function. - * - * @param offsetX The distance between screens along the x axis. - * @param offsetY The distance between screens along the y axis. - * @param rotationXDegrees The rotation in degrees around the x axis (rotates up and down). - * Constrained to `(-90º, 90º)`. - * @param rotationYDegrees The rotation in degrees around the y axis (rotates left and right). - * Constrained to `(-90º, 90º)`. - * @param scale The factor by which to scale all the screens behind the top one. - * Constrained to `[0, 1]`. - * @param opacity The alpha used to draw all the screens behind the top one. - * Constrained to `[0, 1]`. - * @param overlayOpacity The alpha used to draw the top screen, without any other transformations. - * Constrained to `[0, 1]`. - */ -@Immutable -data class InspectionParams( - val offsetX: Dp = 500.dp, - val offsetY: Dp = 10.dp, - val rotationXDegrees: Float = 0f, - val rotationYDegrees: Float = 10f, - val scale: Float = .5f, - val opacity: Float = .4f, - val overlayOpacity: Float = .2f -) - -/** Constrain params to reasonable limits. */ -fun InspectionParams.constrained() = InspectionParams( - offsetX = offsetX.coerceIn(1.dp, 100.dp), - offsetY = offsetY.coerceIn((-50).dp, 50.dp), - rotationXDegrees = rotationXDegrees.coerceIn(-89f, 89f), - rotationYDegrees = rotationYDegrees.coerceIn(-89f, 89f), - scale = scale.coerceIn(0f, 1f), - opacity = opacity.coerceIn(0f, 1f), - overlayOpacity = overlayOpacity.coerceIn(0f, 1f) -) - -internal class BackstackInspector(private val scope: CoroutineScope) { - - private val animation = FloatSpringSpec(stiffness = Spring.StiffnessLow) - - /** - * True when the inspector is in control of rendering. - * Will continue to return true after setting [params] to null until it's finished animating. - */ - var isInspectionActive: Boolean by mutableStateOf(false) - private set - - /** - * Update the parameters used to display the rendering. - * - * Whenever new parameters are passed in, the display will animate towards them, and - * [isInspectionActive] will immediately start returning true. - * - * When null is passed, the display will animate screens back to the default state, and - * inspecting will start returning false _only after_ the default state is reached. - */ - var params: InspectionParams? = null - set(value) { - val constrainedParams = value?.constrained() - if ((field == null) != (constrainedParams == null)) { - if (constrainedParams != null) { - startInspecting() - } else { - stopInspecting() - } - } - constrainedParams?.let { - scope.launch { offsetDpX.animateTo(it.offsetX.value, animation) } - scope.launch { offsetDpY.animateTo(it.offsetY.value, animation) } - scope.launch { rotationX.animateTo(it.rotationXDegrees, animation) } - scope.launch { rotationY.animateTo(it.rotationYDegrees, animation) } - scope.launch { scaleFactor.animateTo(it.scale, animation) } - scope.launch { alpha.animateTo(it.opacity, animation) } - scope.launch { overlayAlpha.animateTo(it.overlayOpacity) } - } - field = constrainedParams - } - - private val offsetDpX = Animatable(INITIAL_OFFSET_X) - private val offsetDpY = Animatable(INITIAL_OFFSET_Y) - private val rotationX = Animatable(INITIAL_ROTATION_X) - private val rotationY = Animatable(INITIAL_ROTATION_Y) - private val scaleFactor = Animatable(INITIAL_SCALE) - private val alpha = Animatable(INITIAL_ALPHA) - private val overlayAlpha = Animatable(INITIAL_OVERLAY_ALPHA) - - /** - * Calculates a [Modifier] to apply to a screen when in inspection mode. - * - * The top screen will be drawn without the usual translations, and only use - * [InspectionParams.overlayOpacity]. All other screens will be drawn as a 3D stack. - * All transformations are animated. - */ - @Suppress("ComposableModifierFactory", "ModifierFactoryExtensionFunction") - @Composable - internal fun inspectScreen( - screenIndex: Int, - screenCount: Int, - visibility: Float - ): Modifier { - // Draw the top screen as an overlay so it's clear where touch targets are. Once - // compose supports transforming inputs as well as outputs, the top screen can - // participate in scaling/rotation too. - val isTop = screenIndex == screenCount - 1 - val density = LocalDensity.current - - // drawLayer will scale around the center of the bounds, so we need to offset relative - // to that so the entire stack stays centered. - val centerOffset by animateFloatAsState( - // Don't need to adjust the pivot point if there's only one screen. - if (screenCount == 1) 0f - // Add -1 + visibility so new screens animate "out of" the previous one. - else (screenIndex - 1f + visibility) - screenCount / 3f - ) - - val scale by animateFloatAsState(if (isTop) 1f else scaleFactor.value) - - val offsetDpX by animateFloatAsState( - if (isTop) 0f else { - // Adjust by screenCount to squeeze more in as the count increases. - val densityFactor = 10f / screenCount - // Adjust X offset by sin(rotation) so it looks 3D. - val xRotation = sin(toRadians(rotationY.value.toDouble())).toFloat() - (centerOffset * offsetDpX.value * scale * densityFactor * xRotation) - } - ) - val offsetDpY by animateFloatAsState(if (isTop) 0f else (centerOffset * offsetDpY.value * scale)) - - val rotationX by animateFloatAsState(if (isTop) 0f else (rotationX.value)) - val rotationY by animateFloatAsState(if (isTop) 0f else (rotationY.value)) - - // This is the only transformation applied to the top screen, so it has some extra logic. - val alpha by animateFloatAsState( - when { - // If there's only one screen in the stack, don't transform it at all. - screenCount == 1 -> 1f - isTop -> overlayAlpha.value - else -> alpha.value - // Adjust alpha by visibility to make transition less jarring when adding/removing - // screens. - } * visibility - ) - - return Modifier.graphicsLayer( - scaleX = scale, - scaleY = scale, - rotationX = rotationX, - rotationY = rotationY, - translationX = with(density) { offsetDpX.dp.toPx() }, - translationY = with(density) { offsetDpY.dp.toPx() }, - alpha = alpha - ) - } - - /** Transition to inspection mode. */ - private fun startInspecting() { - isInspectionActive = true - } - - /** Transition away from inspection mode. */ - private fun stopInspecting() { - scope.launch { - coroutineScope { - launch { offsetDpX.animateTo(INITIAL_OFFSET_X, animation) } - launch { offsetDpY.animateTo(INITIAL_OFFSET_Y, animation) } - launch { rotationX.animateTo(INITIAL_ROTATION_X, animation) } - launch { rotationY.animateTo(INITIAL_ROTATION_Y, animation) } - launch { scaleFactor.animateTo(INITIAL_SCALE, animation) } - launch { alpha.animateTo(INITIAL_ALPHA, animation) } - launch { overlayAlpha.animateTo(INITIAL_OVERLAY_ALPHA, animation) } - } - // Once all the animations are done we need to tell the Backstack that we're done being in - // control. - isInspectionActive = false - } - } - - private companion object { - // Values to use when the inspector is not active (inspecting is false). - const val INITIAL_OFFSET_X = 0f - const val INITIAL_OFFSET_Y = 0f - const val INITIAL_ROTATION_X = 0f - const val INITIAL_ROTATION_Y = 0f - const val INITIAL_SCALE = 1f - const val INITIAL_ALPHA = 1f - const val INITIAL_OVERLAY_ALPHA = 1f - } -} diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt index bea08bb..02dc3c7 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt @@ -2,6 +2,9 @@ package com.zachklipp.compose.backstack +import android.annotation.SuppressLint +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import com.zachklipp.compose.backstack.BackstackTransition.Crossfade @@ -14,7 +17,7 @@ import com.zachklipp.compose.backstack.BackstackTransition.Slide * @see Slide * @see Crossfade */ -interface BackstackTransition { +fun interface BackstackTransition { /** * Returns a [Modifier] to use to draw screen in a [Backstack]. @@ -26,9 +29,8 @@ interface BackstackTransition { * visible, then the top screen is always transitioning _out_, and non-top screens are either * transitioning out or invisible. */ - @Suppress("ModifierFactoryExtensionFunction") - fun modifierForScreen( - visibility: Float, + fun Modifier.modifierForScreen( + visibility: State, isTop: Boolean ): Modifier @@ -36,23 +38,30 @@ interface BackstackTransition { * A simple transition that slides screens horizontally. */ object Slide : BackstackTransition { - @Suppress("ModifierFactoryExtensionFunction") - override fun modifierForScreen( - visibility: Float, + override fun Modifier.modifierForScreen( + visibility: State, isTop: Boolean - ): Modifier = PercentageLayoutOffset( - offset = if (isTop) 1f - visibility else -1 + visibility - ) + ): Modifier = then(PercentageLayoutOffset( + rawOffset = derivedStateOf { if (isTop) 1f - visibility.value else -1 + visibility.value } + )) } /** * A simple transition that crossfades between screens. */ object Crossfade : BackstackTransition { - @Suppress("ModifierFactoryExtensionFunction") - override fun modifierForScreen( - visibility: Float, + override fun Modifier.modifierForScreen( + visibility: State, isTop: Boolean - ): Modifier = Modifier.alpha(visibility) + ): Modifier = alpha(visibility.value) } } + +/** + * Convenience function to make it easier to make composition transitions. + */ +@SuppressLint("ModifierFactoryExtensionFunction") +fun BackstackTransition.modifierForScreen( + visibility: State, + isTop: Boolean +): Modifier = Modifier.modifierForScreen(visibility, isTop) diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/ChildSavedStateRegistry.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/ChildSavedStateRegistry.kt deleted file mode 100644 index a59bc4d..0000000 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/ChildSavedStateRegistry.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.zachklipp.compose.backstack - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ExperimentalComposeApi -import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.currentCompositeKeyHash -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.LocalSaveableStateRegistry -import androidx.compose.runtime.saveable.SaveableStateRegistry -import androidx.compose.runtime.saveable.SaveableStateRegistry.Entry - -/** - * Returns a [SaveableStateRegistry] that will automatically save values from all its registered - * providers whenever [childWillBeComposed] transitions from true to false, and make those values available - * to be restored when [childWillBeComposed] transitions from false to true. - */ -@Suppress("ComposableNaming") -@OptIn(ExperimentalComposeApi::class) -@Composable -fun ChildSavedStateRegistry(childWillBeComposed: Boolean): SaveableStateRegistry { - val parent = LocalSaveableStateRegistry.current - val key = currentCompositeKeyHash.toString() - val holder = remember { SavedStateHolder(key) } - return holder.updateAndReturnRegistry(parent, childWillBeComposed) -} - -internal class SavedStateHolder(private val key: String) : RememberObserver { - private var parent: SaveableStateRegistry? = null - private var isScreenVisible = false - private var values: Map>? = null - private var registry: SaveableStateRegistry = createRegistry() - private var valueProvider: () -> Any? = { - if (isScreenVisible) { - // Save the screen if it is visible right now. If it is invisible, then it's - // values were already saved upon leaving the screen. - values = registry.performSave() - } - values - } - - private var entryInParent: Entry? = null - - /** - * Manages the visibility of the screen and saves its state whenever [isVisible] transitions - * from true to false, or whenever the Android OS triggers an onSaveInstanceState dispatch. - * - * Returns a [UiSavedStateRegistry] containing the most recently saved values. - */ - @Suppress("UNCHECKED_CAST") - fun updateAndReturnRegistry( - parent: SaveableStateRegistry?, - isVisible: Boolean - ): SaveableStateRegistry { - // When values is null, try restore any previously saved values (or fallback to an empty - // map). Once values is non-null, it'll hold the all the latest saved values for the screen. - values = values ?: parent?.consumeRestored(key) as Map>? ?: emptyMap() - - val oldParent = this.parent - this.parent = parent - - // Use an identity comparison here for safety because UiSavedStateRegistry is an interface - // and custom implementations might have their own custom equals implementation. And if we - // call unregisterProvider on an UiSavedStateRegistry where `key` isn't already registered, - // then it'll crash. - if (parent !== oldParent) { - entryInParent?.unregister() - entryInParent = parent?.registerProvider(key, valueProvider) - } - - if (isVisible == this.isScreenVisible) return registry - this.isScreenVisible = isVisible - - if (!isVisible) { - // Perform save on this screen just before it leaves the composition. - values = registry.performSave() - } else { - // Recreate the registry so the most recently-saved values will be used to restore. - // The UiSavedStateRegistry function makes a defensive copy of the passed-in map, so - // it needs to be recreated on every restoration. - registry = createRegistry() - } - - return registry - } - - override fun onAbandoned() { - entryInParent?.unregister() - } - - override fun onRemembered() { - // No-op - } - - override fun onForgotten() { - entryInParent?.unregister() - } - - private fun createRegistry(): SaveableStateRegistry { - // If there's no registry available, then we won't be restored anyway so there are no - // serializability restrictions on saved values. - val canBeSaved: (Any) -> Boolean = parent?.let { it::canBeSaved } ?: { true } - return SaveableStateRegistry(values, canBeSaved) - } -} 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 new file mode 100644 index 0000000..aa16756 --- /dev/null +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/FrameController.kt @@ -0,0 +1,67 @@ +package com.zachklipp.compose.backstack + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.zachklipp.compose.backstack.FrameController.BackstackFrame + +/** + * A stable object that processes changes to a [Backstack]'s list of screen keys, determining which + * screens should be actively composed at any given time, and tweaking their appearance by applying + * [Modifier]s. + * + * The [Backstack] composable will notify its controller whenever the backstack changes by calling + * [updateBackstack], but the controller is in full control of when those changes actually get + * reflected in the composition. For example, a controller may choose to keep some screens around + * for a while, even after they're removed from the backstack, in order to animate their removal. + */ +@Stable +interface FrameController { + + /** + * The frames that are currently being active. All active frames will be composed. When a frame + * that is in the backstack stops appearing in this list, its state will be saved. + * + * Should be backed by either a `MutableState>` or a `MutableStateList`. This property + * will not be read until after [updateBackstack] is called at least once. + */ + val activeFrames: List> + + /** + * Notifies the controller that a new backstack was passed in. This should probably result in the + * [activeFrames] being updated to show new keys or hide old ones, although the controller may + * choose to do that later (e.g. if one of the active frames is currently being animated). + * + * [keys] will always contain at least one element. + */ + fun updateBackstack(keys: List) + + /** + * A frame controlled by a [FrameController], to be shown by [Backstack]. + */ + @Immutable + data class BackstackFrame( + val key: T, + val modifier: Modifier = Modifier + ) +} + +/** + * Returns a [FrameController] that always just shows the top frame without any special effects. + */ +@Suppress("UNCHECKED_CAST") +fun NoopFrameController(): FrameController = NoopFrameController as FrameController + +private object NoopFrameController : FrameController { + private var topFrame by mutableStateOf?>(null) + + override val activeFrames: List> + get() = topFrame?.let { listOf(it) } ?: emptyList() + + override fun updateBackstack(keys: List) { + topFrame = BackstackFrame(keys.last()) + } +} diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt deleted file mode 100644 index b706559..0000000 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt +++ /dev/null @@ -1,57 +0,0 @@ -@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") - -package com.zachklipp.compose.backstack - -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput - -/** - * Wrap your [Backstack] with this composable to get convenient gesture-based control of the - * inspector when [enabled] is true. - * - * ## Example - * - * ``` - * var inspectionEnabled by state { false } - * InspectionGestureDetector(inspectionEnabled) { inspectionParams -> - * Backstack( - * backstack = …, - * inspectionParams = inspectionParams - * ) { screen -> … } - * } - * ``` - * - * @param enabled When true, gestures will be intercepted and used to generate - * [InspectionParams] passed to [children]. When false, [children] will always be passed - * null. - */ -@Composable -fun InspectionGestureDetector( - enabled: Boolean, - children: @Composable (InspectionParams?) -> Unit -) { - var inspectionParams: InspectionParams by remember { mutableStateOf(InspectionParams()) } - - val controlModifier = if (!enabled) Modifier else { - Modifier.pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - inspectionParams = inspectionParams.copy( - scale = inspectionParams.scale * zoom, - // Dragging left-and-right rotates around the vertical Y axis. - rotationYDegrees = inspectionParams.rotationYDegrees + (pan.x / 5f) - ) - } - } - } - - Box(modifier = controlModifier) { - children(inspectionParams.takeIf { enabled }) - } -} diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/PercentageLayoutOffset.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/PercentageLayoutOffset.kt index 7fd1c48..fdedc46 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/PercentageLayoutOffset.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/PercentageLayoutOffset.kt @@ -2,6 +2,7 @@ package com.zachklipp.compose.backstack import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.compose.runtime.State import androidx.compose.ui.layout.LayoutModifier import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult @@ -10,8 +11,8 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize -internal class PercentageLayoutOffset(offset: Float) : LayoutModifier { - private val offset = offset.coerceIn(-1f..1f) +internal class PercentageLayoutOffset(private val rawOffset: State) : LayoutModifier { + private val offset = { rawOffset.value.coerceIn(-1f..1f) } override fun MeasureScope.measure( measurable: Measurable, @@ -25,9 +26,9 @@ internal class PercentageLayoutOffset(offset: Float) : LayoutModifier { @VisibleForTesting(otherwise = PRIVATE) internal fun offsetPosition(containerSize: IntSize) = IntOffset( - // RTL is handled automatically by place. - x = (containerSize.width * offset).toInt(), - y = 0 + // RTL is handled automatically by place. + x = (containerSize.width * offset()).toInt(), + y = 0 ) override fun toString(): String = "${this::class.java.simpleName}(offset=$offset)" 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 new file mode 100644 index 0000000..e0d5211 --- /dev/null +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/TransitionController.kt @@ -0,0 +1,185 @@ +package com.zachklipp.compose.backstack + +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.TransitionDirection.Backward +import com.zachklipp.compose.backstack.TransitionDirection.Forward +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch + +/** + * Returns the default [AnimationSpec] used for [rememberTransitionController]. + */ +@Composable fun defaultBackstackAnimation(): AnimationSpec { + val context = LocalContext.current + return TweenSpec( + durationMillis = context.resources.getInteger(android.R.integer.config_shortAnimTime) + ) +} + +/** + * Returns a [FrameController] that will animate transitions between screens. + * + * @param transition The [BackstackTransition] that defines how to animate between screens when + * the backstack changes. [BackstackTransition] contains a few simple pre-fab transitions. + * @param animationSpec Defines the curve and speed of transition animations. + * @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( + transition: BackstackTransition = BackstackTransition.Slide, + animationSpec: AnimationSpec = defaultBackstackAnimation(), + onTransitionStarting: (from: List, to: List, TransitionDirection) -> Unit = { _, _, _ -> }, + onTransitionFinished: () -> Unit = {}, +): FrameController { + val scope = rememberCoroutineScope() + return remember { TransitionController(scope) }.also { + it.transition = transition + it.animationSpec = animationSpec + it.onTransitionStarting = onTransitionStarting + it.onTransitionFinished = onTransitionFinished + } +} + +/** + * A [FrameController] that implements transition modifiers specified by [BackstackTransition]s. + * + * @param scope The [CoroutineScope] used for animations. + */ +@VisibleForTesting(otherwise = PRIVATE) +internal class TransitionController( + private val scope: CoroutineScope +) : FrameController { + + /** + * Holds information about an in-progress transition. + */ + @Immutable + private data class ActiveTransition( + val fromFrame: BackstackFrame, + val toFrame: BackstackFrame, + val popping: Boolean + ) + + // These aren't MutableStates because they're only read when a backstack change happens. They + // don't need to trigger anything when they're changed. + lateinit var transition: BackstackTransition + lateinit var animationSpec: AnimationSpec + lateinit var onTransitionStarting: (from: List, to: List, TransitionDirection) -> Unit + lateinit var onTransitionFinished: () -> Unit + + /** + * A snapshot of the backstack that will remain unchanged during transitions, even if + * [updateBackstack] is called with a different stack. Just before + * [starting a transition][startTransition], this list will be used to determine if we should use + * 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 activeKeys: List by mutableStateOf(emptyList()) + + /** The latest list of keys seen by [updateBackstack]. */ + private var targetKeys = 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) + + override val activeFrames: List> + get() = activeTransition?.let { transition -> + if (transition.popping) { + listOf(transition.toFrame, transition.fromFrame) + } else { + listOf(transition.fromFrame, transition.toFrame) + } + } ?: listOf(BackstackFrame(activeKeys.last())) + + override fun updateBackstack(keys: 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 + + // We're in the middle of a transition, don't do anything. + if (activeTransition != null) return + + // Either this is the first call or the visible screen didn't change but we need to update our + // active list for the next time we check for navigation direction. + if (activeKeys.isEmpty() || keys.last() == activeKeys.last()) { + activeKeys = keys + return + } + + // We're idle and the visible frame changed so we need to start animating. + startTransition() + } + + /** + * Called when [updateBackstack] gets a new backstack with a new top frame while idle, or after a + * transition if the [targetKeys]' top is not [activeKeys]' top. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun startTransition() { + check(activeTransition == null) { "Can only start transitioning while idle." } + + val fromKey = activeKeys.last() + val toKey = targetKeys.last() + val popping = toKey in activeKeys + val progress = Animatable(0f) + + val fromVisibility = derivedStateOf { 1f - progress.value } + val toVisibility = progress.asState() + + // 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. + val fromModifier = Modifier.composed { + with(transition) { + modifierForScreen(fromVisibility, isTop = popping) + } + } + val toModifier = Modifier.composed { + with(transition) { + modifierForScreen(toVisibility, isTop = !popping) + } + } + + activeTransition = ActiveTransition( + fromFrame = BackstackFrame(fromKey, fromModifier), + toFrame = BackstackFrame(toKey, toModifier), + popping = popping + ) + + val oldActiveKeys = activeKeys + activeKeys = targetKeys + + scope.launch { + onTransitionStarting(oldActiveKeys, activeKeys, if (popping) Backward else Forward) + progress.animateTo(1f, animationSpec) + activeTransition = null + onTransitionFinished() + + if (targetKeys.last() != activeKeys.last()) { + // We got a new top while we were transitioning, so do that transition now. + startTransition() + } + } + } +} diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/BackstackTest.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/BackstackTest.kt deleted file mode 100644 index 780cea5..0000000 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/BackstackTest.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.zachklipp.compose.backstack - -import androidx.compose.ui.Modifier -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class BackstackTest { - - @Test - fun `calculateRegularModifier handles single screen`() { - assertScreenProperties( - 1, 1f, - ScreenProperties( - isVisible = true, - modifier = Modifier - ) - ) - } - - @Test - fun `calculateRegularModifier handles two screens`() { - val count = 2 - assertScreenProperties( - count, 1f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = Modifier - ) - ) - - assertScreenProperties( - count, .75f, - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .25f, isTop = false) - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .75f, isTop = true) - ) - ) - - assertScreenProperties( - count, .25f, - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .75f, isTop = false) - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .25f, isTop = true) - ) - ) - - assertScreenProperties( - count, 0f, - ScreenProperties( - isVisible = true, - modifier = Modifier - ), - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ) - ) - } - - @Test - fun `calculateRegularModifier handles three screens`() { - val count = 3 - assertScreenProperties( - count, 1f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = Modifier - ) - ) - - assertScreenProperties( - count, .75f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .25f, isTop = false) - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .75f, isTop = true) - ) - ) - - assertScreenProperties( - count, .25f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .75f, isTop = false) - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .25f, isTop = true) - ) - ) - - assertScreenProperties( - count, 0f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = Modifier - ), - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ) - ) - } - - private fun assertScreenProperties( - count: Int, - progress: Float, - vararg expectedProperties: ScreenProperties - ) { - require(count > 0) - require(expectedProperties.size == count) - - for (index in 0 until count) { - val result = calculateRegularModifier(TestTransition, index, count, progress) - assertThat(result).isEqualTo(expectedProperties[index]) - } - } - - private object TestTransition : BackstackTransition { - @Suppress("ModifierFactoryExtensionFunction") - override fun modifierForScreen( - visibility: Float, - isTop: Boolean - ): Modifier = TestModifier(visibility, isTop) - } - - private data class TestModifier( - val visibility: Float, - val isTop: Boolean - ) : Modifier.Element -} diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/IntOffsetSubject.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/IntOffsetSubject.kt index f7017db..41d008b 100644 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/IntOffsetSubject.kt +++ b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/IntOffsetSubject.kt @@ -14,12 +14,11 @@ class IntOffsetSubject( fun isEqualTo( x: Int, y: Int - ) = - check("IntPxPosition(x, y)").that(actual).isEqualTo(IntOffset(x, y)) + ) = check("IntPxPosition(x, y)").that(actual).isEqualTo(IntOffset(x, y)) companion object { @JvmStatic - fun assertThat(actual: IntOffset?) = assertAbout(intPxPositions()).that(actual) + fun assertThat(actual: IntOffset?): IntOffsetSubject = assertAbout(intPxPositions()).that(actual) @JvmStatic fun intPxPositions(): Factory = diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/PercentageLayoutOffsetTest.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/PercentageLayoutOffsetTest.kt index ebde215..2123c3d 100644 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/PercentageLayoutOffsetTest.kt +++ b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/PercentageLayoutOffsetTest.kt @@ -2,6 +2,7 @@ package com.zachklipp.compose.backstack +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection @@ -46,7 +47,7 @@ class PercentageLayoutOffsetTest { private fun BackstackTransition.applyModifiedPosition( visibility: Float ): IntOffset { - val modifier = modifierForScreen(visibility, isTop) as PercentageLayoutOffset + val modifier = modifierForScreen(mutableStateOf(visibility), isTop) as PercentageLayoutOffset return modifier.offsetPosition(containerSize) } } diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/SavedStateHolderTest.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/SavedStateHolderTest.kt deleted file mode 100644 index ca9ead6..0000000 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/SavedStateHolderTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.zachklipp.compose.backstack - -import androidx.compose.runtime.saveable.SaveableStateRegistry -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class SavedStateHolderTest { - - @Test - fun `saves and restores`() { - val parent = SaveableStateRegistry(restoredValues = null, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - val valueProvider = { "value" } - - var registry = holder.updateAndReturnRegistry(parent, isVisible = true) - val entry = registry.registerProvider("ck", valueProvider) - /*registry =*/ holder.updateAndReturnRegistry(parent, isVisible = false) - entry.unregister() - registry = holder.updateAndReturnRegistry(parent = parent, isVisible = true) - - assertThat(registry.consumeRestored("ck")).isEqualTo("value") - } - - @Test - fun `restores from initial values`() { - val restoredValues = mutableMapOf("pk" to listOf(mapOf("ck" to listOf("value")))) - val parent = SaveableStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - - val registry = holder.updateAndReturnRegistry(parent = parent, isVisible = true) - - assertThat(registry.consumeRestored("ck")).isEqualTo("value") - } - - @Test - fun `doesn't save unregistered providers`() { - val parent = SaveableStateRegistry(restoredValues = null, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - val valueProvider = { "value" } - - var registry = holder.updateAndReturnRegistry(parent, isVisible = true) - registry.registerProvider("key", valueProvider).also { - it.unregister() - } - holder.updateAndReturnRegistry(parent, isVisible = false) - registry = holder.updateAndReturnRegistry(parent, isVisible = true) - - assertThat(registry.consumeRestored("key")).isNull() - } - - @Test - fun `preserves unrestored values from previous save`() { - val restoredValues = mutableMapOf("pk" to listOf(mapOf("old key" to listOf("old value")))) - val parent = SaveableStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - - holder.updateAndReturnRegistry(parent, isVisible = true) - // Performs the save without having consumed "old key". - holder.updateAndReturnRegistry(parent, isVisible = false) - val registry = holder.updateAndReturnRegistry(parent, isVisible = true) - - assertThat(registry.consumeRestored("old key")).isEqualTo("old value") - } - - @Test - fun `cleans up restored values from previous save`() { - val restoredValues = mutableMapOf("pk" to listOf(mapOf("old key" to listOf("old value")))) - val parent = SaveableStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - - var registry = holder.updateAndReturnRegistry(parent, isVisible = true) - val oldValue = registry.consumeRestored("old key") - holder.updateAndReturnRegistry(parent, isVisible = false) - registry = holder.updateAndReturnRegistry(parent, isVisible = true) - - assertThat(oldValue).isEqualTo("old value") - assertThat(registry.consumeRestored("old key")).isNull() - } - - @Test - fun `parent saves contain values from the currently visible screen`() { - val parent = SaveableStateRegistry(restoredValues = null, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - - val registry = holder.updateAndReturnRegistry(parent, isVisible = true) - registry.registerProvider("ck") { "value" } - - val values = parent.performSave() - assertThat(values["pk"]).isEqualTo(listOf(mapOf("ck" to listOf("value")))) - } - - @Test - fun `parent saves contain values from non-visible screens`() { - val parent = SaveableStateRegistry(restoredValues = null, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - val valueProvider = { "value" } - - var registry = holder.updateAndReturnRegistry(parent, isVisible = true) - val entry = registry.registerProvider("ck", valueProvider) - /*registry =*/ holder.updateAndReturnRegistry(parent, isVisible = false) - entry.unregister() - - val values = parent.performSave() - assertThat(values["pk"]).isEqualTo(listOf(mapOf("ck" to listOf("value")))) - } - - @Test - fun `parent saves contain values from nested children`() { - val topStateHolder = SavedStateHolder("pk1") - val middleStateHolder = SavedStateHolder("pk2") - - val topRegistry = SaveableStateRegistry(restoredValues = null, canBeSaved = { true }) - val middleRegistry = topStateHolder.updateAndReturnRegistry(topRegistry, isVisible = true) - val bottomRegistry = - middleStateHolder.updateAndReturnRegistry(middleRegistry, isVisible = true) - - middleRegistry.registerProvider("ck1") { "middle value" } - bottomRegistry.registerProvider("ck2") { "bottom value" } - - val values = topRegistry.performSave() - assertThat(values["pk1"]).isEqualTo( - listOf( - mapOf( - "ck1" to listOf("middle value"), - "pk2" to listOf(mapOf("ck2" to listOf("bottom value"))) - ) - ) - ) - } -} 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 new file mode 100644 index 0000000..b331779 --- /dev/null +++ b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/TransitionControllerTest.kt @@ -0,0 +1,28 @@ +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 kotlinx.coroutines.CoroutineScope +import org.junit.Test +import kotlin.coroutines.EmptyCoroutineContext + +class TransitionControllerTest { + + private val scope = CoroutineScope(EmptyCoroutineContext) + private val transition = Crossfade + private val animationSpec = TweenSpec(durationMillis = 1000) + private val controller = TransitionController(scope).also { + it.transition = transition + it.animationSpec = animationSpec + it.onTransitionStarting = { _, _, _ -> } + it.onTransitionFinished = { } + + } + + @Test fun `initial update sets activeFrames`() { + controller.updateBackstack(listOf("hello")) + assertThat(controller.activeFrames).containsExactly(BackstackFrame("hello")) + } +} diff --git a/sample/src/main/java/com/zachklipp/compose/backstack/sample/FancyTransition.kt b/sample/src/main/java/com/zachklipp/compose/backstack/sample/FancyTransition.kt index 26ec018..a045c0d 100644 --- a/sample/src/main/java/com/zachklipp/compose/backstack/sample/FancyTransition.kt +++ b/sample/src/main/java/com/zachklipp/compose/backstack/sample/FancyTransition.kt @@ -1,11 +1,14 @@ package com.zachklipp.compose.backstack.sample +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.util.lerp import com.zachklipp.compose.backstack.BackstackTransition import com.zachklipp.compose.backstack.BackstackTransition.Crossfade import com.zachklipp.compose.backstack.BackstackTransition.Slide +import com.zachklipp.compose.backstack.modifierForScreen import kotlin.math.pow /** @@ -13,21 +16,18 @@ import kotlin.math.pow * with some additional math and other transformations. */ object FancyTransition : BackstackTransition { - @Suppress("ModifierFactoryExtensionFunction") - override fun modifierForScreen( - visibility: Float, + override fun Modifier.modifierForScreen( + visibility: State, isTop: Boolean - ): Modifier { - return if (isTop) { - // Start sliding in from the middle to reduce the motion a bit. - val slideVisibility = lerp(.5f, 1f, visibility) - Slide.modifierForScreen(slideVisibility, isTop) - .then(Crossfade.modifierForScreen(visibility, isTop)) - } else { - // Move the non-top screen back, but only a little. - val scaleVisibility = lerp(.9f, 1f, visibility) - Modifier.graphicsLayer(scaleX = scaleVisibility, scaleY = scaleVisibility) - .then(Crossfade.modifierForScreen(visibility.pow(.5f), isTop)) - } + ): Modifier = if (isTop) { + // Start sliding in from the middle to reduce the motion a bit. + val slideVisibility = derivedStateOf { lerp(.5f, 1f, visibility.value) } + then(Slide.modifierForScreen(slideVisibility, isTop)) + .then(Crossfade.modifierForScreen(visibility, isTop)) + } else { + // Move the non-top screen back, but only a little. + val scaleVisibility = lerp(.9f, 1f, visibility.value) + graphicsLayer(scaleX = scaleVisibility, scaleY = scaleVisibility) + .then(Crossfade.modifierForScreen(derivedStateOf { visibility.value.pow(.5f) }, isTop)) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index b57cf15..e6e12f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,7 @@ include( ":compose-backstack", ":compose-backstack-viewer", + ":compose-backstack-xray", ":sample" ) rootProject.name = "compose-backstack"