Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1f66aeff authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Prevent size jumps during interruptions

This CL adds the same kind of interruption support for the size of
elements that was added in ag/26597678. The main difference is that this
support unfortunately does not always work given that the element might
have already been measured when processing the interruption delta,
preventing us from re-measuring it again with the delta taken into
account.

See b/290930950#comment22 for more details.

Bug: 290930950
Test: ElementTest
Flag: com.android.systemui.scene_container
Change-Id: I7498f56ac9598abf931a121d43353d66935fd8fb
parent cfa36482
Loading
Loading
Loading
Loading
+44 −8
Original line number Diff line number Diff line
@@ -49,6 +49,7 @@ import androidx.compose.ui.util.lerp
import com.android.compose.animation.scene.transformation.PropertyTransformation
import com.android.compose.animation.scene.transformation.SharedElementTransformation
import com.android.compose.ui.util.lerp
import kotlin.math.roundToInt
import kotlinx.coroutines.launch

/** An element on screen, that can be composed in one or more scenes. */
@@ -81,11 +82,13 @@ internal class Element(val key: ElementKey) {

        /** The last state this element had in this scene. */
        var lastOffset = Offset.Unspecified
        var lastSize = SizeUnspecified
        var lastScale = Scale.Unspecified
        var lastAlpha = AlphaUnspecified

        /** The state of this element in this scene right before the last interruption (if any). */
        var offsetBeforeInterruption = Offset.Unspecified
        var sizeBeforeInterruption = SizeUnspecified
        var scaleBeforeInterruption = Scale.Unspecified
        var alphaBeforeInterruption = AlphaUnspecified

@@ -96,6 +99,7 @@ internal class Element(val key: ElementKey) {
         * they nicely animate from their values down to 0.
         */
        var offsetInterruptionDelta = Offset.Zero
        var sizeInterruptionDelta = IntSize.Zero
        var scaleInterruptionDelta = Scale.Zero
        var alphaInterruptionDelta = 0f

@@ -263,11 +267,13 @@ internal class ElementNode(
            sceneState.lastAlpha = Element.AlphaUnspecified

            val placeable = measurable.measure(constraints)
            sceneState.lastSize = placeable.size()
            return layout(placeable.width, placeable.height) {}
        }

        val placeable =
            measure(layoutImpl, scene, element, transition, sceneState, measurable, constraints)
        sceneState.lastSize = placeable.size()
        return layout(placeable.width, placeable.height) {
            place(
                layoutImpl,
@@ -377,12 +383,14 @@ private fun prepareInterruption(element: Element) {
    }

    val lastOffset = lastUniqueState?.lastOffset ?: Offset.Unspecified
    val lastSize = lastUniqueState?.lastSize ?: Element.SizeUnspecified
    val lastScale = lastUniqueState?.lastScale ?: Scale.Unspecified
    val lastAlpha = lastUniqueState?.lastAlpha ?: Element.AlphaUnspecified

    // Store the state of the element before the interruption and reset the deltas.
    sceneStates.forEach { sceneState ->
        sceneState.offsetBeforeInterruption = lastOffset
        sceneState.sizeBeforeInterruption = lastSize
        sceneState.scaleBeforeInterruption = lastScale
        sceneState.alphaBeforeInterruption = lastAlpha

@@ -392,6 +400,7 @@ private fun prepareInterruption(element: Element) {

private fun Element.SceneState.clearInterruptionDeltas() {
    offsetInterruptionDelta = Offset.Zero
    sizeInterruptionDelta = IntSize.Zero
    scaleInterruptionDelta = Scale.Zero
    alphaInterruptionDelta = 0f
}
@@ -648,8 +657,6 @@ private fun ApproachMeasureScope.measure(
    // once.
    var maybePlaceable: Placeable? = null

    fun Placeable.size() = IntSize(width, height)

    val targetSize =
        computeValue(
            layoutImpl,
@@ -664,15 +671,44 @@ private fun ApproachMeasureScope.measure(
            ::lerp,
        )

    return maybePlaceable
        ?: measurable.measure(
    // The measurable was already measured, so we can't take interruptions into account here given
    // that we are not allowed to measure the same measurable twice.
    maybePlaceable?.let { placeable ->
        sceneState.sizeBeforeInterruption = Element.SizeUnspecified
        sceneState.sizeInterruptionDelta = IntSize.Zero
        return placeable
    }

    val interruptedSize =
        computeInterruptedValue(
            layoutImpl,
            transition,
            value = targetSize,
            unspecifiedValue = Element.SizeUnspecified,
            zeroValue = IntSize.Zero,
            getValueBeforeInterruption = { sceneState.sizeBeforeInterruption },
            setValueBeforeInterruption = { sceneState.sizeBeforeInterruption = it },
            getInterruptionDelta = { sceneState.sizeInterruptionDelta },
            setInterruptionDelta = { sceneState.sizeInterruptionDelta = it },
            diff = { a, b -> IntSize(a.width - b.width, a.height - b.height) },
            add = { a, b, bProgress ->
                IntSize(
                    (a.width + b.width * bProgress).roundToInt(),
                    (a.height + b.height * bProgress).roundToInt(),
                )
            },
        )

    return measurable.measure(
        Constraints.fixed(
                targetSize.width.coerceAtLeast(0),
                targetSize.height.coerceAtLeast(0),
            interruptedSize.width.coerceAtLeast(0),
            interruptedSize.height.coerceAtLeast(0),
        )
    )
}

private fun Placeable.size(): IntSize = IntSize(width, height)

private fun ContentDrawScope.getDrawScale(
    layoutImpl: SceneTransitionLayoutImpl,
    scene: Scene,
+32 −15
Original line number Diff line number Diff line
@@ -56,6 +56,7 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -1082,13 +1083,17 @@ class ElementTest {
            }

        val layoutSize = DpSize(200.dp, 100.dp)
        val fooSize = DpSize(20.dp, 10.dp)

        @Composable
        fun SceneScope.Foo(modifier: Modifier = Modifier) {
            Box(modifier.element(TestElements.Foo).size(fooSize))
        fun SceneScope.Foo(size: Dp, modifier: Modifier = Modifier) {
            Box(modifier.element(TestElements.Foo).size(size))
        }

        // The size of Foo when idle in A, B or C.
        val sizeInA = 10.dp
        val sizeInB = 30.dp
        val sizeInC = 50.dp

        lateinit var layoutImpl: SceneTransitionLayoutImpl
        rule.setContent {
            SceneTransitionLayoutForTesting(
@@ -1098,33 +1103,35 @@ class ElementTest {
            ) {
                // In scene A, Foo is aligned at the TopStart.
                scene(SceneA) {
                    Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopStart)) }
                    Box(Modifier.fillMaxSize()) { Foo(sizeInA, Modifier.align(Alignment.TopStart)) }
                }

                // In scene C, Foo is aligned at the BottomEnd, so it moves vertically when coming
                // from B. We put it before (below) scene B so that we can check that interruptions
                // values and deltas are properly cleared once all transitions are done.
                scene(SceneC) {
                    Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.BottomEnd)) }
                    Box(Modifier.fillMaxSize()) {
                        Foo(sizeInC, Modifier.align(Alignment.BottomEnd))
                    }
                }

                // In scene B, Foo is aligned at the TopEnd, so it moves horizontally when coming
                // from A.
                scene(SceneB) {
                    Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopEnd)) }
                    Box(Modifier.fillMaxSize()) { Foo(sizeInB, Modifier.align(Alignment.TopEnd)) }
                }
            }
        }

        // The offset of Foo when idle in A, B or C.
        val offsetInA = DpOffset.Zero
        val offsetInB = DpOffset(layoutSize.width - fooSize.width, 0.dp)
        val offsetInC =
            DpOffset(layoutSize.width - fooSize.width, layoutSize.height - fooSize.height)
        val offsetInB = DpOffset(layoutSize.width - sizeInB, 0.dp)
        val offsetInC = DpOffset(layoutSize.width - sizeInC, layoutSize.height - sizeInC)

        // Initial state (idle in A).
        rule
            .onNode(isElement(TestElements.Foo, SceneA))
            .assertSizeIsEqualTo(sizeInA)
            .assertPositionInRootIsEqualTo(offsetInA.x, offsetInA.y)

        // Current transition is A => B at 50%.
@@ -1137,9 +1144,11 @@ class ElementTest {
                onFinish = neverFinish(),
            )
        val offsetInAToB = lerp(offsetInA, offsetInB, aToBProgress)
        val sizeInAToB = lerp(sizeInA, sizeInB, aToBProgress)
        rule.runOnUiThread { state.startTransition(aToB, transitionKey = null) }
        rule
            .onNode(isElement(TestElements.Foo, SceneB))
            .assertSizeIsEqualTo(sizeInAToB)
            .assertPositionInRootIsEqualTo(offsetInAToB.x, offsetInAToB.y)

        // Start B => C at 0%.
@@ -1154,26 +1163,30 @@ class ElementTest {
            )
        rule.runOnUiThread { state.startTransition(bToC, transitionKey = null) }

        // The offset interruption delta, which will be multiplied by the interruption progress then
        // added to the current transition offset.
        val interruptionDelta = offsetInAToB - offsetInB
        // The interruption deltas, which will be multiplied by the interruption progress then added
        // to the current transition offset and size.
        val offsetInterruptionDelta = offsetInAToB - offsetInB
        val sizeInterruptionDelta = sizeInAToB - sizeInB

        // Interruption progress is at 100% and bToC is at 0%, so Foo should be at the same offset
        // as right before the interruption.
        // and size as right before the interruption.
        rule
            .onNode(isElement(TestElements.Foo, SceneB))
            .assertPositionInRootIsEqualTo(offsetInAToB.x, offsetInAToB.y)
            .assertSizeIsEqualTo(sizeInAToB)

        // Move the transition forward at 30% and set the interruption progress to 50%.
        bToCProgress = 0.3f
        interruptionProgress = 0.5f
        val offsetInBToC = lerp(offsetInB, offsetInC, bToCProgress)
        val sizeInBToC = lerp(sizeInB, sizeInC, bToCProgress)
        val offsetInBToCWithInterruption =
            offsetInBToC +
                DpOffset(
                    interruptionDelta.x * interruptionProgress,
                    interruptionDelta.y * interruptionProgress,
                    offsetInterruptionDelta.x * interruptionProgress,
                    offsetInterruptionDelta.y * interruptionProgress,
                )
        val sizeInBToCWithInterruption = sizeInBToC + sizeInterruptionDelta * interruptionProgress
        rule.waitForIdle()
        rule
            .onNode(isElement(TestElements.Foo, SceneB))
@@ -1181,6 +1194,7 @@ class ElementTest {
                offsetInBToCWithInterruption.x,
                offsetInBToCWithInterruption.y,
            )
            .assertSizeIsEqualTo(sizeInBToCWithInterruption)

        // Finish the transition and interruption.
        bToCProgress = 1f
@@ -1188,6 +1202,7 @@ class ElementTest {
        rule
            .onNode(isElement(TestElements.Foo, SceneB))
            .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y)
            .assertSizeIsEqualTo(sizeInC)

        // Manually finish the transition.
        rule.runOnUiThread {
@@ -1202,9 +1217,11 @@ class ElementTest {
        assertThat(foo.sceneStates.keys).containsExactly(SceneC)
        val stateInC = foo.sceneStates.getValue(SceneC)
        assertThat(stateInC.offsetBeforeInterruption).isEqualTo(Offset.Unspecified)
        assertThat(stateInC.sizeBeforeInterruption).isEqualTo(Element.SizeUnspecified)
        assertThat(stateInC.scaleBeforeInterruption).isEqualTo(Scale.Unspecified)
        assertThat(stateInC.alphaBeforeInterruption).isEqualTo(Element.AlphaUnspecified)
        assertThat(stateInC.offsetInterruptionDelta).isEqualTo(Offset.Zero)
        assertThat(stateInC.sizeInterruptionDelta).isEqualTo(IntSize.Zero)
        assertThat(stateInC.scaleInterruptionDelta).isEqualTo(Scale.Zero)
        assertThat(stateInC.alphaInterruptionDelta).isEqualTo(0f)
    }
+5 −1
Original line number Diff line number Diff line
@@ -21,7 +21,11 @@ import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.unit.Dp

fun SemanticsNodeInteraction.assertSizeIsEqualTo(expectedWidth: Dp, expectedHeight: Dp) {
fun SemanticsNodeInteraction.assertSizeIsEqualTo(
    expectedWidth: Dp,
    expectedHeight: Dp = expectedWidth,
): SemanticsNodeInteraction {
    assertWidthIsEqualTo(expectedWidth)
    assertHeightIsEqualTo(expectedHeight)
    return this
}