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

Commit 3cf549bd authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge "Better handle interruptions when shared element is disabled" into main

parents 6075a067 fbf91e3b
Loading
Loading
Loading
Loading
+94 −39
Original line number Diff line number Diff line
@@ -342,10 +342,8 @@ private fun elementTransition(

    if (transition != previousTransition && transition != null && previousTransition != null) {
        // The previous transition was interrupted by another transition.
        prepareInterruption(element)
    }

    if (transition == null && previousTransition != null) {
        prepareInterruption(element, transition, previousTransition)
    } else if (transition == null && previousTransition != null) {
        // The transition was just finished.
        element.sceneStates.values.forEach {
            it.clearValuesBeforeInterruption()
@@ -356,46 +354,103 @@ private fun elementTransition(
    return transition
}

private fun prepareInterruption(element: Element) {
    // We look for the last unique state of this element so that we animate the delta with its
    // future state.
    val sceneStates = element.sceneStates.values
    var lastUniqueState: Element.SceneState? = null
    for (sceneState in sceneStates) {
        val offset = sceneState.lastOffset

        // If the element was placed in this scene...
        if (offset != Offset.Unspecified) {
            // ... and it is the first (and potentially the only) scene where the element was
            // placed, save the state for later.
            if (lastUniqueState == null) {
                lastUniqueState = sceneState
            } else {
                // The element was placed in multiple scenes: we abort the interruption for this
                // element.
                // TODO(b/290930950): Better support cases where a shared element animation is
                // disabled and the same element is drawn/placed in multiple scenes at the same
                // time.
                lastUniqueState = null
                break
private fun prepareInterruption(
    element: Element,
    transition: TransitionState.Transition,
    previousTransition: TransitionState.Transition,
) {
    val previousUniqueState = reconcileStates(element, previousTransition)
    if (previousUniqueState == null) {
        reconcileStates(element, transition)
        return
    }

    val fromSceneState = element.sceneStates[transition.fromScene]
    val toSceneState = element.sceneStates[transition.toScene]

    if (
        fromSceneState == null ||
            toSceneState == null ||
            sharedElementTransformation(element.key, transition)?.enabled != false
    ) {
        // If there is only one copy of the element or if the element is shared, animate deltas in
        // both scenes.
        fromSceneState?.updateValuesBeforeInterruption(previousUniqueState)
        toSceneState?.updateValuesBeforeInterruption(previousUniqueState)
    }
}

    val lastOffset = lastUniqueState?.lastOffset ?: Offset.Unspecified
    val lastSize = lastUniqueState?.lastSize ?: Element.SizeUnspecified
    val lastScale = lastUniqueState?.lastScale ?: Scale.Unspecified
    val lastAlpha = lastUniqueState?.lastAlpha ?: Element.AlphaUnspecified
/**
 * Reconcile the state of [element] in the fromScene and toScene of [transition] so that the values
 * before interruption have their expected values, taking shared transitions into account.
 *
 * If the element had a unique state, i.e. it is shared in [transition] or it is only present in one
 * of the scenes, return it.
 */
private fun reconcileStates(
    element: Element,
    transition: TransitionState.Transition,
): Element.SceneState? {
    val fromSceneState = element.sceneStates[transition.fromScene]
    val toSceneState = element.sceneStates[transition.toScene]
    when {
        // Element is in both scenes.
        fromSceneState != null && toSceneState != null -> {
            val isSharedTransformationDisabled =
                sharedElementTransformation(element.key, transition)?.enabled == false
            when {
                // Element shared transition is disabled so the element is placed in both scenes.
                isSharedTransformationDisabled -> {
                    fromSceneState.updateValuesBeforeInterruption(fromSceneState)
                    toSceneState.updateValuesBeforeInterruption(toSceneState)
                    return null
                }

    // 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
                // Element is shared and placed in fromScene only.
                fromSceneState.lastOffset != Offset.Unspecified -> {
                    fromSceneState.updateValuesBeforeInterruption(fromSceneState)
                    toSceneState.updateValuesBeforeInterruption(fromSceneState)
                    return fromSceneState
                }

        sceneState.clearInterruptionDeltas()
                // Element is shared and placed in toScene only.
                toSceneState.lastOffset != Offset.Unspecified -> {
                    fromSceneState.updateValuesBeforeInterruption(toSceneState)
                    toSceneState.updateValuesBeforeInterruption(toSceneState)
                    return toSceneState
                }

                // Element is in none of the scenes.
                else -> {
                    fromSceneState.updateValuesBeforeInterruption(null)
                    toSceneState.updateValuesBeforeInterruption(null)
                    return null
                }
            }
        }

        // Element is only in fromScene.
        fromSceneState != null -> {
            fromSceneState.updateValuesBeforeInterruption(fromSceneState)
            return fromSceneState
        }

        // Element is only in toScene.
        toSceneState != null -> {
            toSceneState.updateValuesBeforeInterruption(toSceneState)
            return toSceneState
        }
        else -> return null
    }
}

private fun Element.SceneState.updateValuesBeforeInterruption(lastState: Element.SceneState?) {
    offsetBeforeInterruption = lastState?.lastOffset ?: Offset.Unspecified
    sizeBeforeInterruption = lastState?.lastSize ?: Element.SizeUnspecified
    scaleBeforeInterruption = lastState?.lastScale ?: Scale.Unspecified
    alphaBeforeInterruption = lastState?.lastAlpha ?: Element.AlphaUnspecified

    clearInterruptionDeltas()
}

private fun Element.SceneState.clearInterruptionDeltas() {
+106 −0
Original line number Diff line number Diff line
@@ -1225,4 +1225,110 @@ class ElementTest {
        assertThat(stateInC.scaleInterruptionDelta).isEqualTo(Scale.Zero)
        assertThat(stateInC.alphaInterruptionDelta).isEqualTo(0f)
    }

    @Test
    fun interruption_sharedTransitionDisabled() = runTest {
        // 4 frames of animation.
        val duration = 4 * 16
        val layoutSize = DpSize(200.dp, 100.dp)
        val fooSize = 100.dp
        val state =
            rule.runOnUiThread {
                MutableSceneTransitionLayoutStateImpl(
                    SceneA,
                    transitions {
                        from(SceneA, to = SceneB) { spec = tween(duration, easing = LinearEasing) }

                        // Disable the shared transition during B => C.
                        from(SceneB, to = SceneC) {
                            spec = tween(duration, easing = LinearEasing)
                            sharedElement(TestElements.Foo, enabled = false)
                        }
                    },
                )
            }

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

        rule.setContent {
            SceneTransitionLayout(state, Modifier.size(layoutSize)) {
                scene(SceneA) {
                    Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopStart)) }
                }

                scene(SceneB) {
                    Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopEnd)) }
                }

                scene(SceneC) {
                    Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.BottomEnd)) }
                }
            }
        }

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

        // State is a transition A => B at 50% interrupted by B => C at 30%.
        val aToB =
            transition(from = SceneA, to = SceneB, progress = { 0.5f }, onFinish = neverFinish())
        var bToCInterruptionProgress by mutableStateOf(1f)
        val bToC =
            transition(
                from = SceneB,
                to = SceneC,
                progress = { 0.3f },
                interruptionProgress = { bToCInterruptionProgress },
                onFinish = neverFinish(),
            )
        rule.runOnUiThread { state.startTransition(aToB, transitionKey = null) }
        rule.waitForIdle()
        rule.runOnUiThread { state.startTransition(bToC, transitionKey = null) }

        // Foo is placed in both B and C given that the shared transition is disabled. In B, its
        // offset is impacted by the interruption but in C it is not.
        val offsetInAToB = lerp(offsetInA, offsetInB, 0.5f)
        val interruptionDelta = offsetInAToB - offsetInB
        assertThat(interruptionDelta).isNotEqualTo(Offset.Zero)
        rule
            .onNode(isElement(TestElements.Foo, SceneB))
            .assertPositionInRootIsEqualTo(
                offsetInB.x + interruptionDelta.x,
                offsetInB.y + interruptionDelta.y,
            )

        rule
            .onNode(isElement(TestElements.Foo, SceneC))
            .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y)

        // Manually finish A => B so only B => C is remaining.
        bToCInterruptionProgress = 0f
        rule.runOnUiThread { state.finishTransition(aToB, SceneB) }
        rule
            .onNode(isElement(TestElements.Foo, SceneB))
            .assertPositionInRootIsEqualTo(offsetInB.x, offsetInB.y)
        rule
            .onNode(isElement(TestElements.Foo, SceneC))
            .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y)

        // Interrupt B => C by B => A, starting directly at 70%
        val bToA =
            transition(
                from = SceneB,
                to = SceneA,
                progress = { 0.7f },
                interruptionProgress = { 1f },
            )
        rule.runOnUiThread { state.startTransition(bToA, transitionKey = null) }

        // Foo should have the position it had in B right before the interruption.
        rule
            .onNode(isElement(TestElements.Foo, SceneB))
            .assertPositionInRootIsEqualTo(offsetInB.x, offsetInB.y)
    }
}