Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt +46 −19 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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? { Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +65 −14 Original line number Diff line number Diff line Loading @@ -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) Loading Loading @@ -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 Loading Loading @@ -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) } packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt +124 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) } } } } Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt +46 −19 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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? { Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +65 −14 Original line number Diff line number Diff line Loading @@ -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) Loading Loading @@ -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 Loading Loading @@ -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) }
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt +124 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) } } } }