summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt9
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt130
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt26
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt63
-rw-r--r--packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt214
-rw-r--r--packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt88
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 {