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

Commit 693b6465 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Improve transitions when replacing an overlay

This CL improves transitions when replacing an overlay for elements that
are only in one of the overlays but also in the current scene. For these
elements, we now interpolate the state in the current scene with the
state in the overlay given the transition progress.

See b/353679003#comment35 for before/after videos.

Test: atest OverlayTest
Bug: 353679003
Flag: com.android.systemui.scene_container
Change-Id: Icebb02ddc60ae6a67c6cfd85d7d1bcb37613c507
parent 53f7942d
Loading
Loading
Loading
Loading
+46 −19
Original line number Diff line number Diff line
@@ -399,12 +399,41 @@ private class AnimatedStateImpl<T, Delta>(

        val fromValue = sharedValue[transition.fromContent]
        val toValue = sharedValue[transition.toContent]
        return if (fromValue != null && toValue != null) {
        if (fromValue == null && toValue == null) {
            return null
        }

        if (fromValue != null && toValue != null) {
            return interpolateSharedValue(fromValue, toValue, transition, sharedValue)
        }

        if (transition is TransitionState.Transition.ReplaceOverlay) {
            val currentSceneValue = sharedValue[transition.currentScene]
            if (currentSceneValue != null) {
                return interpolateSharedValue(
                    fromValue = fromValue ?: currentSceneValue,
                    toValue = toValue ?: currentSceneValue,
                    transition,
                    sharedValue,
                )
            }
        }

        return fromValue ?: toValue
    }

    private fun interpolateSharedValue(
        fromValue: T,
        toValue: T,
        transition: TransitionState.Transition,
        sharedValue: SharedValue<T, *>,
    ): T? {
        if (fromValue == toValue) {
            // Optimization: avoid reading progress if the values are the same, so we don't
            // relayout/redraw for nothing.
                fromValue
            } else {
            return fromValue
        }

        val overscrollSpec = transition.currentOverscrollSpec
        val progress =
            when {
@@ -416,9 +445,7 @@ private class AnimatedStateImpl<T, Delta>(
                else -> 0f
            }

                sharedValue.type.lerp(fromValue, toValue, progress)
            }
        } else fromValue ?: toValue
        return sharedValue.type.lerp(fromValue, toValue, progress)
    }

    private fun transition(sharedValue: SharedValue<T, Delta>): TransitionState.Transition? {
+65 −14
Original line number Diff line number Diff line
@@ -313,11 +313,28 @@ internal class ElementNode(
        // If this element is not supposed to be laid out now, either because it is not part of any
        // ongoing transition or the other content of its transition is overscrolling, then lay out
        // the element normally and don't place it.
        val overscrollScene = transition?.currentOverscrollSpec?.content
        val isOtherSceneOverscrolling = overscrollScene != null && overscrollScene != content.key
        if (isOtherSceneOverscrolling) {
        val overscrollContent = transition?.currentOverscrollSpec?.content
        if (overscrollContent != null && overscrollContent != content.key) {
            when (transition) {
                is TransitionState.Transition.ChangeScene ->
                    return doNotPlace(measurable, constraints)

                // If we are overscrolling an overlay that does not contain an element that is in
                // the current scene, place it in that scene otherwise the element won't be placed
                // at all.
                is TransitionState.Transition.ShowOrHideOverlay,
                is TransitionState.Transition.ReplaceOverlay -> {
                    if (
                        content.key == transition.currentScene &&
                            overscrollContent !in element.stateByContent
                    ) {
                        return placeNormally(measurable, constraints)
                    } else {
                        return doNotPlace(measurable, constraints)
                    }
                }
            }
        }

        val placeable =
            measure(layoutImpl, element, transition, stateInContent, measurable, constraints)
@@ -1230,17 +1247,30 @@ private inline fun <T> computeValue(
    // elements follow the finger direction.
    val isSharedElement = fromState != null && toState != null
    if (isSharedElement && isSharedElementEnabled(element.key, transition)) {
        val start = contentValue(fromState!!)
        val end = contentValue(toState!!)

        // TODO(b/316901148): Remove checks to isSpecified() once the lookahead pass runs for all
        // nodes before the intermediate layout pass.
        if (!isSpecified(start)) return end
        if (!isSpecified(end)) return start
        return interpolateSharedElement(
            transition = transition,
            contentValue = contentValue,
            fromState = fromState!!,
            toState = toState!!,
            isSpecified = isSpecified,
            lerp = lerp,
        )
    }

        // Make sure we don't read progress if values are the same and we don't need to interpolate,
        // so we don't invalidate the phase where this is read.
        return if (start == end) start else lerp(start, end, transition.progress)
    // If we are replacing an overlay and the element is both in a single overlay and in the current
    // scene, interpolate the state of the element using the current scene as the other scene.
    if (!isSharedElement && transition is TransitionState.Transition.ReplaceOverlay) {
        val currentSceneState = element.stateByContent[transition.currentScene]
        if (currentSceneState != null) {
            return interpolateSharedElement(
                transition = transition,
                contentValue = contentValue,
                fromState = fromState ?: currentSceneState,
                toState = toState ?: currentSceneState,
                isSpecified = isSpecified,
                lerp = lerp,
            )
        }
    }

    // Get the transformed value, i.e. the target value at the beginning (for entering elements) or
@@ -1383,3 +1413,24 @@ private inline fun <T> computeValue(
        lerp(idleValue, targetValue, rangeProgress)
    }
}

private inline fun <T> interpolateSharedElement(
    transition: TransitionState.Transition,
    contentValue: (Element.State) -> T,
    fromState: Element.State,
    toState: Element.State,
    isSpecified: (T) -> Boolean,
    lerp: (T, T, Float) -> T
): T {
    val start = contentValue(fromState)
    val end = contentValue(toState)

    // TODO(b/316901148): Remove checks to isSpecified() once the lookahead pass runs for all
    // nodes before the intermediate layout pass.
    if (!isSpecified(start)) return end
    if (!isSpecified(end)) return start

    // Make sure we don't read progress if values are the same and we don't need to interpolate,
    // so we don't invalidate the phase where this is read.
    return if (start == end) start else lerp(start, end, transition.progress)
}
+124 −0
Original line number Diff line number Diff line
@@ -22,10 +22,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.Modifier
import androidx.compose.ui.platform.testTag
@@ -42,6 +44,8 @@ import com.android.compose.animation.scene.TestOverlays.OverlayA
import com.android.compose.animation.scene.TestOverlays.OverlayB
import com.android.compose.animation.scene.TestScenes.SceneA
import com.android.compose.test.assertSizeIsEqualTo
import com.android.compose.test.subjects.assertThat
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import org.junit.Rule
import org.junit.Test
@@ -524,4 +528,124 @@ class OverlayTest {
            }
        }
    }

    @Test
    fun replaceAnimation_elementInCurrentSceneAndOneOverlay() {
        val sharedIntKey = ValueKey("sharedInt")
        val sharedIntValueByContent = mutableMapOf<ContentKey, Int>()

        @Composable
        fun SceneScope.animateContentInt(targetValue: Int) {
            val animatedValue = animateContentIntAsState(targetValue, sharedIntKey)
            LaunchedEffect(animatedValue) {
                try {
                    snapshotFlow { animatedValue.value }
                        .collect { sharedIntValueByContent[contentKey] = it }
                } finally {
                    sharedIntValueByContent.remove(contentKey)
                }
            }
        }

        rule.testReplaceOverlayTransition(
            currentSceneContent = {
                Box(Modifier.size(width = 180.dp, height = 120.dp)) {
                    animateContentInt(targetValue = 1_000)
                    Foo(width = 60.dp, height = 40.dp)
                }
            },
            fromContent = {},
            fromAlignment = Alignment.TopStart,
            toContent = {
                animateContentInt(targetValue = 2_000)
                Foo(width = 100.dp, height = 80.dp)
            },
            transition = {
                // 4 frames of animation
                spec = tween(4 * 16, easing = LinearEasing)
            },
        ) {
            // Foo moves from (0,0) with a size of 60x40dp to centered (in a 180x120dp Box) with a
            // size of 100x80dp, so at (40,20).
            //
            // The animated Int goes from 1_000 to 2_000.
            before {
                rule
                    .onNode(isElement(TestElements.Foo, content = SceneA))
                    .assertSizeIsEqualTo(60.dp, 40.dp)
                    .assertPositionInRootIsEqualTo(0.dp, 0.dp)
                rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
                rule.onNode(isElement(TestElements.Foo, content = OverlayB)).assertDoesNotExist()

                assertThat(sharedIntValueByContent).containsEntry(SceneA, 1_000)
                assertThat(sharedIntValueByContent).doesNotContainKey(OverlayA)
                assertThat(sharedIntValueByContent).doesNotContainKey(OverlayB)
            }

            at(16) {
                rule
                    .onNode(isElement(TestElements.Foo, content = SceneA))
                    .assertExists()
                    .assertIsNotDisplayed()
                rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
                rule
                    .onNode(isElement(TestElements.Foo, content = OverlayB))
                    .assertSizeIsEqualTo(70.dp, 50.dp)
                    .assertPositionInRootIsEqualTo(10.dp, 5.dp)

                assertThat(sharedIntValueByContent).containsEntry(SceneA, 1_250)
                assertThat(sharedIntValueByContent).doesNotContainKey(OverlayA)
                assertThat(sharedIntValueByContent).containsEntry(OverlayB, 1_250)
            }

            at(32) {
                rule
                    .onNode(isElement(TestElements.Foo, content = SceneA))
                    .assertExists()
                    .assertIsNotDisplayed()
                rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
                rule
                    .onNode(isElement(TestElements.Foo, content = OverlayB))
                    .assertSizeIsEqualTo(80.dp, 60.dp)
                    .assertPositionInRootIsEqualTo(20.dp, 10.dp)

                assertThat(sharedIntValueByContent).containsEntry(SceneA, 1_500)
                assertThat(sharedIntValueByContent).doesNotContainKey(OverlayA)
                assertThat(sharedIntValueByContent).containsEntry(OverlayB, 1_500)
            }

            at(48) {
                rule
                    .onNode(isElement(TestElements.Foo, content = SceneA))
                    .assertExists()
                    .assertIsNotDisplayed()
                rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
                rule
                    .onNode(isElement(TestElements.Foo, content = OverlayB))
                    .assertSizeIsEqualTo(90.dp, 70.dp)
                    .assertPositionInRootIsEqualTo(30.dp, 15.dp)

                assertThat(sharedIntValueByContent).containsEntry(SceneA, 1_750)
                assertThat(sharedIntValueByContent).doesNotContainKey(OverlayA)
                assertThat(sharedIntValueByContent).containsEntry(OverlayB, 1_750)
            }

            after {
                rule
                    .onNode(isElement(TestElements.Foo, content = SceneA))
                    .assertExists()
                    .assertIsNotDisplayed()
                rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
                rule
                    .onNode(isElement(TestElements.Foo, content = OverlayB))
                    .assertSizeIsEqualTo(100.dp, 80.dp)
                    .assertPositionInRootIsEqualTo(40.dp, 20.dp)

                // Outside of transitions, the value is equal to the target value in each content.
                assertThat(sharedIntValueByContent).containsEntry(SceneA, 1_000)
                assertThat(sharedIntValueByContent).doesNotContainKey(OverlayA)
                assertThat(sharedIntValueByContent).containsEntry(OverlayB, 2_000)
            }
        }
    }
}