diff options
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 |