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

Commit fbf91e3b authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Better handle interruptions when shared element is disabled

This CL improves the interruption logic to add support for *disabled*
shared element transitions.

See b/290930950#comment23 for before/after videos.

Bug: 290930950
Test: ElementTest
Flag: com.android.systemui.scene_container
Change-Id: I18830d775bd6315e44cf037091bce3e8da7c5973
parent e80f8c06
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)
    }
}