summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt17
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/mechanics/TransitionScopedMechanicsAdapter.kt135
-rw-r--r--packages/SystemUI/compose/scene/tests/goldens/motionValue_interruptedAnimation_completes.json70
-rw-r--r--packages/SystemUI/compose/scene/tests/goldens/motionValue_withAnimation_prolongsTransition.json48
-rw-r--r--packages/SystemUI/compose/scene/tests/goldens/motionValue_withoutAnimation_terminatesImmediately.json26
-rw-r--r--packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/mechanics/TransitionScopedMechanicsAdapterTest.kt519
6 files changed, 814 insertions, 1 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
index e23e234b1cad..312dd77fd53f 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
@@ -22,6 +22,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import com.android.compose.animation.scene.ContentKey
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.compose.animation.scene.OverlayKey
@@ -241,6 +243,15 @@ sealed interface TransitionState {
/** Additional gesture context whenever the transition is driven by a user gesture. */
abstract val gestureContext: GestureContext?
+ /**
+ * True when the transition reached the end and the progress won't be updated anymore.
+ *
+ * [isProgressStable] will be `true` before this [Transition] is completed while there are
+ * still custom transition animations settling.
+ */
+ var isProgressStable: Boolean by mutableStateOf(false)
+ private set
+
/** The CUJ covered by this transition. */
@CujType
val cuj: Int?
@@ -372,7 +383,11 @@ sealed interface TransitionState {
check(_coroutineScope == null) { "A Transition can be started only once." }
coroutineScope {
_coroutineScope = this
- run()
+ try {
+ run()
+ } finally {
+ isProgressStable = true
+ }
}
}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/mechanics/TransitionScopedMechanicsAdapter.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/mechanics/TransitionScopedMechanicsAdapter.kt
new file mode 100644
index 000000000000..ac8a8c014af4
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/mechanics/TransitionScopedMechanicsAdapter.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.compose.animation.scene.mechanics
+
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.mutableFloatStateOf
+import com.android.compose.animation.scene.ContentKey
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.ElementStateScope
+import com.android.compose.animation.scene.content.state.TransitionState
+import com.android.compose.animation.scene.transformation.CustomPropertyTransformation
+import com.android.compose.animation.scene.transformation.PropertyTransformationScope
+import com.android.mechanics.MotionValue
+import com.android.mechanics.ProvidedGestureContext
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.MotionSpec
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Callback to create a [MotionSpec] on the first call to [CustomPropertyTransformation.transform]
+ */
+typealias SpecFactory =
+ PropertyTransformationScope.(content: ContentKey, element: ElementKey) -> MotionSpec
+
+/** Callback to compute the [MotionValue] per frame */
+typealias MotionValueInput =
+ PropertyTransformationScope.(progress: Float, content: ContentKey, element: ElementKey) -> Float
+
+/**
+ * Adapter to create a [MotionValue] and `keepRunning()` it temporarily while a
+ * [CustomPropertyTransformation] is in progress and until the animation settles.
+ *
+ * The [MotionValue]'s input is by default the transition progress.
+ */
+internal class TransitionScopedMechanicsAdapter(
+ private val computeInput: MotionValueInput = { progress, _, _ -> progress },
+ private val stableThreshold: Float = MotionValue.StableThresholdEffect,
+ private val label: String? = null,
+ private val createSpec: SpecFactory,
+) {
+
+ private val input = mutableFloatStateOf(0f)
+ private var motionValue: MotionValue? = null
+
+ fun PropertyTransformationScope.update(
+ content: ContentKey,
+ element: ElementKey,
+ transition: TransitionState.Transition,
+ transitionScope: CoroutineScope,
+ ): Float {
+ val progress = transition.progressTo(content)
+ input.floatValue = computeInput(progress, content, element)
+ var motionValue = motionValue
+
+ if (motionValue == null) {
+ motionValue =
+ MotionValue(
+ input::floatValue,
+ transition.gestureContext
+ ?: ProvidedGestureContext(
+ 0f,
+ appearDirection(content, element, transition),
+ ),
+ createSpec(content, element),
+ stableThreshold = stableThreshold,
+ label = label,
+ )
+ this@TransitionScopedMechanicsAdapter.motionValue = motionValue
+
+ transitionScope.launch {
+ motionValue.keepRunningWhile { !transition.isProgressStable || !isStable }
+ }
+ }
+
+ return motionValue.output
+ }
+
+ companion object {
+ /**
+ * Computes the InputDirection for a triggered transition of an element appearing /
+ * disappearing.
+ *
+ * Since [CustomPropertyTransformation] are only supported for non-shared elements, the
+ * [TransitionScopedMechanicsAdapter] is only used in the context of an element appearing /
+ * disappearing. This helper computes the direction to result in [InputDirection.Max] for an
+ * appear transition, and [InputDirection.Min] for a disappear transition.
+ */
+ @VisibleForTesting
+ internal fun ElementStateScope.appearDirection(
+ content: ContentKey,
+ element: ElementKey,
+ transition: TransitionState.Transition,
+ ): InputDirection {
+ check(!transition.isInitiatedByUserInput)
+
+ val inMaxDirection =
+ when (transition) {
+ is TransitionState.Transition.ChangeScene -> {
+ val transitionTowardsContent = content == transition.toContent
+ val elementInContent = element.targetSize(content) != null
+ val isReversed = transition.currentScene != transition.toScene
+ (transitionTowardsContent xor elementInContent) xor !isReversed
+ }
+
+ is TransitionState.Transition.ShowOrHideOverlay -> {
+ val transitioningTowardsOverlay = transition.overlay == transition.toContent
+ val isReversed =
+ transitioningTowardsOverlay xor transition.isEffectivelyShown
+ transitioningTowardsOverlay xor isReversed
+ }
+
+ is TransitionState.Transition.ReplaceOverlay -> {
+ transition.effectivelyShownOverlay == content
+ }
+ }
+
+ return if (inMaxDirection) InputDirection.Max else InputDirection.Min
+ }
+ }
+}
diff --git a/packages/SystemUI/compose/scene/tests/goldens/motionValue_interruptedAnimation_completes.json b/packages/SystemUI/compose/scene/tests/goldens/motionValue_interruptedAnimation_completes.json
new file mode 100644
index 000000000000..ce62ac3f4ee2
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/goldens/motionValue_interruptedAnimation_completes.json
@@ -0,0 +1,70 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ 272,
+ 288,
+ 304,
+ 320,
+ 336,
+ 352,
+ 368,
+ 384,
+ "after"
+ ],
+ "features": [
+ {
+ "name": "Foo_yOffset",
+ "type": "float",
+ "data_points": [
+ {
+ "type": "not_found"
+ },
+ {
+ "type": "not_found"
+ },
+ 175,
+ 175,
+ 174.00105,
+ 149.84001,
+ 114.73702,
+ 0,
+ 0,
+ 0,
+ 0,
+ 10.212692,
+ 42.525528,
+ 77.174965,
+ 106.322296,
+ 128.37651,
+ 144.09671,
+ 154.88022,
+ 162.08202,
+ 166.79778,
+ 169.83923,
+ 171.77742,
+ 173.00056,
+ 173.76627,
+ 174.24236,
+ {
+ "type": "not_found"
+ }
+ ]
+ }
+ ]
+} \ No newline at end of file
diff --git a/packages/SystemUI/compose/scene/tests/goldens/motionValue_withAnimation_prolongsTransition.json b/packages/SystemUI/compose/scene/tests/goldens/motionValue_withAnimation_prolongsTransition.json
new file mode 100644
index 000000000000..ac09ff3f359c
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/goldens/motionValue_withAnimation_prolongsTransition.json
@@ -0,0 +1,48 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ "after"
+ ],
+ "features": [
+ {
+ "name": "Foo_yOffset",
+ "type": "float",
+ "data_points": [
+ 175,
+ 175,
+ 175,
+ 175,
+ 156.26086,
+ 121.784874,
+ 88.35684,
+ 61.32686,
+ 41.302353,
+ 27.215454,
+ 17.638702,
+ 11.284393,
+ 7.144104,
+ 4.4841614,
+ 2.7943878,
+ 1.7307587,
+ 1.0663452,
+ 0
+ ]
+ }
+ ]
+} \ No newline at end of file
diff --git a/packages/SystemUI/compose/scene/tests/goldens/motionValue_withoutAnimation_terminatesImmediately.json b/packages/SystemUI/compose/scene/tests/goldens/motionValue_withoutAnimation_terminatesImmediately.json
new file mode 100644
index 000000000000..5cf66a4aa88c
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/goldens/motionValue_withoutAnimation_terminatesImmediately.json
@@ -0,0 +1,26 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ "after"
+ ],
+ "features": [
+ {
+ "name": "Foo_yOffset",
+ "type": "float",
+ "data_points": [
+ 175,
+ 145.83333,
+ 116.666664,
+ 87.5,
+ 58.33333,
+ 29.166672,
+ 0
+ ]
+ }
+ ]
+} \ No newline at end of file
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/mechanics/TransitionScopedMechanicsAdapterTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/mechanics/TransitionScopedMechanicsAdapterTest.kt
new file mode 100644
index 000000000000..b9bd115782b7
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/mechanics/TransitionScopedMechanicsAdapterTest.kt
@@ -0,0 +1,519 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.compose.animation.scene.mechanics
+
+import android.platform.test.annotations.MotionTest
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.ContentKey
+import com.android.compose.animation.scene.ContentScope
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.MutableSceneTransitionLayoutStateForTests
+import com.android.compose.animation.scene.MutableSceneTransitionLayoutStateImpl
+import com.android.compose.animation.scene.OverlayKey
+import com.android.compose.animation.scene.SceneKey
+import com.android.compose.animation.scene.SceneTransitionLayout
+import com.android.compose.animation.scene.SceneTransitionLayoutForTesting
+import com.android.compose.animation.scene.SceneTransitionsBuilder
+import com.android.compose.animation.scene.TestElements
+import com.android.compose.animation.scene.TestOverlays
+import com.android.compose.animation.scene.TestScenes
+import com.android.compose.animation.scene.TransitionBuilder
+import com.android.compose.animation.scene.TransitionRecordingSpec
+import com.android.compose.animation.scene.content.state.TransitionState
+import com.android.compose.animation.scene.featureOfElement
+import com.android.compose.animation.scene.mechanics.TransitionScopedMechanicsAdapter.Companion.appearDirection
+import com.android.compose.animation.scene.recordTransition
+import com.android.compose.animation.scene.testing.lastOffsetForTesting
+import com.android.compose.animation.scene.transformation.CustomPropertyTransformation
+import com.android.compose.animation.scene.transformation.PropertyTransformation
+import com.android.compose.animation.scene.transformation.PropertyTransformationScope
+import com.android.compose.animation.scene.transitions
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.buildDirectionalMotionSpec
+import com.android.mechanics.spring.SpringParameters
+import com.google.common.truth.Truth
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.test.TestScope
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.compose.ComposeRecordingSpec
+import platform.test.motion.compose.MotionControl
+import platform.test.motion.compose.createComposeMotionTestRule
+import platform.test.motion.compose.recordMotion
+import platform.test.motion.compose.runTest
+import platform.test.motion.golden.DataPoint
+import platform.test.motion.golden.DataPointTypes
+import platform.test.motion.golden.FeatureCapture
+import platform.test.motion.testing.createGoldenPathManager
+
+@RunWith(AndroidJUnit4::class)
+@MotionTest
+class TransitionScopedMechanicsAdapterTest {
+
+ private val goldenPaths =
+ createGoldenPathManager("frameworks/base/packages/SystemUI/compose/scene/tests/goldens")
+
+ private val testScope = TestScope()
+ @get:Rule val motionRule = createComposeMotionTestRule(goldenPaths, testScope)
+ private val composeRule = motionRule.toolkit.composeContentTestRule
+
+ @Test
+ fun motionValue_withoutAnimation_terminatesImmediately() =
+ motionRule.runTest {
+ val specFactory: SpecFactory = { _, _ ->
+ MotionSpec(
+ // Linearly animate from 10 down to 0
+ buildDirectionalMotionSpec(TestSpring, Mapping.Fixed(50.dp.toPx())) {
+ targetFromCurrent(breakpoint = 0f, to = 0f)
+ constantValueFromCurrent(breakpoint = 1f)
+ }
+ )
+ }
+
+ assertOffsetMatchesGolden(
+ transition = {
+ spec = tween(16 * 6, easing = LinearEasing)
+ transformation(TestElements.Foo) { TestTransformation(specFactory) }
+ }
+ )
+ }
+
+ @Test
+ fun motionValue_withAnimation_prolongsTransition() =
+ motionRule.runTest {
+ val specFactory: SpecFactory = { _, _ ->
+ MotionSpec(
+ // Use a spring to toggle 10f -> 0f at a progress of 0.5
+ buildDirectionalMotionSpec(TestSpring, Mapping.Fixed(50.dp.toPx())) {
+ constantValue(breakpoint = 0.5f, value = 0f)
+ }
+ )
+ }
+
+ assertOffsetMatchesGolden(
+ transition = {
+ spec = tween(16 * 6, easing = LinearEasing)
+ transformation(TestElements.Foo) { TestTransformation(specFactory) }
+ }
+ )
+ }
+
+ @Test
+ fun motionValue_interruptedAnimation_completes() =
+ motionRule.runTest {
+ val transitions = transitions {
+ from(TestScenes.SceneA, to = TestScenes.SceneB) {
+ spec = tween(16 * 6, easing = LinearEasing)
+
+ transformation(TestElements.Foo) {
+ TestTransformation { _, _ ->
+ MotionSpec(
+ buildDirectionalMotionSpec(
+ TestSpring,
+ Mapping.Fixed(50.dp.toPx()),
+ ) {
+ constantValue(breakpoint = 0.3f, value = 0f)
+ }
+ )
+ }
+ }
+ }
+ }
+
+ val state =
+ composeRule.runOnUiThread {
+ MutableSceneTransitionLayoutStateForTests(TestScenes.SceneA, transitions)
+ }
+ lateinit var coroutineScope: CoroutineScope
+
+ val motionControl =
+ MotionControl(delayRecording = { awaitFrames(4) }) {
+ awaitFrames(1)
+ val (transitionToB, firstTransitionJob) =
+ toolkit.composeContentTestRule.runOnUiThread {
+ checkNotNull(
+ state.setTargetScene(
+ TestScenes.SceneB,
+ animationScope = coroutineScope,
+ )
+ )
+ }
+
+ awaitCondition { transitionToB.progress > 0.5f }
+ val (transitionBackToA, secondTransitionJob) =
+ toolkit.composeContentTestRule.runOnUiThread {
+ checkNotNull(
+ state.setTargetScene(
+ TestScenes.SceneA,
+ animationScope = coroutineScope,
+ )
+ )
+ }
+
+ Truth.assertThat(transitionBackToA.replacedTransition)
+ .isSameInstanceAs(transitionToB)
+
+ awaitCondition { !state.isTransitioning() }
+
+ Truth.assertThat(firstTransitionJob.isCompleted).isTrue()
+ Truth.assertThat(secondTransitionJob.isCompleted).isTrue()
+ }
+
+ val motion =
+ recordMotion(
+ content = {
+ coroutineScope = rememberCoroutineScope()
+ SceneTransitionLayoutForTesting(state, modifier = Modifier.size(50.dp)) {
+ scene(TestScenes.SceneA) { SceneAContent() }
+ scene(TestScenes.SceneB) { SceneBContent() }
+ }
+ },
+ ComposeRecordingSpec(motionControl, recordBefore = false) {
+ featureOfElement(TestElements.Foo, yOffsetFeature)
+ },
+ )
+
+ assertThat(motion).timeSeriesMatchesGolden()
+ }
+
+ @Test
+ fun animationDirection_sceneTransition_forward() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneA,
+ transitionBuilder = {
+ from(TestScenes.SceneA, to = TestScenes.SceneB) { it(TestElements.Foo) }
+ },
+ ) { state, animationScope, _ ->
+ state.setTargetScene(TestScenes.SceneB, animationScope)
+ false
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Max)
+ }
+
+ @Test
+ fun animationDirection_sceneTransition_backwards() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneB,
+ transitionBuilder = {
+ from(TestScenes.SceneA, to = TestScenes.SceneB) { it(TestElements.Foo) }
+ },
+ ) { state, animationScope, _ ->
+ state.setTargetScene(TestScenes.SceneA, animationScope)
+ false
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Min)
+ }
+
+ @Test
+ fun animationDirection_interruptedTransition_flipsDirection() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneA,
+ transitionBuilder = {
+ from(TestScenes.SceneA, to = TestScenes.SceneB) { it(TestElements.Foo) }
+ },
+ ) { state, animationScope, iteration ->
+ when (iteration) {
+ 0 -> {
+ state.setTargetScene(TestScenes.SceneB, animationScope)
+ true
+ }
+ 1 -> {
+ state.setTargetScene(TestScenes.SceneA, animationScope)
+ false
+ }
+ else -> throw AssertionError()
+ }
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Min)
+ }
+
+ @Test
+ fun animationDirection_showOverlay_animatesInMaxDirection() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneA,
+ transitionBuilder = { this.to(TestOverlays.OverlayA) { it(TestElements.Bar) } },
+ ) { state, animationScope, _ ->
+ state.showOverlay(TestOverlays.OverlayA, animationScope)
+ false
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Max)
+ }
+
+ @Test
+ fun animationDirection_hideOverlay_animatesInMinDirection() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneA,
+ initialOverlays = setOf(TestOverlays.OverlayA),
+ transitionBuilder = { this.to(TestOverlays.OverlayA) { it(TestElements.Bar) } },
+ ) { state, animationScope, _ ->
+ state.hideOverlay(TestOverlays.OverlayA, animationScope)
+ false
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Min)
+ }
+
+ @Test
+ fun animationDirection_hideOverlayMidTransition_animatesInMinDirection() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneA,
+ transitionBuilder = { this.to(TestOverlays.OverlayA) { it(TestElements.Bar) } },
+ ) { state, animationScope, iteration ->
+ when (iteration) {
+ 0 -> {
+ state.showOverlay(TestOverlays.OverlayA, animationScope)
+ true
+ }
+ 1 -> {
+ state.hideOverlay(TestOverlays.OverlayA, animationScope)
+ false
+ }
+ else -> throw AssertionError()
+ }
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Min)
+ }
+
+ @Test
+ fun animationDirection_replaceOverlay_showingContent_animatesInMaxDirection() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneA,
+ initialOverlays = setOf(TestOverlays.OverlayB),
+ transitionBuilder = { this.to(TestOverlays.OverlayA) { it(TestElements.Bar) } },
+ ) { state, animationScope, _ ->
+ state.replaceOverlay(TestOverlays.OverlayB, TestOverlays.OverlayA, animationScope)
+ false
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Max)
+ }
+
+ @Test
+ fun animationDirection_replaceOverlay_hidingContent_animatesInMinDirection() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneA,
+ initialOverlays = setOf(TestOverlays.OverlayA),
+ transitionBuilder = { this.to(TestOverlays.OverlayA) { it(TestElements.Bar) } },
+ ) { state, animationScope, _ ->
+ state.replaceOverlay(TestOverlays.OverlayA, TestOverlays.OverlayB, animationScope)
+ false
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Min)
+ }
+
+ @Test
+ fun animationDirection_replaceOverlay_revertMidTransition_animatesInMinDirection() {
+ val transitionDirection =
+ composeRule.getAppearDirectionOnTransition(
+ initialScene = TestScenes.SceneA,
+ initialOverlays = setOf(TestOverlays.OverlayB),
+ transitionBuilder = { this.to(TestOverlays.OverlayA) { it(TestElements.Bar) } },
+ ) { state, animationScope, iteration ->
+ when (iteration) {
+ 0 -> {
+ state.replaceOverlay(
+ TestOverlays.OverlayB,
+ TestOverlays.OverlayA,
+ animationScope,
+ )
+ true
+ }
+ 1 -> {
+ state.replaceOverlay(
+ TestOverlays.OverlayA,
+ TestOverlays.OverlayB,
+ animationScope,
+ )
+ false
+ }
+ else -> throw AssertionError()
+ }
+ }
+
+ Truth.assertThat(transitionDirection).isEqualTo(InputDirection.Min)
+ }
+
+ private fun ComposeContentTestRule.getAppearDirectionOnTransition(
+ initialScene: SceneKey,
+ transitionBuilder: SceneTransitionsBuilder.(foo: DirectionAssertionTransition) -> Unit,
+ initialOverlays: Set<OverlayKey> = emptySet(),
+ runTransition:
+ (
+ state: MutableSceneTransitionLayoutStateImpl,
+ animationScope: CoroutineScope,
+ iteration: Int,
+ ) -> Boolean,
+ ): InputDirection {
+
+ lateinit var result: InputDirection
+
+ val x: DirectionAssertionTransition = {
+ transformation(it) {
+ object : CustomPropertyTransformation<IntSize> {
+ override val property = PropertyTransformation.Property.Size
+
+ override fun PropertyTransformationScope.transform(
+ content: ContentKey,
+ element: ElementKey,
+ transition: TransitionState.Transition,
+ transitionScope: CoroutineScope,
+ ): IntSize {
+ result = appearDirection(content, element, transition)
+ return IntSize.Zero
+ }
+ }
+ }
+ }
+
+ val state = runOnUiThread {
+ MutableSceneTransitionLayoutStateForTests(
+ initialScene,
+ transitions { transitionBuilder(x) },
+ initialOverlays,
+ )
+ }
+ lateinit var coroutineScope: CoroutineScope
+
+ setContent {
+ coroutineScope = rememberCoroutineScope()
+ SceneTransitionLayout(state) {
+ scene(TestScenes.SceneA) { SceneAContent() }
+ scene(TestScenes.SceneB) { SceneBContent() }
+ overlay(TestOverlays.OverlayA) { OverlayAContent() }
+ overlay(TestOverlays.OverlayB) {}
+ }
+ }
+
+ waitForIdle()
+ mainClock.autoAdvance = false
+ var keepOnAnimating = true
+ var iterationCount = 0
+ while (keepOnAnimating) {
+ runOnUiThread { keepOnAnimating = runTransition(state, coroutineScope, iterationCount) }
+ composeRule.mainClock.advanceTimeByFrame()
+ waitForIdle()
+ iterationCount++
+ }
+ waitForIdle()
+
+ return result
+ }
+
+ private class TestTransformation(specFactory: SpecFactory) :
+ CustomPropertyTransformation<Offset> {
+ override val property = PropertyTransformation.Property.Offset
+
+ val motionValue =
+ TransitionScopedMechanicsAdapter(createSpec = specFactory, stableThreshold = 1f)
+
+ override fun PropertyTransformationScope.transform(
+ content: ContentKey,
+ element: ElementKey,
+ transition: TransitionState.Transition,
+ transitionScope: CoroutineScope,
+ ): Offset {
+ val yOffset =
+ with(motionValue) { update(content, element, transition, transitionScope) }
+
+ return Offset(x = 0f, y = yOffset)
+ }
+ }
+
+ private fun assertOffsetMatchesGolden(transition: TransitionBuilder.() -> Unit) {
+ val recordingSpec =
+ TransitionRecordingSpec(recordBefore = false, recordAfter = true) {
+ featureOfElement(TestElements.Foo, yOffsetFeature)
+ }
+
+ val motion =
+ motionRule.recordTransition(
+ fromSceneContent = { SceneAContent() },
+ toSceneContent = { SceneBContent() },
+ transition = transition,
+ recordingSpec = recordingSpec,
+ layoutModifier = Modifier.size(50.dp),
+ )
+
+ motionRule.assertThat(motion).timeSeriesMatchesGolden()
+ }
+
+ companion object {
+
+ @Composable
+ fun ContentScope.SceneAContent() {
+ Box(modifier = Modifier.fillMaxSize())
+ }
+
+ @Composable
+ fun ContentScope.SceneBContent() {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Box(Modifier.element(TestElements.Foo).size(50.dp).background(Color.Red))
+ }
+ }
+
+ @Composable
+ fun ContentScope.OverlayAContent() {
+ Box(Modifier.element(TestElements.Bar).size(50.dp).background(Color.Red))
+ }
+
+ @Composable
+ fun ContentScope.OverlayBContent() {
+ Box(modifier = Modifier.size(50.dp).background(Color.Green))
+ }
+
+ val TestSpring = SpringParameters(1200f, 1f)
+
+ val yOffsetFeature =
+ FeatureCapture<SemanticsNode, Float>("yOffset") {
+ DataPoint.of(it.lastOffsetForTesting?.y, DataPointTypes.float)
+ }
+ }
+}
+
+typealias DirectionAssertionTransition = TransitionBuilder.(container: ElementKey) -> Unit