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

Commit e0be1637 authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Improve transitions when replacing an overlay" into main

parents 12cdf878 693b6465
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)
            }
        }
    }
}