diff options
| -rw-r--r-- | packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt | 2 | ||||
| -rw-r--r-- | packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt | 309 | ||||
| -rw-r--r-- | packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt | 2 | ||||
| -rw-r--r-- | packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt | 37 | ||||
| -rw-r--r-- | packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt | 122 | ||||
| -rw-r--r-- | packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/Transition.kt (renamed from packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/Transition.kt) | 22 |
6 files changed, 459 insertions, 35 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 da07f6d12a67..6b289f3c66a3 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 @@ -190,4 +190,4 @@ private class OneOffTransition( // TODO(b/290184746): Compute a good default visibility threshold that depends on the layout size // and screen density. -private const val ProgressVisibilityThreshold = 1e-3f +internal const val ProgressVisibilityThreshold = 1e-3f 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 7d43ca8ddd37..4273b4fbf7b8 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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.geometry.isUnspecified import androidx.compose.ui.geometry.lerp import androidx.compose.ui.graphics.CompositingStrategy @@ -55,9 +56,15 @@ import kotlinx.coroutines.launch internal class Element(val key: ElementKey) { /** The mapping between a scene and the state this element has in that scene, if any. */ // TODO(b/316901148): Make this a normal map instead once we can make sure that new transitions - // are first seen by composition then layout/drawing code. See 316901148#comment2 for details. + // are first seen by composition then layout/drawing code. See b/316901148#comment2 for details. val sceneStates = SnapshotStateMap<SceneKey, SceneState>() + /** + * The last transition that was used when computing the state (size, position and alpha) of this + * element in any scene, or `null` if it was last laid out when idle. + */ + var lastTransition: TransitionState.Transition? = null + override fun toString(): String { return "Element(key=$key)" } @@ -65,9 +72,33 @@ internal class Element(val key: ElementKey) { /** The last and target state of this element in a given scene. */ @Stable class SceneState(val scene: SceneKey) { + /** + * The *target* state of this element in this scene, i.e. the state of this element when we + * are idle on this scene. + */ var targetSize by mutableStateOf(SizeUnspecified) var targetOffset by mutableStateOf(Offset.Unspecified) + /** The last state this element had in this scene. */ + var lastOffset = Offset.Unspecified + var lastScale = Scale.Unspecified + var lastAlpha = AlphaUnspecified + + /** The state of this element in this scene right before the last interruption (if any). */ + var offsetBeforeInterruption = Offset.Unspecified + var scaleBeforeInterruption = Scale.Unspecified + var alphaBeforeInterruption = AlphaUnspecified + + /** + * The delta values to add to this element state to have smoother interruptions. These + * should be multiplied by the + * [current interruption progress][TransitionState.Transition.interruptionProgress] so that + * they nicely animate from their values down to 0. + */ + var offsetInterruptionDelta = Offset.Zero + var scaleInterruptionDelta = Scale.Zero + var alphaInterruptionDelta = 0f + /** * The attached [ElementNode] a Modifier.element() for a given element and scene. During * composition, this set could have 0 to 2 elements. After composition and after all @@ -78,12 +109,15 @@ internal class Element(val key: ElementKey) { companion object { val SizeUnspecified = IntSize(Int.MAX_VALUE, Int.MAX_VALUE) + val AlphaUnspecified = Float.MAX_VALUE } } data class Scale(val scaleX: Float, val scaleY: Float, val pivot: Offset = Offset.Unspecified) { companion object { val Default = Scale(1f, 1f, Offset.Unspecified) + val Zero = Scale(0f, 0f, Offset.Zero) + val Unspecified = Scale(Float.MAX_VALUE, Float.MAX_VALUE, Offset.Unspecified) } } @@ -212,6 +246,10 @@ internal class ElementNode( val isOtherSceneOverscrolling = overscrollScene != null && overscrollScene != scene.key val isNotPartOfAnyOngoingTransitions = transitions.isNotEmpty() && transition == null if (isNotPartOfAnyOngoingTransitions || isOtherSceneOverscrolling) { + sceneState.lastOffset = Offset.Unspecified + sceneState.lastScale = Scale.Unspecified + sceneState.lastAlpha = Element.AlphaUnspecified + val placeable = measurable.measure(constraints) return layout(placeable.width, placeable.height) {} } @@ -233,7 +271,7 @@ internal class ElementNode( override fun ContentDrawScope.draw() { val transition = elementTransition(element, layoutImpl.state.currentTransitions) - val drawScale = getDrawScale(layoutImpl, scene, element, transition) + val drawScale = getDrawScale(layoutImpl, scene, element, transition, sceneState) if (drawScale == Scale.Default) { drawContent() } else { @@ -276,8 +314,116 @@ private fun elementTransition( element: Element, transitions: List<TransitionState.Transition>, ): TransitionState.Transition? { - return transitions.fastLastOrNull { transition -> - transition.fromScene in element.sceneStates || transition.toScene in element.sceneStates + val transition = + transitions.fastLastOrNull { transition -> + transition.fromScene in element.sceneStates || transition.toScene in element.sceneStates + } + + val previousTransition = element.lastTransition + element.lastTransition = transition + + if (transition != previousTransition && transition != null && previousTransition != null) { + // The previous transition was interrupted by another transition. + prepareInterruption(element) + } + + 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 + } + } + + return transition +} + +private fun prepareInterruption(element: Element) { + // We look for the last unique state of this element so that we animate the delta with its + // future state. + val sceneStates = element.sceneStates.values + var lastUniqueState: Element.SceneState? = null + for (sceneState in sceneStates) { + val offset = sceneState.lastOffset + + // If the element was placed in this scene... + if (offset != Offset.Unspecified) { + // ... and it is the first (and potentially the only) scene where the element was + // placed, save the state for later. + if (lastUniqueState == null) { + lastUniqueState = sceneState + } else { + // The element was placed in multiple scenes: we abort the interruption for this + // element. + // TODO(b/290930950): Better support cases where a shared element animation is + // disabled and the same element is drawn/placed in multiple scenes at the same + // time. + lastUniqueState = null + break + } + } + } + + val lastOffset = lastUniqueState?.lastOffset ?: Offset.Unspecified + val lastScale = lastUniqueState?.lastScale ?: Scale.Unspecified + val lastAlpha = lastUniqueState?.lastAlpha ?: Element.AlphaUnspecified + + // Store the state of the element before the interruption and reset the deltas. + sceneStates.forEach { sceneState -> + sceneState.offsetBeforeInterruption = lastOffset + sceneState.scaleBeforeInterruption = lastScale + sceneState.alphaBeforeInterruption = lastAlpha + + sceneState.offsetInterruptionDelta = Offset.Zero + sceneState.scaleInterruptionDelta = Scale.Zero + sceneState.alphaInterruptionDelta = 0f + } +} + +/** + * Compute what [value] should be if we take the + * [interruption progress][TransitionState.Transition.interruptionProgress] of [transition] into + * account. + */ +private inline fun <T> computeInterruptedValue( + layoutImpl: SceneTransitionLayoutImpl, + transition: TransitionState.Transition?, + value: T, + unspecifiedValue: T, + zeroValue: T, + getValueBeforeInterruption: () -> T, + setValueBeforeInterruption: (T) -> Unit, + getInterruptionDelta: () -> T, + setInterruptionDelta: (T) -> Unit, + diff: (a: T, b: T) -> T, // a - b + add: (a: T, b: T, bProgress: Float) -> T, // a + (b * bProgress) +): T { + val valueBeforeInterruption = getValueBeforeInterruption() + + // If the value before the interruption is specified, it means that this is the first time we + // compute [value] right after an interruption. + if (valueBeforeInterruption != unspecifiedValue) { + // Compute and store the delta between the value before the interruption and the current + // value. + setInterruptionDelta(diff(valueBeforeInterruption, value)) + + // Reset the value before interruption now that we processed it. + setValueBeforeInterruption(unspecifiedValue) + } + + val delta = getInterruptionDelta() + return if (delta == zeroValue || transition == null) { + // There was no interruption or there is no transition: just return the value. + value + } else { + // Add `delta * interruptionProgress` to the value so that we animate to value. + val interruptionProgress = transition.interruptionProgress(layoutImpl) + if (interruptionProgress == 0f) { + value + } else { + add(value, delta, interruptionProgress) + } } } @@ -417,20 +563,47 @@ private fun elementAlpha( scene: Scene, element: Element, transition: TransitionState.Transition?, + sceneState: Element.SceneState, ): Float { - return computeValue( - layoutImpl, - scene, - element, - transition, - sceneValue = { 1f }, - transformation = { it.alpha }, - idleValue = 1f, - currentValue = { 1f }, - isSpecified = { true }, - ::lerp, - ) - .fastCoerceIn(0f, 1f) + val alpha = + computeValue( + layoutImpl, + scene, + element, + transition, + sceneValue = { 1f }, + transformation = { it.alpha }, + idleValue = 1f, + currentValue = { 1f }, + isSpecified = { true }, + ::lerp, + ) + .fastCoerceIn(0f, 1f) + + val interruptedAlpha = interruptedAlpha(layoutImpl, transition, sceneState, alpha) + sceneState.lastAlpha = interruptedAlpha + return interruptedAlpha +} + +private fun interruptedAlpha( + layoutImpl: SceneTransitionLayoutImpl, + transition: TransitionState.Transition?, + sceneState: Element.SceneState, + alpha: Float, +): Float { + return computeInterruptedValue( + layoutImpl, + transition, + value = alpha, + unspecifiedValue = Element.AlphaUnspecified, + zeroValue = 0f, + getValueBeforeInterruption = { sceneState.alphaBeforeInterruption }, + setValueBeforeInterruption = { sceneState.alphaBeforeInterruption = it }, + getInterruptionDelta = { sceneState.alphaInterruptionDelta }, + setInterruptionDelta = { sceneState.alphaInterruptionDelta = it }, + diff = { a, b -> a - b }, + add = { a, b, bProgress -> a + b * bProgress }, + ) } @OptIn(ExperimentalComposeUiApi::class) @@ -480,24 +653,70 @@ private fun ApproachMeasureScope.measure( ) } -private fun getDrawScale( +private fun ContentDrawScope.getDrawScale( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, transition: TransitionState.Transition?, + sceneState: Element.SceneState, ): Scale { - return computeValue( - layoutImpl, - scene, - element, - transition, - sceneValue = { Scale.Default }, - transformation = { it.drawScale }, - idleValue = Scale.Default, - currentValue = { Scale.Default }, - isSpecified = { true }, - ::lerp, - ) + val scale = + computeValue( + layoutImpl, + scene, + element, + transition, + sceneValue = { Scale.Default }, + transformation = { it.drawScale }, + idleValue = Scale.Default, + currentValue = { Scale.Default }, + isSpecified = { true }, + ::lerp, + ) + + fun Offset.specifiedOrCenter(): Offset { + return this.takeIf { isSpecified } ?: center + } + + val interruptedScale = + computeInterruptedValue( + layoutImpl, + transition, + value = scale, + unspecifiedValue = Scale.Unspecified, + zeroValue = Scale.Zero, + getValueBeforeInterruption = { sceneState.scaleBeforeInterruption }, + setValueBeforeInterruption = { sceneState.scaleBeforeInterruption = it }, + getInterruptionDelta = { sceneState.scaleInterruptionDelta }, + setInterruptionDelta = { sceneState.scaleInterruptionDelta = it }, + diff = { a, b -> + Scale( + scaleX = a.scaleX - b.scaleX, + scaleY = a.scaleY - b.scaleY, + pivot = + if (a.pivot.isUnspecified && b.pivot.isUnspecified) { + Offset.Unspecified + } else { + a.pivot.specifiedOrCenter() - b.pivot.specifiedOrCenter() + } + ) + }, + add = { a, b, bProgress -> + Scale( + scaleX = a.scaleX + b.scaleX * bProgress, + scaleY = a.scaleY + b.scaleY * bProgress, + pivot = + if (a.pivot.isUnspecified && b.pivot.isUnspecified) { + Offset.Unspecified + } else { + a.pivot.specifiedOrCenter() + b.pivot.specifiedOrCenter() * bProgress + } + ) + } + ) + + sceneState.lastScale = interruptedScale + return interruptedScale } @OptIn(ExperimentalComposeUiApi::class) @@ -524,6 +743,8 @@ 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 return } @@ -542,15 +763,37 @@ private fun ApproachMeasureScope.place( ::lerp, ) - val offset = (targetOffset - currentOffset).round() - if (isElementOpaque(scene, element, transition)) { + val interruptedOffset = + computeInterruptedValue( + layoutImpl, + transition, + value = targetOffset, + unspecifiedValue = Offset.Unspecified, + zeroValue = Offset.Zero, + getValueBeforeInterruption = { sceneState.offsetBeforeInterruption }, + setValueBeforeInterruption = { sceneState.offsetBeforeInterruption = it }, + getInterruptionDelta = { sceneState.offsetInterruptionDelta }, + setInterruptionDelta = { sceneState.offsetInterruptionDelta = it }, + diff = { a, b -> a - b }, + add = { a, b, bProgress -> a + b * bProgress }, + ) + + sceneState.lastOffset = interruptedOffset + + val offset = (interruptedOffset - currentOffset).round() + if ( + isElementOpaque(scene, element, transition) && + interruptedAlpha(layoutImpl, transition, sceneState, alpha = 1f) == 1f + ) { + sceneState.lastAlpha = 1f + // TODO(b/291071158): Call placeWithLayer() if offset != IntOffset.Zero and size is not // animated once b/305195729 is fixed. Test that drawing is not invalidated in that // case. placeable.place(offset) } else { placeable.placeWithLayer(offset) { - alpha = elementAlpha(layoutImpl, scene, element, transition) + alpha = elementAlpha(layoutImpl, scene, element, transition, sceneState) compositingStrategy = CompositingStrategy.ModulateAlpha } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt index 20dcc2044c8b..ad691ba54607 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt @@ -49,7 +49,7 @@ internal class SceneTransitionLayoutImpl( internal var swipeSourceDetector: SwipeSourceDetector, internal var transitionInterceptionThreshold: Float, builder: SceneTransitionLayoutScope.() -> Unit, - private val coroutineScope: CoroutineScope, + internal val coroutineScope: CoroutineScope, ) { /** * The map of [Scene]s. 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 f13c016e9d68..5fda77a3e0ae 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 @@ -18,6 +18,9 @@ package com.android.compose.animation.scene import android.util.Log import androidx.annotation.VisibleForTesting +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.spring import androidx.compose.foundation.gestures.Orientation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,6 +37,7 @@ import kotlin.math.absoluteValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch /** * The state of a [SceneTransitionLayout]. @@ -253,6 +257,12 @@ sealed interface TransitionState { } } + /** + * An animatable that animates from 1f to 0f. This will be used to nicely animate the sudden + * jump of values when this transitions interrupts another one. + */ + private var interruptionDecay: Animatable<Float, AnimationVector1D>? = null + init { check(fromScene != toScene) } @@ -289,6 +299,33 @@ sealed interface TransitionState { fromOverscrollSpec = fromSpec toOverscrollSpec = toSpec } + + internal open fun interruptionProgress( + layoutImpl: SceneTransitionLayoutImpl, + ): Float { + if (!layoutImpl.state.enableInterruptions) { + return 0f + } + + fun create(): Animatable<Float, AnimationVector1D> { + val animatable = Animatable(1f, visibilityThreshold = ProgressVisibilityThreshold) + layoutImpl.coroutineScope.launch { + val swipeSpec = layoutImpl.state.transitions.defaultSwipeSpec + val progressSpec = + spring( + stiffness = swipeSpec.stiffness, + dampingRatio = swipeSpec.dampingRatio, + visibilityThreshold = ProgressVisibilityThreshold, + ) + animatable.animateTo(0f, progressSpec) + } + + return animatable + } + + val animatable = interruptionDecay ?: create().also { interruptionDecay = it } + return animatable.value + } } interface HasOverscrollProperties { 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 b7fc91c4e762..b1d7055573b6 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -55,7 +56,10 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.compose.animation.scene.TestScenes.SceneA import com.android.compose.animation.scene.TestScenes.SceneB @@ -1019,4 +1023,122 @@ class ElementTest { rule.onNode(isElement(TestElements.Foo)).assertDoesNotExist() rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(100.dp, 100.dp) } + + @Test + fun interruption() = runTest { + // 4 frames of animation. + val duration = 4 * 16 + + val state = + MutableSceneTransitionLayoutStateImpl( + SceneA, + transitions { + from(SceneA, to = SceneB) { spec = tween(duration, easing = LinearEasing) } + from(SceneB, to = SceneC) { spec = tween(duration, easing = LinearEasing) } + }, + enableInterruptions = false, + ) + + val layoutSize = DpSize(200.dp, 100.dp) + val fooSize = DpSize(20.dp, 10.dp) + + @Composable + fun SceneScope.Foo(modifier: Modifier = Modifier) { + Box(modifier.element(TestElements.Foo).size(fooSize)) + } + + rule.setContent { + SceneTransitionLayout(state, Modifier.size(layoutSize)) { + // In scene A, Foo is aligned at the TopStart. + scene(SceneA) { + Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopStart)) } + } + + // 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)) } + } + } + } + + // The offset of Foo when idle in A, B or C. + val offsetInA = DpOffset.Zero + val offsetInB = DpOffset(layoutSize.width - fooSize.width, 0.dp) + val offsetInC = + DpOffset(layoutSize.width - fooSize.width, layoutSize.height - fooSize.height) + + // Initial state (idle in A). + rule + .onNode(isElement(TestElements.Foo, SceneA)) + .assertPositionInRootIsEqualTo(offsetInA.x, offsetInA.y) + + // Current transition is A => B at 50%. + val aToBProgress = 0.5f + val aToB = + transition( + from = SceneA, + to = SceneB, + progress = { aToBProgress }, + onFinish = neverFinish(), + ) + val offsetInAToB = lerp(offsetInA, offsetInB, aToBProgress) + rule.runOnUiThread { state.startTransition(aToB, transitionKey = null) } + rule + .onNode(isElement(TestElements.Foo, SceneB)) + .assertPositionInRootIsEqualTo(offsetInAToB.x, offsetInAToB.y) + + // Start B => C at 0%. + var bToCProgress by mutableFloatStateOf(0f) + var interruptionProgress by mutableFloatStateOf(1f) + val bToC = + transition( + from = SceneB, + to = SceneC, + progress = { bToCProgress }, + interruptionProgress = { interruptionProgress }, + ) + rule.runOnUiThread { state.startTransition(bToC, transitionKey = null) } + + // The offset interruption delta, which will be multiplied by the interruption progress then + // added to the current transition offset. + val interruptionDelta = offsetInAToB - offsetInB + + // 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)) + .assertPositionInRootIsEqualTo(offsetInAToB.x, offsetInAToB.y) + + // Move the transition forward at 30% and set the interruption progress to 50%. + bToCProgress = 0.3f + interruptionProgress = 0.5f + val offsetInBToC = lerp(offsetInB, offsetInC, bToCProgress) + val offsetInBToCWithInterruption = + offsetInBToC + + DpOffset( + interruptionDelta.x * interruptionProgress, + interruptionDelta.y * interruptionProgress, + ) + rule.waitForIdle() + rule + .onNode(isElement(TestElements.Foo, SceneC)) + .assertPositionInRootIsEqualTo( + offsetInBToCWithInterruption.x, + offsetInBToCWithInterruption.y, + ) + + // Finish the transition and interruption. + bToCProgress = 1f + interruptionProgress = 0f + rule + .onNode(isElement(TestElements.Foo, SceneC)) + .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) + } } diff --git a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/Transition.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/Transition.kt index 767057b585b8..c1218ae778ba 100644 --- a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/Transition.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/Transition.kt @@ -18,12 +18,17 @@ package com.android.compose.animation.scene import androidx.compose.foundation.gestures.Orientation import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.TestScope /** A utility to easily create a [TransitionState.Transition] in tests. */ fun transition( from: SceneKey, to: SceneKey, progress: () -> Float = { 0f }, + interruptionProgress: () -> Float = { 100f }, isInitiatedByUserInput: Boolean = false, isUserInputOngoing: Boolean = false, isUpOrLeft: Boolean = false, @@ -55,5 +60,22 @@ fun transition( return onFinish(this) } + + override fun interruptionProgress(layoutImpl: SceneTransitionLayoutImpl): Float { + return interruptionProgress() + } + } +} + +/** + * Return a onFinish lambda that can be used with [transition] so that the transition never + * finishes. This allows to keep the transition in the current transitions list. + */ +fun TestScope.neverFinish(): (TransitionState.Transition) -> Job { + return { + backgroundScope.launch { + // Try to acquire a locked mutex so that this code never completes. + Mutex(locked = true).withLock {} + } } } |