diff options
| author | 2023-11-13 09:49:48 +0000 | |
|---|---|---|
| committer | 2023-11-13 09:49:48 +0000 | |
| commit | a3ba8f1f263efe69e032e472affbec25fb84174a (patch) | |
| tree | bbbed46b346d794ff6f0e2a17db2c5ec5a41d1fc | |
| parent | e6584d79e880629ea243208aeac4256f3a31d0c5 (diff) | |
| parent | f48981108a21830cf93e282d137debbf30b17277 (diff) | |
Merge "Animate SceneTransitionLayout size" into main
11 files changed, 117 insertions, 37 deletions
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 aae61bd0f554..b77a60b5b5d6 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 @@ -506,7 +506,10 @@ private inline fun <T> computeValue( // There is no ongoing transition. if (state !is TransitionState.Transition || state.fromScene == state.toScene) { - return idleValue + // 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() } // A transition was started but it's not ready yet (not all elements have been composed/laid diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt index 216608aff0cb..5d8eaf7f3d15 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt @@ -2,9 +2,10 @@ package com.android.compose.animation.scene import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.unit.IntSize interface DraggableHandler { - fun onDragStarted(startedPosition: Offset, pointersDown: Int = 1) + fun onDragStarted(layoutSize: IntSize, startedPosition: Offset, pointersDown: Int = 1) fun onDelta(pixels: Float) fun onDragStopped(velocity: Float) } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt index d0a5f5bfebc0..d48781a4529b 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity import androidx.compose.ui.util.fastForEach @@ -60,7 +61,7 @@ internal fun Modifier.multiPointerDraggable( orientation: Orientation, enabled: Boolean, startDragImmediately: Boolean, - onDragStarted: (startedPosition: Offset, pointersDown: Int) -> Unit, + onDragStarted: (layoutSize: IntSize, startedPosition: Offset, pointersDown: Int) -> Unit, onDragDelta: (Float) -> Unit, onDragStopped: (velocity: Float) -> Unit, ): Modifier = composed { @@ -83,7 +84,7 @@ internal fun Modifier.multiPointerDraggable( val onDragStart: (Offset, Int) -> Unit = { startedPosition, pointersDown -> velocityTracker.resetTracking() - onDragStarted(startedPosition, pointersDown) + onDragStarted(size, startedPosition, pointersDown) } val onDragCancel: () -> Unit = { onDragStopped(/* velocity= */ 0f) } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt index eb5168bdd3cb..1a79522da05d 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt @@ -25,8 +25,9 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.intermediateLayout import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.IntSize import androidx.compose.ui.zIndex @@ -44,14 +45,24 @@ internal class Scene( var content by mutableStateOf(content) var userActions by mutableStateOf(actions) var zIndex by mutableFloatStateOf(zIndex) - var size by mutableStateOf(IntSize.Zero) + var targetSize by mutableStateOf(IntSize.Zero) /** The shared values in this scene that are not tied to a specific element. */ val sharedValues = SnapshotStateMap<ValueKey, Element.SharedValue<*>>() @Composable + @OptIn(ExperimentalComposeUiApi::class) fun Content(modifier: Modifier = Modifier) { - Box(modifier.zIndex(zIndex).onPlaced { size = it.size }.testTag(key.testTag)) { + Box( + modifier + .zIndex(zIndex) + .intermediateLayout { measurable, constraints -> + targetSize = lookaheadSize + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + .testTag(key.testTag) + ) { scope.content() } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt index 9a3a0aef30cb..9d71801be25b 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round @@ -90,7 +91,7 @@ class SceneGestureHandler( internal var gestureWithPriority: Any? = null - internal fun onDragStarted(pointersDown: Int, startedPosition: Offset?) { + internal fun onDragStarted(pointersDown: Int, layoutSize: IntSize, startedPosition: Offset?) { if (isDrivingTransition) { // This [transition] was already driving the animation: simply take over it. // Stop animating and start from where the current offset. @@ -126,14 +127,14 @@ class SceneGestureHandler( // we will also have to make sure that we correctly handle overscroll. swipeTransition.absoluteDistance = when (orientation) { - Orientation.Horizontal -> layoutImpl.size.width - Orientation.Vertical -> layoutImpl.size.height + Orientation.Horizontal -> layoutSize.width + Orientation.Vertical -> layoutSize.height }.toFloat() val fromEdge = startedPosition?.let { position -> layoutImpl.edgeDetector.edge( - layoutImpl.size, + layoutSize, position.round(), layoutImpl.density, orientation, @@ -513,9 +514,9 @@ class SceneGestureHandler( private class SceneDraggableHandler( private val gestureHandler: SceneGestureHandler, ) : DraggableHandler { - override fun onDragStarted(startedPosition: Offset, pointersDown: Int) { + override fun onDragStarted(layoutSize: IntSize, startedPosition: Offset, pointersDown: Int) { gestureHandler.gestureWithPriority = this - gestureHandler.onDragStarted(pointersDown, startedPosition) + gestureHandler.onDragStarted(pointersDown, layoutSize, startedPosition) } override fun onDelta(pixels: Float) { @@ -647,7 +648,11 @@ class SceneNestedScrollHandler( canContinueScroll = { true }, onStart = { gestureHandler.gestureWithPriority = this - gestureHandler.onDragStarted(pointersDown = 1, startedPosition = null) + gestureHandler.onDragStarted( + pointersDown = 1, + layoutSize = gestureHandler.currentScene.targetSize, + startedPosition = null, + ) }, onScroll = { offsetAvailable -> if (gestureHandler.gestureWithPriority != this) { 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 6edd1b6b923d..0b06953bc8e2 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 @@ -30,13 +30,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.layout.LookaheadScope -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.intermediateLayout import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.util.fastForEach +import com.android.compose.ui.util.lerp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel @@ -64,12 +66,6 @@ class SceneTransitionLayoutImpl( private val horizontalGestureHandler: SceneGestureHandler private val verticalGestureHandler: SceneGestureHandler - /** - * The size of this layout. Note that this could be [IntSize.Zero] if this layour does not have - * any scene configured or right before the first measure pass of the layout. - */ - @VisibleForTesting var size by mutableStateOf(IntSize.Zero) - init { setScenes(builder) @@ -157,15 +153,46 @@ class SceneTransitionLayoutImpl( } @Composable + @OptIn(ExperimentalComposeUiApi::class) internal fun Content(modifier: Modifier) { Box( modifier // Handle horizontal and vertical swipes on this layout. // Note: order here is important and will give a slight priority to the vertical // swipes. - .swipeToScene(gestureHandler(Orientation.Horizontal)) - .swipeToScene(gestureHandler(Orientation.Vertical)) - .onSizeChanged { size = it } + .swipeToScene(horizontalGestureHandler) + .swipeToScene(verticalGestureHandler) + // Animate the size of this layout. + .intermediateLayout { measurable, constraints -> + // Measure content normally. + val placeable = measurable.measure(constraints) + + val width: Int + val height: Int + val state = state.transitionState + if (state !is TransitionState.Transition || state.fromScene == state.toScene) { + width = placeable.width + height = placeable.height + } else { + // Interpolate the size. + val fromSize = scene(state.fromScene).targetSize + val toSize = scene(state.toScene).targetSize + + // Optimization: make sure we don't read state.progress if fromSize == + // toSize to avoid running this code every frame when the layout size does + // not change. + if (fromSize == toSize) { + width = fromSize.width + height = fromSize.height + } else { + val size = lerp(fromSize, toSize, state.progress) + width = size.width.coerceAtLeast(0) + height = size.height.coerceAtLeast(0) + } + } + + layout(width, height) { placeable.place(0, 0) } + } ) { LookaheadScope { val scenesToCompose = @@ -230,4 +257,9 @@ class SceneTransitionLayoutImpl( } internal fun isSceneReady(scene: SceneKey): Boolean = readyScenes.containsKey(scene) + + @VisibleForTesting + fun setScenesTargetSizeForTest(size: IntSize) { + scenes.values.forEach { it.targetSize = size } + } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt index 840800d838db..70534dde4f6f 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt @@ -38,7 +38,7 @@ internal class EdgeTranslate( transition: TransitionState.Transition, value: Offset ): Offset { - val sceneSize = scene.size + val sceneSize = scene.targetSize val elementSize = sceneValues.targetSize if (elementSize == Element.SizeUnspecified) { return value diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt index 1e3d01108103..7ab2096b3d88 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt @@ -46,6 +46,7 @@ import org.junit.Test import org.junit.runner.RunWith private const val SCREEN_SIZE = 100f +private val LAYOUT_SIZE = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt()) @RunWith(AndroidJUnit4::class) class SceneGestureHandlerTest { @@ -80,7 +81,7 @@ class SceneGestureHandlerTest { edgeDetector = DefaultEdgeDetector, coroutineScope = coroutineScope, ) - .also { it.size = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt()) }, + .apply { setScenesTargetSizeForTest(LAYOUT_SIZE) }, orientation = Orientation.Vertical, coroutineScope = coroutineScope, ) @@ -128,18 +129,21 @@ class SceneGestureHandlerTest { runMonotonicClockTest { TestGestureScope(coroutineScope = this).block() } } + private fun DraggableHandler.onDragStarted() = + onDragStarted(layoutSize = LAYOUT_SIZE, startedPosition = Offset.Zero) + @Test fun testPreconditions() = runGestureTest { assertScene(currentScene = SceneA, isIdle = true) } @Test fun onDragStarted_shouldStartATransition() = runGestureTest { - draggable.onDragStarted(startedPosition = Offset.Zero) + draggable.onDragStarted() assertScene(currentScene = SceneA, isIdle = false) } @Test fun afterSceneTransitionIsStarted_interceptDragEvents() = runGestureTest { - draggable.onDragStarted(startedPosition = Offset.Zero) + draggable.onDragStarted() assertScene(currentScene = SceneA, isIdle = false) val transition = transitionState as Transition @@ -152,7 +156,7 @@ class SceneGestureHandlerTest { @Test fun onDragStoppedAfterDrag_velocityLowerThanThreshold_remainSameScene() = runGestureTest { - draggable.onDragStarted(startedPosition = Offset.Zero) + draggable.onDragStarted() assertScene(currentScene = SceneA, isIdle = false) draggable.onDelta(pixels = deltaInPixels10) @@ -170,7 +174,7 @@ class SceneGestureHandlerTest { @Test fun onDragStoppedAfterDrag_velocityAtLeastThreshold_goToNextScene() = runGestureTest { - draggable.onDragStarted(startedPosition = Offset.Zero) + draggable.onDragStarted() assertScene(currentScene = SceneA, isIdle = false) draggable.onDelta(pixels = deltaInPixels10) @@ -188,7 +192,7 @@ class SceneGestureHandlerTest { @Test fun onDragStoppedAfterStarted_returnImmediatelyToIdle() = runGestureTest { - draggable.onDragStarted(startedPosition = Offset.Zero) + draggable.onDragStarted() assertScene(currentScene = SceneA, isIdle = false) draggable.onDragStopped(velocity = 0f) @@ -197,7 +201,7 @@ class SceneGestureHandlerTest { @Test fun startGestureDuringAnimatingOffset_shouldImmediatelyStopTheAnimation() = runGestureTest { - draggable.onDragStarted(startedPosition = Offset.Zero) + draggable.onDragStarted() assertScene(currentScene = SceneA, isIdle = false) draggable.onDelta(pixels = deltaInPixels10) @@ -217,7 +221,7 @@ class SceneGestureHandlerTest { assertScene(currentScene = SceneC, isIdle = false) // Start a new gesture while the offset is animating - draggable.onDragStarted(startedPosition = Offset.Zero) + draggable.onDragStarted() assertThat(sceneGestureHandler.isAnimatingOffset).isFalse() } @@ -421,6 +425,7 @@ class SceneGestureHandlerTest { draggable.onDelta(deltaInPixels10) assertScene(currentScene = SceneA, isIdle = true) } + @Test fun beforeDraggableStart_stop_shouldBeIgnored() = runGestureTest { draggable.onDragStopped(velocityThreshold) @@ -437,7 +442,7 @@ class SceneGestureHandlerTest { @Test fun startNestedScrollWhileDragging() = runGestureTest { val nestedScroll = nestedScrollConnection(nestedScrollBehavior = Always) - draggable.onDragStarted(Offset.Zero) + draggable.onDragStarted() assertScene(currentScene = SceneA, isIdle = false) val transition = transitionState as Transition 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 5afd420a5e16..321cf637824a 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 @@ -18,6 +18,8 @@ package com.android.compose.animation.scene import androidx.activity.ComponentActivity import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -48,6 +50,7 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.compose.test.assertSizeIsEqualTo import com.android.compose.test.subjects.DpOffsetSubject import com.android.compose.test.subjects.assertThat import com.google.common.truth.Truth.assertThat @@ -307,6 +310,26 @@ class SceneTransitionLayoutTest { assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA) } + @Test + fun layoutSizeIsAnimated() { + val layoutTag = "layout" + rule.testTransition( + fromSceneContent = { Box(Modifier.size(200.dp, 100.dp)) }, + toSceneContent = { Box(Modifier.size(120.dp, 140.dp)) }, + transition = { + // 4 frames of animation. + spec = tween(4 * 16, easing = LinearEasing) + }, + layoutModifier = Modifier.testTag(layoutTag), + ) { + before { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(200.dp, 100.dp) } + at(16) { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(180.dp, 110.dp) } + at(32) { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(160.dp, 120.dp) } + at(48) { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(140.dp, 130.dp) } + after { rule.onNodeWithTag(layoutTag).assertSizeIsEqualTo(120.dp, 140.dp) } + } + } + private fun SemanticsNodeInteraction.offsetRelativeTo( other: SemanticsNodeInteraction, ): DpOffset { diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt index 2a27763f1d5c..8cffcf6980cc 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt @@ -48,7 +48,7 @@ class EdgeTranslateTest { rule.testTransition( // The layout under test is 300dp x 300dp. layoutModifier = Modifier.size(300.dp), - fromSceneContent = {}, + fromSceneContent = { Box(Modifier.fillMaxSize()) }, toSceneContent = { // Foo is 100dp x 100dp in the center of the layout, so at offset = (100dp, 100dp) Box(Modifier.fillMaxSize()) { diff --git a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt index e0ae1be69aaf..06de2965f716 100644 --- a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt +++ b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt @@ -16,7 +16,6 @@ package com.android.compose.animation.scene -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -104,7 +103,7 @@ fun ComposeContentTestRule.testTransition( currentScene, onChangeScene, transitions { from(fromScene, to = toScene, transition) }, - layoutModifier.fillMaxSize(), + layoutModifier, ) { scene(fromScene, content = fromSceneContent) scene(toScene, content = toSceneContent) |