diff options
12 files changed, 476 insertions, 64 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt index 6b289f3c66a3..b5e93131f828 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt @@ -47,8 +47,11 @@ internal fun CoroutineScope.animateToScene( } return when (transitionState) { - is TransitionState.Idle -> animate(layoutState, target, transitionKey) + is TransitionState.Idle -> + animate(layoutState, target, transitionKey, isInitiatedByUserInput = false) is TransitionState.Transition -> { + val isInitiatedByUserInput = transitionState.isInitiatedByUserInput + // A transition is currently running: first check whether `transition.toScene` or // `transition.fromScene` is the same as our target scene, in which case the transition // can be accelerated or reversed to end up in the target state. @@ -68,8 +71,14 @@ internal fun CoroutineScope.animateToScene( } else { // The transition is in progress: start the canned animation at the same // progress as it was in. - // TODO(b/290184746): Also take the current velocity into account. - animate(layoutState, target, transitionKey, startProgress = progress) + animate( + layoutState, + target, + transitionKey, + isInitiatedByUserInput, + initialProgress = progress, + initialVelocity = transitionState.progressVelocity, + ) } } else if (transitionState.fromScene == target) { // There is a transition from [target] to another scene: simply animate the same @@ -83,19 +92,52 @@ internal fun CoroutineScope.animateToScene( layoutState.finishTransition(transitionState, target) null } else { - // TODO(b/290184746): Also take the current velocity into account. animate( layoutState, target, transitionKey, - startProgress = progress, + isInitiatedByUserInput, + initialProgress = progress, + initialVelocity = transitionState.progressVelocity, reversed = true, ) } } else { // Generic interruption; the current transition is neither from or to [target]. - // TODO(b/290930950): Better handle interruptions here. - animate(layoutState, target, transitionKey) + val interruptionResult = + layoutState.transitions.interruptionHandler.onInterruption( + transitionState, + target, + ) + ?: DefaultInterruptionHandler.onInterruption(transitionState, target) + + val animateFrom = interruptionResult.animateFrom + if ( + animateFrom != transitionState.toScene && + animateFrom != transitionState.fromScene + ) { + error( + "InterruptionResult.animateFrom must be either the fromScene " + + "(${transitionState.fromScene.debugName}) or the toScene " + + "(${transitionState.toScene.debugName}) of the interrupted transition." + ) + } + + // If we were A => B and that we are now animating A => C, add a transition B => A + // to the list of transitions so that B "disappears back to A". + val chain = interruptionResult.chain + if (chain && animateFrom != transitionState.currentScene) { + animateToScene(layoutState, animateFrom, transitionKey = null) + } + + animate( + layoutState, + target, + transitionKey, + isInitiatedByUserInput, + fromScene = animateFrom, + chain = chain, + ) } } } @@ -103,32 +145,31 @@ internal fun CoroutineScope.animateToScene( private fun CoroutineScope.animate( layoutState: BaseSceneTransitionLayoutState, - target: SceneKey, + targetScene: SceneKey, transitionKey: TransitionKey?, - startProgress: Float = 0f, + isInitiatedByUserInput: Boolean, + initialProgress: Float = 0f, + initialVelocity: Float = 0f, reversed: Boolean = false, + fromScene: SceneKey = layoutState.transitionState.currentScene, + chain: Boolean = true, ): TransitionState.Transition { - val fromScene = layoutState.transitionState.currentScene - val isUserInput = - (layoutState.transitionState as? TransitionState.Transition)?.isInitiatedByUserInput - ?: false - val targetProgress = if (reversed) 0f else 1f val transition = if (reversed) { OneOffTransition( - fromScene = target, + fromScene = targetScene, toScene = fromScene, - currentScene = target, - isInitiatedByUserInput = isUserInput, + currentScene = targetScene, + isInitiatedByUserInput = isInitiatedByUserInput, isUserInputOngoing = false, ) } else { OneOffTransition( fromScene = fromScene, - toScene = target, - currentScene = target, - isInitiatedByUserInput = isUserInput, + toScene = targetScene, + currentScene = targetScene, + isInitiatedByUserInput = isInitiatedByUserInput, isUserInputOngoing = false, ) } @@ -136,7 +177,7 @@ private fun CoroutineScope.animate( // Change the current layout state to start this new transition. This will compute the // TransformationSpec associated to this transition, which we need to initialize the Animatable // that will actually animate it. - layoutState.startTransition(transition, transitionKey) + layoutState.startTransition(transition, transitionKey, chain) // The transition now contains the transformation spec that we should use to instantiate the // Animatable. @@ -144,19 +185,19 @@ private fun CoroutineScope.animate( val visibilityThreshold = (animationSpec as? SpringSpec)?.visibilityThreshold ?: ProgressVisibilityThreshold val animatable = - Animatable(startProgress, visibilityThreshold = visibilityThreshold).also { + Animatable(initialProgress, visibilityThreshold = visibilityThreshold).also { transition.animatable = it } // Animate the progress to its target value. transition.job = - launch { animatable.animateTo(targetProgress, animationSpec) } + launch { animatable.animateTo(targetProgress, animationSpec, initialVelocity) } .apply { invokeOnCompletion { // Settle the state to Idle(target). Note that this will do nothing if this // transition was replaced/interrupted by another one, and this also runs if // this coroutine is cancelled, i.e. if [this] coroutine scope is cancelled. - layoutState.finishTransition(transition, target) + layoutState.finishTransition(transition, targetScene) } } @@ -185,6 +226,9 @@ private class OneOffTransition( override val progress: Float get() = animatable.value + override val progressVelocity: Float + get() = animatable.velocity + override fun finish(): Job = job } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt index f78ed2fdcaf6..cb4d5723e8e5 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt @@ -579,6 +579,18 @@ private class SwipeTransition( return offset / distance } + override val progressVelocity: Float + get() { + val animatable = offsetAnimation?.animatable ?: return 0f + val distance = distance() + if (distance == DistanceUnspecified) { + return 0f + } + + val velocityInDistanceUnit = animatable.velocity + return velocityInDistanceUnit / distance.absoluteValue + } + override val isInitiatedByUserInput = true override var bouncingScene: SceneKey? = null diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt index ca643231e874..20742ee77fff 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt @@ -329,10 +329,9 @@ private fun elementTransition( if (transition == null && previousTransition != null) { // The transition was just finished. - element.sceneStates.values.forEach { sceneState -> - sceneState.offsetInterruptionDelta = Offset.Zero - sceneState.scaleInterruptionDelta = Scale.Zero - sceneState.alphaInterruptionDelta = 0f + element.sceneStates.values.forEach { + it.clearValuesBeforeInterruption() + it.clearInterruptionDeltas() } } @@ -375,12 +374,22 @@ private fun prepareInterruption(element: Element) { sceneState.scaleBeforeInterruption = lastScale sceneState.alphaBeforeInterruption = lastAlpha - sceneState.offsetInterruptionDelta = Offset.Zero - sceneState.scaleInterruptionDelta = Scale.Zero - sceneState.alphaInterruptionDelta = 0f + sceneState.clearInterruptionDeltas() } } +private fun Element.SceneState.clearInterruptionDeltas() { + offsetInterruptionDelta = Offset.Zero + scaleInterruptionDelta = Scale.Zero + alphaInterruptionDelta = 0f +} + +private fun Element.SceneState.clearValuesBeforeInterruption() { + offsetBeforeInterruption = Offset.Unspecified + scaleBeforeInterruption = Scale.Unspecified + alphaBeforeInterruption = Element.AlphaUnspecified +} + /** * Compute what [value] should be if we take the * [interruption progress][TransitionState.Transition.interruptionProgress] of [transition] into @@ -744,7 +753,11 @@ private fun ApproachMeasureScope.place( // No need to place the element in this scene if we don't want to draw it anyways. if (!shouldPlaceElement(layoutImpl, scene, element, transition)) { sceneState.lastOffset = Offset.Unspecified - sceneState.offsetBeforeInterruption = Offset.Unspecified + sceneState.lastScale = Scale.Unspecified + sceneState.lastAlpha = Element.AlphaUnspecified + + sceneState.clearValuesBeforeInterruption() + sceneState.clearInterruptionDeltas() return } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/InterruptionHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/InterruptionHandler.kt new file mode 100644 index 000000000000..54c64fd721ec --- /dev/null +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/InterruptionHandler.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024 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 + +/** + * A handler to specify how a transition should be interrupted. + * + * @see DefaultInterruptionHandler + * @see SceneTransitionsBuilder.interruptionHandler + */ +interface InterruptionHandler { + /** + * This function is called when [interrupted] is interrupted: it is currently animating between + * [interrupted.fromScene] and [interrupted.toScene], and we will now animate to + * [newTargetScene]. + * + * If this returns `null`, then the [default behavior][DefaultInterruptionHandler] will be used: + * we will animate from [interrupted.currentScene] and chaining will be enabled (see + * [InterruptionResult] for more information about chaining). + * + * @see InterruptionResult + */ + fun onInterruption( + interrupted: TransitionState.Transition, + newTargetScene: SceneKey, + ): InterruptionResult? +} + +/** + * The result of an interruption that specifies how we should handle a transition A => B now that we + * have to animate to C. + * + * For instance, if the interrupted transition was A => B and currentScene = B: + * - animateFrom = B && chain = true => there will be 2 transitions running in parallel, A => B and + * B => C. + * - animateFrom = A && chain = true => there will be 2 transitions running in parallel, B => A and + * A => C. + * - animateFrom = B && chain = false => there will be 1 transition running, B => C. + * - animateFrom = A && chain = false => there will be 1 transition running, A => C. + */ +class InterruptionResult( + /** + * The scene we should animate from when transitioning to C. + * + * Important: This **must** be either [TransitionState.Transition.fromScene] or + * [TransitionState.Transition.toScene] of the transition that was interrupted. + */ + val animateFrom: SceneKey, + + /** + * Whether chaining is enabled, i.e. if the new transition to C should run in parallel with the + * previous one(s) or if it should be the only remaining transition that is running. + */ + val chain: Boolean = true, +) + +/** + * The default interruption handler: we animate from [TransitionState.Transition.currentScene] and + * chaining is enabled. + */ +object DefaultInterruptionHandler : InterruptionHandler { + override fun onInterruption( + interrupted: TransitionState.Transition, + newTargetScene: SceneKey, + ): InterruptionResult { + return InterruptionResult( + animateFrom = interrupted.currentScene, + chain = true, + ) + } +} diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt index 5fda77a3e0ae..7f94f0d88c5e 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt @@ -227,6 +227,9 @@ sealed interface TransitionState { */ abstract val progress: Float + /** The current velocity of [progress], in progress units. */ + abstract val progressVelocity: Float + /** Whether the transition was triggered by user input rather than being programmatic. */ abstract val isInitiatedByUserInput: Boolean @@ -422,13 +425,18 @@ internal abstract class BaseSceneTransitionLayoutState( } /** - * Start a new [transition], instantly interrupting any ongoing transition if there was one. + * Start a new [transition]. + * + * If [chain] is `true`, then the transitions will simply be added to [currentTransitions] and + * will run in parallel to the current transitions. If [chain] is `false`, then the list of + * [currentTransitions] will be cleared and [transition] will be the only running transition. * * Important: you *must* call [finishTransition] once the transition is finished. */ internal fun startTransition( transition: TransitionState.Transition, transitionKey: TransitionKey?, + chain: Boolean = true, ) { // Compute the [TransformationSpec] when the transition starts. val fromScene = transition.fromScene @@ -471,26 +479,10 @@ internal abstract class BaseSceneTransitionLayoutState( finishTransition(currentState, currentState.currentScene) } - // Check that we don't have too many concurrent transitions. - if (transitionStates.size >= MAX_CONCURRENT_TRANSITIONS) { - Log.wtf( - TAG, - buildString { - appendLine("Potential leak detected in SceneTransitionLayoutState!") - appendLine( - " Some transition(s) never called STLState.finishTransition()." - ) - appendLine(" Transitions (size=${transitionStates.size}):") - transitionStates.fastForEach { state -> - val transition = state as TransitionState.Transition - val from = transition.fromScene - val to = transition.toScene - val indicator = - if (finishedTransitions.contains(transition)) "x" else " " - appendLine(" [$indicator] $from => $to ($transition)") - } - } - ) + val tooManyTransitions = transitionStates.size >= MAX_CONCURRENT_TRANSITIONS + val clearCurrentTransitions = !chain || tooManyTransitions + if (clearCurrentTransitions) { + if (tooManyTransitions) logTooManyTransitions() // Force finish all transitions. while (currentTransitions.isNotEmpty()) { @@ -511,6 +503,24 @@ internal abstract class BaseSceneTransitionLayoutState( } } + private fun logTooManyTransitions() { + Log.wtf( + TAG, + buildString { + appendLine("Potential leak detected in SceneTransitionLayoutState!") + appendLine(" Some transition(s) never called STLState.finishTransition().") + appendLine(" Transitions (size=${transitionStates.size}):") + transitionStates.fastForEach { state -> + val transition = state as TransitionState.Transition + val from = transition.fromScene + val to = transition.toScene + val indicator = if (finishedTransitions.contains(transition)) "x" else " " + appendLine(" [$indicator] $from => $to ($transition)") + } + } + ) + } + private fun cancelActiveTransitionLinks() { for ((link, linkedTransition) in activeTransitionLinks) { link.target.finishTransition(linkedTransition, linkedTransition.currentScene) diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt index b46614397ff4..0f6a1d276578 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt @@ -44,6 +44,7 @@ internal constructor( internal val defaultSwipeSpec: SpringSpec<Float>, internal val transitionSpecs: List<TransitionSpecImpl>, internal val overscrollSpecs: List<OverscrollSpecImpl>, + internal val interruptionHandler: InterruptionHandler, ) { private val transitionCache = mutableMapOf< @@ -145,6 +146,7 @@ internal constructor( defaultSwipeSpec = DefaultSwipeSpec, transitionSpecs = emptyList(), overscrollSpecs = emptyList(), + interruptionHandler = DefaultInterruptionHandler, ) } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt index 6bc397e86cfa..a4682ff2a885 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt @@ -40,6 +40,12 @@ interface SceneTransitionsBuilder { var defaultSwipeSpec: SpringSpec<Float> /** + * The [InterruptionHandler] used when transitions are interrupted. Defaults to + * [DefaultInterruptionHandler]. + */ + var interruptionHandler: InterruptionHandler + + /** * Define the default animation to be played when transitioning [to] the specified scene, from * any scene. For the animation specification to apply only when transitioning between two * specific scenes, use [from] instead. diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt index 1c9080fa085d..802ab1f2eebb 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt @@ -47,12 +47,14 @@ internal fun transitionsImpl( return SceneTransitions( impl.defaultSwipeSpec, impl.transitionSpecs, - impl.transitionOverscrollSpecs + impl.transitionOverscrollSpecs, + impl.interruptionHandler, ) } private class SceneTransitionsBuilderImpl : SceneTransitionsBuilder { override var defaultSwipeSpec: SpringSpec<Float> = SceneTransitions.DefaultSwipeSpec + override var interruptionHandler: InterruptionHandler = DefaultInterruptionHandler val transitionSpecs = mutableListOf<TransitionSpecImpl>() val transitionOverscrollSpecs = mutableListOf<OverscrollSpecImpl>() diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt index 73393a1ab0cf..79f126d24561 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt @@ -45,5 +45,8 @@ internal class LinkedTransition( override val progress: Float get() = originalTransition.progress + override val progressVelocity: Float + get() = originalTransition.progressVelocity + override fun finish(): Job = originalTransition.finish() } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt index 92e1b2cd030c..bbf3d8a5571e 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt @@ -1049,24 +1049,30 @@ class ElementTest { Box(modifier.element(TestElements.Foo).size(fooSize)) } + lateinit var layoutImpl: SceneTransitionLayoutImpl rule.setContent { - SceneTransitionLayout(state, Modifier.size(layoutSize)) { + SceneTransitionLayoutForTesting( + state, + Modifier.size(layoutSize), + onLayoutImpl = { layoutImpl = it }, + ) { // In scene A, Foo is aligned at the TopStart. scene(SceneA) { Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopStart)) } } + // In scene C, Foo is aligned at the BottomEnd, so it moves vertically when coming + // from B. We put it before (below) scene B so that we can check that interruptions + // values and deltas are properly cleared once all transitions are done. + scene(SceneC) { + Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.BottomEnd)) } + } + // In scene B, Foo is aligned at the TopEnd, so it moves horizontally when coming // from A. scene(SceneB) { Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopEnd)) } } - - // In scene C, Foo is aligned at the BottomEnd, so it moves vertically when coming - // from B. - scene(SceneC) { - Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.BottomEnd)) } - } } } @@ -1115,7 +1121,7 @@ class ElementTest { // Interruption progress is at 100% and bToC is at 0%, so Foo should be at the same offset // as right before the interruption. rule - .onNode(isElement(TestElements.Foo, SceneC)) + .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo(offsetInAToB.x, offsetInAToB.y) // Move the transition forward at 30% and set the interruption progress to 50%. @@ -1130,7 +1136,7 @@ class ElementTest { ) rule.waitForIdle() rule - .onNode(isElement(TestElements.Foo, SceneC)) + .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo( offsetInBToCWithInterruption.x, offsetInBToCWithInterruption.y, @@ -1140,7 +1146,24 @@ class ElementTest { bToCProgress = 1f interruptionProgress = 0f rule - .onNode(isElement(TestElements.Foo, SceneC)) + .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) + + // Manually finish the transition. + state.finishTransition(aToB, SceneB) + state.finishTransition(bToC, SceneC) + rule.waitForIdle() + assertThat(state.currentTransition).isNull() + + // The interruption values should be unspecified and deltas should be set to zero. + val foo = layoutImpl.elements.getValue(TestElements.Foo) + assertThat(foo.sceneStates.keys).containsExactly(SceneC) + val stateInC = foo.sceneStates.getValue(SceneC) + assertThat(stateInC.offsetBeforeInterruption).isEqualTo(Offset.Unspecified) + assertThat(stateInC.scaleBeforeInterruption).isEqualTo(Scale.Unspecified) + assertThat(stateInC.alphaBeforeInterruption).isEqualTo(Element.AlphaUnspecified) + assertThat(stateInC.offsetInterruptionDelta).isEqualTo(Offset.Zero) + assertThat(stateInC.scaleInterruptionDelta).isEqualTo(Scale.Zero) + assertThat(stateInC.alphaInterruptionDelta).isEqualTo(0f) } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/InterruptionHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/InterruptionHandlerTest.kt new file mode 100644 index 000000000000..ba9cf7f12a2b --- /dev/null +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/InterruptionHandlerTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2024 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 + +import androidx.compose.animation.core.tween +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.compose.animation.scene.TestScenes.SceneA +import com.android.compose.animation.scene.TestScenes.SceneB +import com.android.compose.animation.scene.TestScenes.SceneC +import com.android.compose.test.runMonotonicClockTest +import com.google.common.truth.Correspondence +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InterruptionHandlerTest { + @get:Rule val rule = createComposeRule() + + @Test + fun default() = runMonotonicClockTest { + val state = + MutableSceneTransitionLayoutState( + SceneA, + transitions { /* default interruption handler */}, + ) + + state.setTargetScene(SceneB, coroutineScope = this) + state.setTargetScene(SceneC, coroutineScope = this) + + assertThat(state.currentTransitions) + .comparingElementsUsing(FromToCurrentTriple) + .containsExactly( + // A to B. + Triple(SceneA, SceneB, SceneB), + + // B to C. + Triple(SceneB, SceneC, SceneC), + ) + .inOrder() + } + + @Test + fun chainingDisabled() = runMonotonicClockTest { + val state = + MutableSceneTransitionLayoutState( + SceneA, + transitions { + // Handler that animates from currentScene (default) but disables chaining. + interruptionHandler = + object : InterruptionHandler { + override fun onInterruption( + interrupted: TransitionState.Transition, + newTargetScene: SceneKey + ): InterruptionResult { + return InterruptionResult( + animateFrom = interrupted.currentScene, + chain = false, + ) + } + } + }, + ) + + state.setTargetScene(SceneB, coroutineScope = this) + state.setTargetScene(SceneC, coroutineScope = this) + + assertThat(state.currentTransitions) + .comparingElementsUsing(FromToCurrentTriple) + .containsExactly( + // B to C. + Triple(SceneB, SceneC, SceneC), + ) + .inOrder() + } + + @Test + fun animateFromOtherScene() = runMonotonicClockTest { + val duration = 500 + val state = + MutableSceneTransitionLayoutState( + SceneA, + transitions { + // Handler that animates from the scene that is not currentScene. + interruptionHandler = + object : InterruptionHandler { + override fun onInterruption( + interrupted: TransitionState.Transition, + newTargetScene: SceneKey + ): InterruptionResult { + return InterruptionResult( + animateFrom = + if (interrupted.currentScene == interrupted.toScene) { + interrupted.fromScene + } else { + interrupted.toScene + } + ) + } + } + + from(SceneA, to = SceneB) { spec = tween(duration) } + }, + ) + + // Animate to B and advance the transition a little bit so that progress > visibility + // threshold and that reversing from B back to A won't immediately snap to A. + state.setTargetScene(SceneB, coroutineScope = this) + testScheduler.advanceTimeBy(duration / 2L) + + state.setTargetScene(SceneC, coroutineScope = this) + + assertThat(state.currentTransitions) + .comparingElementsUsing(FromToCurrentTriple) + .containsExactly( + // Initial transition A to B. This transition will never be consumed by anyone given + // that it has the same (from, to) pair as the next transition. + Triple(SceneA, SceneB, SceneB), + + // Initial transition reversed, B back to A. + Triple(SceneA, SceneB, SceneA), + + // A to C. + Triple(SceneA, SceneC, SceneC), + ) + .inOrder() + } + + @Test + fun animateToFromScene() = runMonotonicClockTest { + val state = MutableSceneTransitionLayoutStateImpl(SceneA, transitions {}) + + // Fake a transition from A to B that has a non 0 velocity. + val progressVelocity = 1f + val aToB = + transition( + from = SceneA, + to = SceneB, + current = { SceneB }, + // Progress must be > visibility threshold otherwise we will directly snap to A. + progress = { 0.5f }, + progressVelocity = { progressVelocity }, + onFinish = { launch {} }, + ) + state.startTransition(aToB, transitionKey = null) + + // Animate back to A. The previous transition is reversed, i.e. it has the same (from, to) + // pair, and its velocity is used when animating the progress back to 0. + val bToA = checkNotNull(state.setTargetScene(SceneA, coroutineScope = this)) + testScheduler.runCurrent() + assertThat(bToA.fromScene).isEqualTo(SceneA) + assertThat(bToA.toScene).isEqualTo(SceneB) + assertThat(bToA.currentScene).isEqualTo(SceneA) + assertThat(bToA.progressVelocity).isEqualTo(progressVelocity) + } + + @Test + fun animateToToScene() = runMonotonicClockTest { + val state = MutableSceneTransitionLayoutStateImpl(SceneA, transitions {}) + + // Fake a transition from A to B with current scene = A that has a non 0 velocity. + val progressVelocity = -1f + val aToB = + transition( + from = SceneA, + to = SceneB, + current = { SceneA }, + progressVelocity = { progressVelocity }, + onFinish = { launch {} }, + ) + state.startTransition(aToB, transitionKey = null) + + // Animate to B. The previous transition is reversed, i.e. it has the same (from, to) pair, + // and its velocity is used when animating the progress to 1. + val bToA = checkNotNull(state.setTargetScene(SceneB, coroutineScope = this)) + testScheduler.runCurrent() + assertThat(bToA.fromScene).isEqualTo(SceneA) + assertThat(bToA.toScene).isEqualTo(SceneB) + assertThat(bToA.currentScene).isEqualTo(SceneB) + assertThat(bToA.progressVelocity).isEqualTo(progressVelocity) + } + + companion object { + val FromToCurrentTriple = + Correspondence.transforming( + { transition: TransitionState.Transition? -> + Triple(transition?.fromScene, transition?.toScene, transition?.currentScene) + }, + "(from, to, current) triple" + ) + } +} diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/Transition.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/Transition.kt index c49a5b85ebe3..a609be48a225 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/Transition.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/Transition.kt @@ -29,6 +29,7 @@ fun transition( to: SceneKey, current: () -> SceneKey = { from }, progress: () -> Float = { 0f }, + progressVelocity: () -> Float = { 0f }, interruptionProgress: () -> Float = { 100f }, isInitiatedByUserInput: Boolean = false, isUserInputOngoing: Boolean = false, @@ -42,6 +43,8 @@ fun transition( get() = current() override val progress: Float get() = progress() + override val progressVelocity: Float + get() = progressVelocity() override val isInitiatedByUserInput: Boolean = isInitiatedByUserInput override val isUserInputOngoing: Boolean = isUserInputOngoing |