diff options
6 files changed, 402 insertions, 128 deletions
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 1b0627576af7..32cebd1fd7e2 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 @@ -280,7 +280,7 @@ private class DragControllerImpl( swipes.findUserActionResult( fromScene = fromScene, directionOffset = swipeTransition.dragOffset, - updateSwipesResults = isNewFromScene + updateSwipesResults = isNewFromScene, ) if (result == null) { @@ -288,13 +288,14 @@ private class DragControllerImpl( return } - swipeTransition.dragOffset += acceleratedOffset - if ( isNewFromScene || result.toScene != swipeTransition.toScene || result.transitionKey != swipeTransition.key ) { + // Make sure the current transition will finish to the right current scene. + swipeTransition._currentScene = fromScene + val swipeTransition = SwipeTransition( layoutState = layoutState, @@ -305,7 +306,7 @@ private class DragControllerImpl( layoutImpl = draggableHandler.layoutImpl, orientation = draggableHandler.orientation, ) - .apply { dragOffset = swipeTransition.dragOffset } + .apply { dragOffset = swipeTransition.dragOffset + acceleratedOffset } updateTransition(swipeTransition) } 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 f4009ee56737..7d43ca8ddd37 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 @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.round import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastLastOrNull import androidx.compose.ui.util.lerp import com.android.compose.animation.scene.transformation.PropertyTransformation import com.android.compose.animation.scene.transformation.SharedElementTransformation @@ -81,14 +82,12 @@ internal class Element(val key: ElementKey) { } data class Scale(val scaleX: Float, val scaleY: Float, val pivot: Offset = Offset.Unspecified) { - companion object { val Default = Scale(1f, 1f, Offset.Unspecified) } } /** The implementation of [SceneScope.element]. */ -@OptIn(ExperimentalComposeUiApi::class) @Stable internal fun Modifier.element( layoutImpl: SceneTransitionLayoutImpl, @@ -187,7 +186,7 @@ internal class ElementNode( override fun isMeasurementApproachComplete(lookaheadSize: IntSize): Boolean { // TODO(b/324191441): Investigate whether making this check more complex (checking if this // element is shared or transformed) would lead to better performance. - return layoutImpl.state.currentTransition == null + return layoutImpl.state.currentTransitions.isEmpty() } override fun Placeable.PlacementScope.isPlacementApproachComplete( @@ -195,7 +194,7 @@ internal class ElementNode( ): Boolean { // TODO(b/324191441): Investigate whether making this check more complex (checking if this // element is shared or transformed) would lead to better performance. - return layoutImpl.state.currentTransition == null + return layoutImpl.state.currentTransitions.isEmpty() } @ExperimentalComposeUiApi @@ -203,25 +202,38 @@ internal class ElementNode( measurable: Measurable, constraints: Constraints, ): MeasureResult { - val overscrollScene = layoutImpl.state.currentTransition?.currentOverscrollSpec?.scene - if (overscrollScene != null && overscrollScene != scene.key) { - // There is an overscroll in progress on another scene - // By measuring composable elements, Compose can cache relevant information. - // This reduces the need for re-measure when users return from an overscroll animation. + val transitions = layoutImpl.state.currentTransitions + val transition = elementTransition(element, transitions) + + // If this element is not supposed to be laid out now, either because it is not part of any + // ongoing transition or the other scene of its transition is overscrolling, then lay out + // the element normally and don't place it. + val overscrollScene = transition?.currentOverscrollSpec?.scene + val isOtherSceneOverscrolling = overscrollScene != null && overscrollScene != scene.key + val isNotPartOfAnyOngoingTransitions = transitions.isNotEmpty() && transition == null + if (isNotPartOfAnyOngoingTransitions || isOtherSceneOverscrolling) { val placeable = measurable.measure(constraints) - return layout(placeable.width, placeable.height) { - // We don't want to draw it, no need to place the element. - } + return layout(placeable.width, placeable.height) {} } - val placeable = measure(layoutImpl, scene, element, sceneState, measurable, constraints) + val placeable = + measure(layoutImpl, scene, element, transition, sceneState, measurable, constraints) return layout(placeable.width, placeable.height) { - place(layoutImpl, scene, element, sceneState, placeable, placementScope = this) + place( + layoutImpl, + scene, + element, + transition, + sceneState, + placeable, + placementScope = this, + ) } } override fun ContentDrawScope.draw() { - val drawScale = getDrawScale(layoutImpl, element, scene) + val transition = elementTransition(element, layoutImpl.state.currentTransitions) + val drawScale = getDrawScale(layoutImpl, scene, element, transition) if (drawScale == Scale.Default) { drawContent() } else { @@ -256,45 +268,64 @@ internal class ElementNode( } } -private fun shouldDrawElement( +/** + * The transition that we should consider for [element]. This is the last transition where one of + * its scenes contains the element. + */ +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 + } +} + +private fun shouldPlaceElement( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, + transition: TransitionState.Transition?, ): Boolean { - val transition = layoutImpl.state.currentTransition ?: return true - - val inFromScene = transition.fromScene in element.sceneStates - val inToScene = transition.toScene in element.sceneStates + // Always place the element if we are idle. + if (transition == null) { + return true + } - // If an element is not present in any scene, it should not be drawn. - if (!inFromScene && !inToScene) { + // Don't place the element in this scene if this scene is not part of the current element + // transition. + if (scene.key != transition.fromScene && scene.key != transition.toScene) { return false } - // Always draw if the element is not shared or if the current scene is the one that is currently - // over scrolling with [OverscrollSpec]. - if (!inFromScene || !inToScene || transition.currentOverscrollSpec?.scene == scene.key) { + // Place the element if it is not shared or if the current scene is the one that is currently + // overscrolling with [OverscrollSpec]. + if ( + transition.fromScene !in element.sceneStates || + transition.toScene !in element.sceneStates || + transition.currentOverscrollSpec?.scene == scene.key + ) { return true } - val sharedTransformation = sharedElementTransformation(transition, element.key) + val sharedTransformation = sharedElementTransformation(element.key, transition) if (sharedTransformation?.enabled == false) { return true } return shouldDrawOrComposeSharedElement( layoutImpl, - transition, scene.key, element.key, + transition, ) } internal fun shouldDrawOrComposeSharedElement( layoutImpl: SceneTransitionLayoutImpl, - transition: TransitionState.Transition, scene: SceneKey, element: ElementKey, + transition: TransitionState.Transition, ): Boolean { val scenePicker = element.scenePicker val fromScene = transition.fromScene @@ -313,15 +344,15 @@ internal fun shouldDrawOrComposeSharedElement( } private fun isSharedElementEnabled( - transition: TransitionState.Transition, element: ElementKey, + transition: TransitionState.Transition, ): Boolean { - return sharedElementTransformation(transition, element)?.enabled ?: true + return sharedElementTransformation(element, transition)?.enabled ?: true } internal fun sharedElementTransformation( - transition: TransitionState.Transition, element: ElementKey, + transition: TransitionState.Transition, ): SharedElementTransformation? { val transformationSpec = transition.transformationSpec val sharedInFromScene = transformationSpec.transformations(element, transition.fromScene).shared @@ -346,11 +377,13 @@ internal fun sharedElementTransformation( * placement and we don't want to read the transition progress in that phase. */ private fun isElementOpaque( - layoutImpl: SceneTransitionLayoutImpl, - element: Element, scene: Scene, + element: Element, + transition: TransitionState.Transition?, ): Boolean { - val transition = layoutImpl.state.currentTransition ?: return true + if (transition == null) { + return true + } val fromScene = transition.fromScene val toScene = transition.toScene @@ -364,7 +397,7 @@ private fun isElementOpaque( } val isSharedElement = fromState != null && toState != null - if (isSharedElement && isSharedElementEnabled(transition, element.key)) { + if (isSharedElement && isSharedElementEnabled(element.key, transition)) { return true } @@ -381,13 +414,15 @@ private fun isElementOpaque( */ private fun elementAlpha( layoutImpl: SceneTransitionLayoutImpl, - element: Element, scene: Scene, + element: Element, + transition: TransitionState.Transition?, ): Float { return computeValue( layoutImpl, scene, element, + transition, sceneValue = { 1f }, transformation = { it.alpha }, idleValue = 1f, @@ -403,6 +438,7 @@ private fun ApproachMeasureScope.measure( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, + transition: TransitionState.Transition?, sceneState: Element.SceneState, measurable: Measurable, constraints: Constraints, @@ -426,6 +462,7 @@ private fun ApproachMeasureScope.measure( layoutImpl, scene, element, + transition, sceneValue = { it.targetSize }, transformation = { it.size }, idleValue = lookaheadSize, @@ -445,13 +482,15 @@ private fun ApproachMeasureScope.measure( private fun getDrawScale( layoutImpl: SceneTransitionLayoutImpl, + scene: Scene, element: Element, - scene: Scene + transition: TransitionState.Transition?, ): Scale { return computeValue( layoutImpl, scene, element, + transition, sceneValue = { Scale.Default }, transformation = { it.drawScale }, idleValue = Scale.Default, @@ -466,6 +505,7 @@ private fun ApproachMeasureScope.place( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, + transition: TransitionState.Transition?, sceneState: Element.SceneState, placeable: Placeable, placementScope: Placeable.PlacementScope, @@ -483,7 +523,7 @@ private fun ApproachMeasureScope.place( } // No need to place the element in this scene if we don't want to draw it anyways. - if (!shouldDrawElement(layoutImpl, scene, element)) { + if (!shouldPlaceElement(layoutImpl, scene, element, transition)) { return } @@ -493,6 +533,7 @@ private fun ApproachMeasureScope.place( layoutImpl, scene, element, + transition, sceneValue = { it.targetOffset }, transformation = { it.offset }, idleValue = targetOffsetInScene, @@ -502,14 +543,14 @@ private fun ApproachMeasureScope.place( ) val offset = (targetOffset - currentOffset).round() - if (isElementOpaque(layoutImpl, element, scene)) { + if (isElementOpaque(scene, element, transition)) { // 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, element, scene) + alpha = elementAlpha(layoutImpl, scene, element, transition) compositingStrategy = CompositingStrategy.ModulateAlpha } } @@ -540,6 +581,7 @@ private inline fun <T> computeValue( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, + transition: TransitionState.Transition?, sceneValue: (Element.SceneState) -> T, transformation: (ElementTransformations) -> PropertyTransformation<T>?, idleValue: T, @@ -547,13 +589,13 @@ private inline fun <T> computeValue( isSpecified: (T) -> Boolean, lerp: (T, T, Float) -> T, ): T { - val transition = - layoutImpl.state.currentTransition + if (transition == null) { // There is no ongoing transition. Even if this element SceneTransitionLayout is not // animated, the layout itself might be animated (e.g. by another parent // SceneTransitionLayout), in which case this element still need to participate in the // layout phase. - ?: return currentValue() + return currentValue() + } val fromScene = transition.fromScene val toScene = transition.toScene @@ -606,7 +648,7 @@ private inline fun <T> computeValue( // TODO(b/290184746): Support non linear shared paths as well as a way to make sure that shared // elements follow the finger direction. val isSharedElement = fromState != null && toState != null - if (isSharedElement && isSharedElementEnabled(transition, element.key)) { + if (isSharedElement && isSharedElementEnabled(element.key, transition)) { val start = sceneValue(fromState!!) val end = sceneValue(toState!!) diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt index be066fd0018a..4b20acaee2bd 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastLastOrNull @Composable internal fun Element( @@ -165,18 +166,33 @@ private fun shouldComposeMovableElement( scene: SceneKey, element: ElementKey, ): Boolean { - val transition = - layoutImpl.state.currentTransition + val transitions = layoutImpl.state.currentTransitions + if (transitions.isEmpty()) { // If we are idle, there is only one [scene] that is composed so we can compose our - // movable content here. - ?: return true + // movable content here. We still check that [scene] is equal to the current idle scene, to + // make sure we only compose it there. + return layoutImpl.state.transitionState.currentScene == scene + } + + // The current transition for this element is the last transition in which either fromScene or + // toScene contains the element. + val transition = + transitions.fastLastOrNull { transition -> + element.scenePicker.sceneDuringTransition( + element = element, + transition = transition, + fromSceneZIndex = layoutImpl.scenes.getValue(transition.fromScene).zIndex, + toSceneZIndex = layoutImpl.scenes.getValue(transition.toScene).zIndex, + ) != null + } + ?: return false // Always compose movable elements in the scene picked by their scene picker. return shouldDrawOrComposeSharedElement( layoutImpl, - transition, scene, element, + transition, ) } 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 dbec059715b4..20dcc2044c8b 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 @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachReversed import com.android.compose.ui.util.lerp import kotlinx.coroutines.CoroutineScope @@ -191,39 +192,45 @@ internal class SceneTransitionLayoutImpl( .then(LayoutElement(layoutImpl = this)) ) { LookaheadScope { - val scenesToCompose = - when (val state = state.transitionState) { - is TransitionState.Idle -> listOf(scene(state.currentScene)) - is TransitionState.Transition -> { - if (state.toScene != state.fromScene) { - listOf(scene(state.toScene), scene(state.fromScene)) - } else { - listOf(scene(state.fromScene)) - } - } - } + BackHandler() - // Handle back events. - val targetSceneForBackOrNull = - scene(state.transitionState.currentScene).userActions[Back]?.toScene - BackHandler( - enabled = targetSceneForBackOrNull != null, - ) { - targetSceneForBackOrNull?.let { targetSceneForBack -> - // TODO(b/290184746): Handle predictive back and use result.distance if - // specified. - if (state.canChangeScene(targetSceneForBack)) { - with(state) { coroutineScope.onChangeScene(targetSceneForBack) } - } - } + scenesToCompose().fastForEach { scene -> key(scene.key) { scene.Content() } } + } + } + } + + @Composable + private fun BackHandler() { + val targetSceneForBackOrNull = + scene(state.transitionState.currentScene).userActions[Back]?.toScene + BackHandler(enabled = targetSceneForBackOrNull != null) { + targetSceneForBackOrNull?.let { targetSceneForBack -> + // TODO(b/290184746): Handle predictive back and use result.distance if specified. + if (state.canChangeScene(targetSceneForBack)) { + with(state) { coroutineScope.onChangeScene(targetSceneForBack) } } + } + } + } - Box { - scenesToCompose.fastForEach { scene -> - val key = scene.key - key(key) { scene.Content() } + private fun scenesToCompose(): List<Scene> { + val transitions = state.currentTransitions + return if (transitions.isEmpty()) { + listOf(scene(state.transitionState.currentScene)) + } else { + buildList { + val visited = mutableSetOf<SceneKey>() + fun maybeAdd(sceneKey: SceneKey) { + if (visited.add(sceneKey)) { + add(scene(sceneKey)) } } + + // Compose the new scene we are going to first. + transitions.fastForEachReversed { transition -> + maybeAdd(transition.toScene) + maybeAdd(transition.fromScene) + } } } } 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 458a2b99f9ce..b7fc91c4e762 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 @@ -16,6 +16,7 @@ package com.android.compose.animation.scene +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween @@ -40,12 +41,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.intermediateLayout import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertPositionInRootIsEqualTo import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -54,6 +57,9 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp 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.google.common.truth.Truth.assertThat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -187,8 +193,8 @@ class ElementTest { lateinit var changeScene: (SceneKey) -> Unit rule.testTransition( - from = TestScenes.SceneA, - to = TestScenes.SceneB, + from = SceneA, + to = SceneB, transitionLayout = { currentScene, onChangeScene -> changeScene = onChangeScene @@ -196,11 +202,15 @@ class ElementTest { currentScene, onChangeScene, transitions { - from(TestScenes.SceneA, to = TestScenes.SceneB) { spec = tween } - from(TestScenes.SceneB, to = TestScenes.SceneC) { spec = tween } + from(SceneA, to = SceneB) { spec = tween } + from(SceneB, to = SceneC) { spec = tween } }, + + // Disable interruptions so that the current transition is directly removed when + // starting a new one. + enableInterruptions = false, ) { - scene(TestScenes.SceneA) { + scene(SceneA) { Box(Modifier.size(layoutSize)) { // Transformed element Element( @@ -210,8 +220,8 @@ class ElementTest { ) } } - scene(TestScenes.SceneB) { Box(Modifier.size(layoutSize)) } - scene(TestScenes.SceneC) { Box(Modifier.size(layoutSize)) } + scene(SceneB) { Box(Modifier.size(layoutSize)) } + scene(SceneC) { Box(Modifier.size(layoutSize)) } } }, ) { @@ -220,7 +230,7 @@ class ElementTest { onElement(TestElements.Bar).assertExists() // Start transition from SceneB to SceneC - changeScene(TestScenes.SceneC) + changeScene(SceneC) } at(2 * frameDuration) { onElement(TestElements.Bar).assertIsNotDisplayed() } @@ -317,7 +327,7 @@ class ElementTest { @Test fun elementIsReusedBetweenScenes() { - var currentScene by mutableStateOf(TestScenes.SceneA) + var currentScene by mutableStateOf(SceneA) var sceneCState by mutableStateOf(0) val key = TestElements.Foo var nullableLayoutImpl: SceneTransitionLayoutImpl? = null @@ -331,9 +341,9 @@ class ElementTest { ), onLayoutImpl = { nullableLayoutImpl = it }, ) { - scene(TestScenes.SceneA) { /* Nothing */} - scene(TestScenes.SceneB) { Box(Modifier.element(key)) } - scene(TestScenes.SceneC) { + scene(SceneA) { /* Nothing */} + scene(SceneB) { Box(Modifier.element(key)) } + scene(SceneC) { when (sceneCState) { 0 -> Row(Modifier.element(key)) {} else -> { @@ -352,21 +362,21 @@ class ElementTest { assertThat(layoutImpl.elements).isEmpty() // Scene B: element is in the map. - currentScene = TestScenes.SceneB + currentScene = SceneB rule.waitForIdle() assertThat(layoutImpl.elements.keys).containsExactly(key) val element = layoutImpl.elements.getValue(key) - assertThat(element.sceneStates.keys).containsExactly(TestScenes.SceneB) + assertThat(element.sceneStates.keys).containsExactly(SceneB) // Scene C, state 0: the same element is reused. - currentScene = TestScenes.SceneC + currentScene = SceneC sceneCState = 0 rule.waitForIdle() assertThat(layoutImpl.elements.keys).containsExactly(key) assertThat(layoutImpl.elements.getValue(key)).isSameInstanceAs(element) - assertThat(element.sceneStates.keys).containsExactly(TestScenes.SceneC) + assertThat(element.sceneStates.keys).containsExactly(SceneC) // Scene C, state 1: the element is removed from the map. sceneCState = 1 @@ -454,14 +464,10 @@ class ElementTest { rule.setContent { SceneTransitionLayoutForTesting( - state = - updateSceneTransitionLayoutState( - currentScene = TestScenes.SceneA, - onChangeScene = {} - ), + state = updateSceneTransitionLayoutState(currentScene = SceneA, onChangeScene = {}), onLayoutImpl = { nullableLayoutImpl = it }, ) { - scene(TestScenes.SceneA) { Box(Modifier.element(key)) } + scene(SceneA) { Box(Modifier.element(key)) } } } @@ -471,7 +477,7 @@ class ElementTest { // There is only Foo in the elements map. assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Foo) val fooElement = layoutImpl.elements.getValue(TestElements.Foo) - assertThat(fooElement.sceneStates.keys).containsExactly(TestScenes.SceneA) + assertThat(fooElement.sceneStates.keys).containsExactly(SceneA) key = TestElements.Bar @@ -479,7 +485,7 @@ class ElementTest { rule.waitForIdle() assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Bar) val barElement = layoutImpl.elements.getValue(TestElements.Bar) - assertThat(barElement.sceneStates.keys).containsExactly(TestScenes.SceneA) + assertThat(barElement.sceneStates.keys).containsExactly(SceneA) assertThat(fooElement.sceneStates).isEmpty() } @@ -507,14 +513,10 @@ class ElementTest { scrollScope = rememberCoroutineScope() SceneTransitionLayoutForTesting( - state = - updateSceneTransitionLayoutState( - currentScene = TestScenes.SceneA, - onChangeScene = {} - ), + state = updateSceneTransitionLayoutState(currentScene = SceneA, onChangeScene = {}), onLayoutImpl = { nullableLayoutImpl = it }, ) { - scene(TestScenes.SceneA) { + scene(SceneA) { // The pages are full-size and beyondBoundsPageCount is 0, so at rest only one // page should be composed. HorizontalPager( @@ -538,11 +540,11 @@ class ElementTest { assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Foo) val element = layoutImpl.elements.getValue(TestElements.Foo) val sceneValues = element.sceneStates - assertThat(sceneValues.keys).containsExactly(TestScenes.SceneA) + assertThat(sceneValues.keys).containsExactly(SceneA) // Get the ElementModifier node that should be reused later on when coming back to this // page. - val nodes = sceneValues.getValue(TestScenes.SceneA).nodes + val nodes = sceneValues.getValue(SceneA).nodes assertThat(nodes).hasSize(1) val node = nodes.single() @@ -563,10 +565,10 @@ class ElementTest { val newSceneValues = newElement.sceneStates assertThat(newElement).isNotEqualTo(element) assertThat(newSceneValues).isNotEqualTo(sceneValues) - assertThat(newSceneValues.keys).containsExactly(TestScenes.SceneA) + assertThat(newSceneValues.keys).containsExactly(SceneA) // The ElementModifier node should be the same as before. - val newNodes = newSceneValues.getValue(TestScenes.SceneA).nodes + val newNodes = newSceneValues.getValue(SceneA).nodes assertThat(newNodes).hasSize(1) val newNode = newNodes.single() assertThat(newNode).isSameInstanceAs(node) @@ -612,7 +614,7 @@ class ElementTest { val state = MutableSceneTransitionLayoutState( - initialScene = TestScenes.SceneA, + initialScene = SceneA, transitions = transitions(sceneTransitions), ) as MutableSceneTransitionLayoutStateImpl @@ -623,10 +625,7 @@ class ElementTest { state = state, modifier = Modifier.size(layoutWidth, layoutHeight) ) { - scene( - key = TestScenes.SceneA, - userActions = mapOf(Swipe.Down to TestScenes.SceneB) - ) { + scene(key = SceneA, userActions = mapOf(Swipe.Down to SceneB)) { animateSceneFloatAsState( value = animatedFloatRange.start, key = TestValues.Value1, @@ -634,7 +633,7 @@ class ElementTest { ) Spacer(Modifier.fillMaxSize()) } - scene(TestScenes.SceneB) { + scene(SceneB) { val animatedFloat by animateSceneFloatAsState( value = animatedFloatRange.endInclusive, @@ -674,7 +673,7 @@ class ElementTest { layoutWidth = layoutWidth, layoutHeight = layoutHeight, sceneTransitions = { - overscroll(TestScenes.SceneB, Orientation.Vertical) { + overscroll(SceneB, Orientation.Vertical) { // On overscroll 100% -> Foo should translate by overscrollTranslateY translate(TestElements.Foo, y = overscrollTranslateY) } @@ -726,10 +725,10 @@ class ElementTest { val state = MutableSceneTransitionLayoutState( - initialScene = TestScenes.SceneB, + initialScene = SceneB, transitions = transitions { - overscroll(TestScenes.SceneB, Orientation.Vertical) { + overscroll(SceneB, Orientation.Vertical) { translate(TestElements.Foo, y = overscrollTranslateY) } } @@ -742,8 +741,8 @@ class ElementTest { state = state, modifier = Modifier.size(layoutWidth, layoutHeight) ) { - scene(TestScenes.SceneA) { Spacer(Modifier.fillMaxSize()) } - scene(TestScenes.SceneB, userActions = mapOf(Swipe.Up to TestScenes.SceneA)) { + scene(SceneA) { Spacer(Modifier.fillMaxSize()) } + scene(SceneB, userActions = mapOf(Swipe.Up to SceneA)) { Box( Modifier // Unconsumed scroll gesture will be intercepted by STL @@ -801,7 +800,7 @@ class ElementTest { layoutWidth = layoutWidth, layoutHeight = layoutHeight, sceneTransitions = { - overscroll(TestScenes.SceneB, Orientation.Vertical) { + overscroll(SceneB, Orientation.Vertical) { // On overscroll 100% -> Foo should translate by layoutHeight translate(TestElements.Foo, y = { absoluteDistance }) } @@ -858,7 +857,7 @@ class ElementTest { stiffness = Spring.StiffnessLow, ) - overscroll(TestScenes.SceneB, Orientation.Vertical) { + overscroll(SceneB, Orientation.Vertical) { // On overscroll 100% -> Foo should translate by layoutHeight translate(TestElements.Foo, y = { absoluteDistance }) } @@ -899,4 +898,125 @@ class ElementTest { assertThat(transition.bouncingScene).isEqualTo(transition.toScene) assertThat(animatedFloat).isEqualTo(100f) } + + @Test + fun elementIsUsingLastTransition() { + // 4 frames of animation. + val duration = 4 * 16 + + val state = + MutableSceneTransitionLayoutState( + SceneA, + transitions { + // Foo is at the top left corner of scene A. We make it disappear during A => B + // to the right edge so it translates to the right. + from(SceneA, to = SceneB) { + spec = tween(duration, easing = LinearEasing) + translate( + TestElements.Foo, + edge = Edge.Right, + startsOutsideLayoutBounds = false, + ) + } + + // Bar is at the top right corner of scene C. We make it appear during B => C + // from the left edge so it translates to the right at same time as Foo. + from(SceneB, to = SceneC) { + spec = tween(duration, easing = LinearEasing) + translate( + TestElements.Bar, + edge = Edge.Left, + startsOutsideLayoutBounds = false, + ) + } + } + ) + + val layoutSize = 150.dp + val elemSize = 50.dp + lateinit var coroutineScope: CoroutineScope + rule.setContent { + coroutineScope = rememberCoroutineScope() + + SceneTransitionLayout(state) { + scene(SceneA) { + Box(Modifier.size(layoutSize)) { + Box( + Modifier.align(Alignment.TopStart) + .element(TestElements.Foo) + .size(elemSize) + ) + } + } + scene(SceneB) { + // Empty scene. + Box(Modifier.size(layoutSize)) + } + scene(SceneC) { + Box(Modifier.size(layoutSize)) { + Box( + Modifier.align(Alignment.BottomEnd) + .element(TestElements.Bar) + .size(elemSize) + ) + } + } + } + } + + rule.mainClock.autoAdvance = false + + // Trigger A => B then directly B => C so that Foo and Bar move together to the right edge. + rule.runOnUiThread { + state.setTargetScene(SceneB, coroutineScope) + state.setTargetScene(SceneC, coroutineScope) + } + + val transitions = state.currentTransitions + assertThat(transitions).hasSize(2) + assertThat(transitions[0].fromScene).isEqualTo(SceneA) + assertThat(transitions[0].toScene).isEqualTo(SceneB) + assertThat(transitions[0].progress).isEqualTo(0f) + + assertThat(transitions[1].fromScene).isEqualTo(SceneB) + assertThat(transitions[1].toScene).isEqualTo(SceneC) + assertThat(transitions[1].progress).isEqualTo(0f) + + // First frame: both are at x = 0dp. For the whole transition, Foo is at y = 0dp and Bar is + // at y = layoutSize - elementSoze = 100dp. + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + rule.onNode(isElement(TestElements.Foo)).assertPositionInRootIsEqualTo(0.dp, 0.dp) + rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(0.dp, 100.dp) + + // Advance to the second frame (25% of the transition): they are both translating + // horizontally to the final target (x = layoutSize - elemSize = 100dp), so they should now + // be at x = 25dp. + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + rule.onNode(isElement(TestElements.Foo)).assertPositionInRootIsEqualTo(25.dp, 0.dp) + rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(25.dp, 100.dp) + + // Advance to the second frame (50% of the transition): they should now be at x = 50dp. + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + rule.onNode(isElement(TestElements.Foo)).assertPositionInRootIsEqualTo(50.dp, 0.dp) + rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(50.dp, 100.dp) + + // Advance to the third frame (75% of the transition): they should now be at x = 75dp. + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + rule.onNode(isElement(TestElements.Foo)).assertPositionInRootIsEqualTo(75.dp, 0.dp) + rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(75.dp, 100.dp) + + // Advance to the end of the animation. We can't really test the fourth frame because when + // pausing the clock, the layout/drawing code will still run (so elements will have their + // size/offset when there is no more transition running) but composition will not (so + // elements that should not be composed anymore will still be composed). + rule.mainClock.autoAdvance = true + rule.waitForIdle() + assertThat(state.currentTransitions).isEmpty() + rule.onNode(isElement(TestElements.Foo)).assertDoesNotExist() + rule.onNode(isElement(TestElements.Bar)).assertPositionInRootIsEqualTo(100.dp, 100.dp) + } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt index 2eaccb477524..7836581c86e8 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt @@ -337,6 +337,94 @@ class SceneTransitionLayoutTest { } } + @Test + fun multipleTransitionsWillComposeMultipleScenes() { + val duration = 10 * 16L + + var currentScene: SceneKey by mutableStateOf(SceneA) + lateinit var state: SceneTransitionLayoutState + rule.setContent { + state = + updateSceneTransitionLayoutState( + currentScene = currentScene, + onChangeScene = { currentScene = it }, + transitions = + transitions { + from(SceneA, to = SceneB) { + spec = tween(duration.toInt(), easing = LinearEasing) + } + from(SceneB, to = SceneC) { + spec = tween(duration.toInt(), easing = LinearEasing) + } + } + ) + + SceneTransitionLayout(state) { + scene(SceneA) { Box(Modifier.testTag("aRoot").fillMaxSize()) } + scene(SceneB) { Box(Modifier.testTag("bRoot").fillMaxSize()) } + scene(SceneC) { Box(Modifier.testTag("cRoot").fillMaxSize()) } + } + } + + // Initial state: only A is composed. + rule.onNodeWithTag("aRoot").assertExists() + rule.onNodeWithTag("bRoot").assertDoesNotExist() + rule.onNodeWithTag("cRoot").assertDoesNotExist() + + // Pause the clock so we can manually advance it. + rule.waitForIdle() + rule.mainClock.autoAdvance = false + + // Start A => B and go to the middle of the transition. + currentScene = SceneB + + // We need to tick 2 frames after changing [currentScene] before the animation actually + // starts. + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeBy(duration / 2) + rule.waitForIdle() + assertThat(state.currentTransition?.progress).isEqualTo(0.5f) + + // A and B are composed. + rule.onNodeWithTag("aRoot").assertExists() + rule.onNodeWithTag("bRoot").assertExists() + rule.onNodeWithTag("cRoot").assertDoesNotExist() + + // Start B => C. + currentScene = SceneC + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + assertThat(state.currentTransition?.progress).isEqualTo(0f) + + // A, B and C are composed. + rule.onNodeWithTag("aRoot").assertExists() + rule.onNodeWithTag("bRoot").assertExists() + rule.onNodeWithTag("cRoot").assertExists() + + // Let A => B finish. + rule.mainClock.advanceTimeBy(duration / 2L) + assertThat(state.currentTransition?.progress).isEqualTo(0.5f) + rule.waitForIdle() + + // B and C are composed. + rule.onNodeWithTag("aRoot").assertDoesNotExist() + rule.onNodeWithTag("bRoot").assertExists() + rule.onNodeWithTag("cRoot").assertExists() + + // Let B => C finish. + rule.mainClock.advanceTimeBy(duration / 2L) + rule.mainClock.advanceTimeByFrame() + assertThat(state.currentTransition).isNull() + rule.waitForIdle() + + // Only C is composed. + rule.onNodeWithTag("aRoot").assertDoesNotExist() + rule.onNodeWithTag("bRoot").assertDoesNotExist() + rule.onNodeWithTag("cRoot").assertExists() + } + private fun SemanticsNodeInteraction.offsetRelativeTo( other: SemanticsNodeInteraction, ): DpOffset { |