diff options
author | 2024-01-03 16:37:28 +0000 | |
---|---|---|
committer | 2024-01-03 16:37:28 +0000 | |
commit | 79b8a00c5ff39a1a3530cbe7449e45fd01c6333f (patch) | |
tree | 4654208765ccf95bcee1f5ccdd3a951660b6bb4a | |
parent | 1315a6992d82cc62a368c0bcc38f10d6b3681389 (diff) | |
parent | aa4ecae12c09924cdce940fcc9c3dbae1b6836be (diff) |
Merge changes from topics "stl-movable-refactor", "stl-picker-rename", "stl-remove-isbackground", "stl-ucs" into main
* changes:
Introduce MovableElementScenePicker
Remove ElementKey.isBackground (1/2)
Rename SharedElementScenePicker to ElementScenePicker (1/2)
Make (Movable)ElementScope extend ElementBoxScope
Work around b/317972419
Make the Element map a normal Map
Extract movable contents outside of the Element class
Rename Element.TargetValues to Element.SceneState
Extract shared values outside of the Element class
Replace AnimatedState.valueOrNull by unsafeCompositionState (1/2)
Refactor animate(Scene|Element)FooAsState (1/2)
Make animated values normal State objects
Compose movable elements in the scene picked by the ScenePicker (1/2)
Split SceneScope.MovableElement with SceneSope.Element (1/2)
Move scene picker definition inside ElementKey (1/2)
35 files changed, 1431 insertions, 735 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/AmbientIndicationSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/AmbientIndicationSection.kt index 0e7ac5ec046a..1e5481ebfa6c 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/AmbientIndicationSection.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/AmbientIndicationSection.kt @@ -35,14 +35,16 @@ class AmbientIndicationSection @Inject constructor() { key = AmbientIndicationElementKey, modifier = modifier, ) { - Box( - modifier = Modifier.fillMaxWidth().background(Color.Green), - ) { - Text( - text = "TODO(b/316211368): Ambient indication", - color = Color.White, - modifier = Modifier.align(Alignment.Center), - ) + content { + Box( + modifier = Modifier.fillMaxWidth().background(Color.Green), + ) { + Text( + text = "TODO(b/316211368): Ambient indication", + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/BottomAreaSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/BottomAreaSection.kt index 4f3498e8dff5..8bd0d45920f4 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/BottomAreaSection.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/BottomAreaSection.kt @@ -74,20 +74,22 @@ constructor( key = if (isStart) StartButtonElementKey else EndButtonElementKey, modifier = modifier, ) { - Shortcut( - viewId = if (isStart) R.id.start_button else R.id.end_button, - viewModel = if (isStart) viewModel.startButton else viewModel.endButton, - transitionAlpha = viewModel.transitionAlpha, - falsingManager = falsingManager, - vibratorHelper = vibratorHelper, - indicationController = indicationController, - modifier = - if (applyPadding) { - Modifier.shortcutPadding() - } else { - Modifier - } - ) + content { + Shortcut( + viewId = if (isStart) R.id.start_button else R.id.end_button, + viewModel = if (isStart) viewModel.startButton else viewModel.endButton, + transitionAlpha = viewModel.transitionAlpha, + falsingManager = falsingManager, + vibratorHelper = vibratorHelper, + indicationController = indicationController, + modifier = + if (applyPadding) { + Modifier.shortcutPadding() + } else { + Modifier + } + ) + } } } @@ -99,11 +101,13 @@ constructor( key = IndicationAreaElementKey, modifier = modifier.shortcutPadding(), ) { - IndicationArea( - indicationAreaViewModel = indicationAreaViewModel, - alphaViewModel = alphaViewModel, - indicationController = indicationController, - ) + content { + IndicationArea( + indicationAreaViewModel = indicationAreaViewModel, + alphaViewModel = alphaViewModel, + indicationController = indicationController, + ) + } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/ClockSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/ClockSection.kt index 0b49922a8412..0f3fc47d4e91 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/ClockSection.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/ClockSection.kt @@ -49,17 +49,19 @@ constructor( key = ClockElementKey, modifier = modifier, ) { - Box( - modifier = - Modifier.fillMaxWidth() - .background(Color.Magenta) - .onTopPlacementChanged(onTopChanged) - ) { - Text( - text = "TODO(b/316211368): Small clock", - color = Color.White, - modifier = Modifier.align(Alignment.Center), - ) + content { + Box( + modifier = + Modifier.fillMaxWidth() + .background(Color.Magenta) + .onTopPlacementChanged(onTopChanged) + ) { + Text( + text = "TODO(b/316211368): Small clock", + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } } } } @@ -74,14 +76,16 @@ constructor( key = ClockElementKey, modifier = modifier, ) { - Box( - modifier = Modifier.fillMaxWidth().background(Color.Blue), - ) { - Text( - text = "TODO(b/316211368): Large clock", - color = Color.White, - modifier = Modifier.align(Alignment.Center), - ) + content { + Box( + modifier = Modifier.fillMaxWidth().background(Color.Blue), + ) { + Text( + text = "TODO(b/316211368): Large clock", + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/NotificationSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/NotificationSection.kt index c547e2b93158..900616f6af89 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/NotificationSection.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/NotificationSection.kt @@ -22,7 +22,6 @@ import com.android.compose.animation.scene.SceneScope import com.android.systemui.notifications.ui.composable.NotificationStack import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher class NotificationSection @Inject diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/StatusBarSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/StatusBarSection.kt index 5727e3436126..ddc12ff22d50 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/StatusBarSection.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/keyguard/ui/composable/section/StatusBarSection.kt @@ -53,37 +53,43 @@ constructor( key = StatusBarElementKey, modifier = modifier, ) { - AndroidView( - factory = { - notificationPanelView.get().findViewById<View>(R.id.keyguard_header)?.let { - (it.parent as ViewGroup).removeView(it) - } + content { + AndroidView( + factory = { + notificationPanelView.get().findViewById<View>(R.id.keyguard_header)?.let { + (it.parent as ViewGroup).removeView(it) + } + + val provider = + object : ShadeViewStateProvider { + override val lockscreenShadeDragProgress: Float = 0f + override val panelViewExpandedHeight: Float = 0f - val provider = - object : ShadeViewStateProvider { - override val lockscreenShadeDragProgress: Float = 0f - override val panelViewExpandedHeight: Float = 0f - override fun shouldHeadsUpBeVisible(): Boolean { - return false + override fun shouldHeadsUpBeVisible(): Boolean { + return false + } } - } - @SuppressLint("InflateParams") - val view = - LayoutInflater.from(context) - .inflate( - R.layout.keyguard_status_bar, - null, - false, - ) as KeyguardStatusBarView - componentFactory.build(view, provider).keyguardStatusBarViewController.init() - view - }, - modifier = - Modifier.fillMaxWidth().padding(horizontal = 16.dp).height { - Utils.getStatusBarHeaderHeightKeyguard(context) + @SuppressLint("InflateParams") + val view = + LayoutInflater.from(context) + .inflate( + R.layout.keyguard_status_bar, + null, + false, + ) as KeyguardStatusBarView + componentFactory + .build(view, provider) + .keyguardStatusBarViewController + .init() + view }, - ) + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp).height { + Utils.getStatusBarHeaderHeightKeyguard(context) + }, + ) + } } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt index 12f1b301c836..0eec024d3c81 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.unit.dp import com.android.compose.animation.scene.ElementKey import com.android.compose.animation.scene.SceneScope import com.android.compose.animation.scene.ValueKey -import com.android.compose.animation.scene.animateSharedFloatAsState +import com.android.compose.animation.scene.animateElementFloatAsState import com.android.systemui.notifications.ui.composable.Notifications.Form import com.android.systemui.notifications.ui.composable.Notifications.SharedValues.SharedExpansionValue import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationsPlaceholderViewModel @@ -157,10 +157,10 @@ private fun SceneScope.NotificationPlaceholder( modifier: Modifier = Modifier, ) { val elementKey = Notifications.Elements.NotificationPlaceholder - Box( + Element( + elementKey, modifier = modifier - .element(elementKey) .debugBackground(viewModel) .onSizeChanged { size: IntSize -> debugLog(viewModel) { "STACK onSizeChanged: size=$size" } @@ -182,19 +182,23 @@ private fun SceneScope.NotificationPlaceholder( } ) { val animatedExpansion by - animateSharedFloatAsState( + animateElementFloatAsState( value = if (form == Form.HunFromTop) 0f else 1f, - key = SharedExpansionValue, - element = elementKey + key = SharedExpansionValue ) debugLog(viewModel) { "STACK composed: expansion=$animatedExpansion" } - if (viewModel.isPlaceholderTextVisible) { - Text( - text = "Notifications", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.Center), - ) + + content { + if (viewModel.isPlaceholderTextVisible) { + Box(Modifier.fillMaxSize()) { + Text( + text = "Notifications", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.Center), + ) + } + } } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt index f3cde534ebaa..65a53f57f2e7 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt @@ -98,7 +98,7 @@ fun SceneScope.QuickSettings( key = QuickSettings.Elements.Content, modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 300.dp) ) { - QuickSettingsContent(qsSceneAdapter = qsSceneAdapter, contentState) + content { QuickSettingsContent(qsSceneAdapter = qsSceneAdapter, contentState) } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeHeader.kt b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeHeader.kt index 4bbb78b69392..99f81ee5c7d3 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeHeader.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeHeader.kt @@ -50,7 +50,7 @@ import androidx.compose.ui.viewinterop.AndroidView import com.android.compose.animation.scene.ElementKey import com.android.compose.animation.scene.SceneScope import com.android.compose.animation.scene.ValueKey -import com.android.compose.animation.scene.animateSharedFloatAsState +import com.android.compose.animation.scene.animateSceneFloatAsState import com.android.compose.windowsizeclass.LocalWindowSizeClass import com.android.settingslib.Utils import com.android.systemui.battery.BatteryMeterView @@ -69,7 +69,6 @@ import com.android.systemui.statusbar.policy.Clock object ShadeHeader { object Elements { - val FormatPlaceholder = ElementKey("ShadeHeaderFormatPlaceholder") val ExpandedContent = ElementKey("ShadeHeaderExpandedContent") val CollapsedContent = ElementKey("ShadeHeaderCollapsedContent") } @@ -92,14 +91,7 @@ fun SceneScope.CollapsedShadeHeader( statusBarIconController: StatusBarIconController, modifier: Modifier = Modifier, ) { - // TODO(b/298153892): Remove this once animateSharedFloatAsState.element can be null. - Spacer(Modifier.element(ShadeHeader.Elements.FormatPlaceholder)) - val formatProgress = - animateSharedFloatAsState( - 0.0f, - ShadeHeader.Keys.transitionProgress, - ShadeHeader.Elements.FormatPlaceholder - ) + val formatProgress = animateSceneFloatAsState(0.0f, ShadeHeader.Keys.transitionProgress) val cutoutWidth = LocalDisplayCutout.current.width() val cutoutLocation = LocalDisplayCutout.current.location @@ -217,14 +209,7 @@ fun SceneScope.ExpandedShadeHeader( statusBarIconController: StatusBarIconController, modifier: Modifier = Modifier, ) { - // TODO(b/298153892): Remove this once animateSharedFloatAsState.element can be null. - Spacer(Modifier.element(ShadeHeader.Elements.FormatPlaceholder)) - val formatProgress = - animateSharedFloatAsState( - 1.0f, - ShadeHeader.Keys.transitionProgress, - ShadeHeader.Elements.FormatPlaceholder - ) + val formatProgress = animateSceneFloatAsState(1.0f, ShadeHeader.Keys.transitionProgress) val useExpandedFormat by remember(formatProgress) { derivedStateOf { formatProgress.value > 0.5f } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt index 2944bd9f9a8e..b26194f2397b 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt @@ -17,10 +17,15 @@ package com.android.compose.animation.scene import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.unit.Dp @@ -28,180 +33,263 @@ import androidx.compose.ui.unit.lerp import com.android.compose.ui.util.lerp /** - * Animate a shared Int value. + * A [State] whose [value] is animated. * - * @see SceneScope.animateSharedValueAsState + * Important: This animated value should always be ready *after* composition, e.g. during layout, + * drawing or inside a LaunchedEffect. If you read [value] during composition, it will probably + * throw an exception, for 2 important reasons: + * 1. You should never read animated values during composition, because this will probably lead to + * bad performance. + * 2. Given that this value depends on the target value in different scenes, its current value + * (depending on the current transition state) can only be computed once the full tree has been + * composed. + * + * If you don't have the choice and *have to* get the value during composition, for instance because + * a Modifier or Composable reading this value does not have a lazy/lambda-based API, then you can + * access [unsafeCompositionState] and use a fallback value for the first frame where this animated + * value can not be computed yet. Note however that doing so will be bad for performance and might + * lead to late-by-one-frame flickers. + */ +@Stable +interface AnimatedState<T> : State<T> { + /** + * Return a [State] that can be read during composition. + * + * Important: You should avoid using this as much as possible and instead read [value] during + * layout/drawing, otherwise you will probably end up with a few frames that have a value that + * is not correctly interpolated. + */ + @Composable fun unsafeCompositionState(initialValue: T): State<T> +} + +/** + * Animate a scene Int value. + * + * @see SceneScope.animateSceneValueAsState */ @Composable -fun SceneScope.animateSharedIntAsState( +fun SceneScope.animateSceneIntAsState( value: Int, key: ValueKey, - element: ElementKey?, canOverflow: Boolean = true, -): State<Int> { - return animateSharedValueAsState(value, key, element, ::lerp, canOverflow) +): AnimatedState<Int> { + return animateSceneValueAsState(value, key, ::lerp, canOverflow) } /** - * Animate a shared Int value. + * Animate a shared element Int value. * - * @see MovableElementScope.animateSharedValueAsState + * @see ElementScope.animateElementValueAsState */ @Composable -fun MovableElementScope.animateSharedIntAsState( +fun ElementScope<*>.animateElementIntAsState( value: Int, - debugName: String, + key: ValueKey, canOverflow: Boolean = true, -): State<Int> { - return animateSharedValueAsState(value, debugName, ::lerp, canOverflow) +): AnimatedState<Int> { + return animateElementValueAsState(value, key, ::lerp, canOverflow) } /** - * Animate a shared Float value. + * Animate a scene Float value. * - * @see SceneScope.animateSharedValueAsState + * @see SceneScope.animateSceneValueAsState */ @Composable -fun SceneScope.animateSharedFloatAsState( +fun SceneScope.animateSceneFloatAsState( value: Float, key: ValueKey, - element: ElementKey?, canOverflow: Boolean = true, -): State<Float> { - return animateSharedValueAsState(value, key, element, ::lerp, canOverflow) +): AnimatedState<Float> { + return animateSceneValueAsState(value, key, ::lerp, canOverflow) } /** - * Animate a shared Float value. + * Animate a shared element Float value. * - * @see MovableElementScope.animateSharedValueAsState + * @see ElementScope.animateElementValueAsState */ @Composable -fun MovableElementScope.animateSharedFloatAsState( +fun ElementScope<*>.animateElementFloatAsState( value: Float, - debugName: String, + key: ValueKey, canOverflow: Boolean = true, -): State<Float> { - return animateSharedValueAsState(value, debugName, ::lerp, canOverflow) +): AnimatedState<Float> { + return animateElementValueAsState(value, key, ::lerp, canOverflow) } /** - * Animate a shared Dp value. + * Animate a scene Dp value. * - * @see SceneScope.animateSharedValueAsState + * @see SceneScope.animateSceneValueAsState */ @Composable -fun SceneScope.animateSharedDpAsState( +fun SceneScope.animateSceneDpAsState( value: Dp, key: ValueKey, - element: ElementKey?, canOverflow: Boolean = true, -): State<Dp> { - return animateSharedValueAsState(value, key, element, ::lerp, canOverflow) +): AnimatedState<Dp> { + return animateSceneValueAsState(value, key, ::lerp, canOverflow) } /** - * Animate a shared Dp value. + * Animate a shared element Dp value. * - * @see MovableElementScope.animateSharedValueAsState + * @see ElementScope.animateElementValueAsState */ @Composable -fun MovableElementScope.animateSharedDpAsState( +fun ElementScope<*>.animateElementDpAsState( value: Dp, - debugName: String, + key: ValueKey, canOverflow: Boolean = true, -): State<Dp> { - return animateSharedValueAsState(value, debugName, ::lerp, canOverflow) +): AnimatedState<Dp> { + return animateElementValueAsState(value, key, ::lerp, canOverflow) } /** - * Animate a shared Color value. + * Animate a scene Color value. * - * @see SceneScope.animateSharedValueAsState + * @see SceneScope.animateSceneValueAsState */ @Composable -fun SceneScope.animateSharedColorAsState( +fun SceneScope.animateSceneColorAsState( value: Color, key: ValueKey, - element: ElementKey?, -): State<Color> { - return animateSharedValueAsState(value, key, element, ::lerp, canOverflow = false) +): AnimatedState<Color> { + return animateSceneValueAsState(value, key, ::lerp, canOverflow = false) } /** - * Animate a shared Color value. + * Animate a shared element Color value. * - * @see MovableElementScope.animateSharedValueAsState + * @see ElementScope.animateElementValueAsState */ @Composable -fun MovableElementScope.animateSharedColorAsState( +fun ElementScope<*>.animateElementColorAsState( value: Color, - debugName: String, -): State<Color> { - return animateSharedValueAsState(value, debugName, ::lerp, canOverflow = false) + key: ValueKey, +): AnimatedState<Color> { + return animateElementValueAsState(value, key, ::lerp, canOverflow = false) } @Composable internal fun <T> animateSharedValueAsState( layoutImpl: SceneTransitionLayoutImpl, - scene: Scene, - element: Element?, + scene: SceneKey, + element: ElementKey?, key: ValueKey, value: T, lerp: (T, T, Float) -> T, canOverflow: Boolean, -): State<T> { - val sharedValue = - Snapshot.withoutReadObservation { - val sharedValues = - element?.sceneValues?.getValue(scene.key)?.sharedValues ?: scene.sharedValues - sharedValues.getOrPut(key) { Element.SharedValue(key, value) } as Element.SharedValue<T> - } +): AnimatedState<T> { + DisposableEffect(layoutImpl, scene, element, key) { + // Create the associated maps that hold the current value for each (element, scene) pair. + val valueMap = layoutImpl.sharedValues.getOrPut(key) { mutableMapOf() } + val sceneToValueMap = + valueMap.getOrPut(element) { SnapshotStateMap<SceneKey, Any>() } + as SnapshotStateMap<SceneKey, T> + sceneToValueMap[scene] = value + + onDispose { + // Remove the value associated to the current scene, and eventually remove the maps if + // they are empty. + sceneToValueMap.remove(scene) - if (value != sharedValue.value) { - sharedValue.value = value + if (sceneToValueMap.isEmpty() && valueMap[element] === sceneToValueMap) { + valueMap.remove(element) + + if (valueMap.isEmpty() && layoutImpl.sharedValues[key] === valueMap) { + layoutImpl.sharedValues.remove(key) + } + } + } } - return remember(layoutImpl, element, sharedValue, lerp, canOverflow) { - derivedStateOf { computeValue(layoutImpl, element, sharedValue, lerp, canOverflow) } + // Update the current value. Note that side effects run after disposable effects, so we know + // that the associated maps were created at this point. + SideEffect { sceneToValueMap<T>(layoutImpl, key, element)[scene] = value } + + return remember(layoutImpl, scene, element, lerp, canOverflow) { + object : AnimatedState<T> { + override val value: T + get() = value(layoutImpl, scene, element, key, lerp, canOverflow) + + @Composable + override fun unsafeCompositionState(initialValue: T): State<T> { + val state = remember { mutableStateOf(initialValue) } + + val animatedState = this + LaunchedEffect(animatedState) { + snapshotFlow { animatedState.value }.collect { state.value = it } + } + + return state + } + } } } -private fun <T> computeValue( +private fun <T> sceneToValueMap( layoutImpl: SceneTransitionLayoutImpl, - element: Element?, - sharedValue: Element.SharedValue<T>, + key: ValueKey, + element: ElementKey? +): MutableMap<SceneKey, T> { + return layoutImpl.sharedValues[key]?.get(element)?.let { it as SnapshotStateMap<SceneKey, T> } + ?: error(valueReadTooEarlyMessage(key)) +} + +private fun valueReadTooEarlyMessage(key: ValueKey) = + "Animated value $key was read before its target values were set. This probably " + + "means that you are reading it during composition, which you should not do. See the " + + "documentation of AnimatedState for more information." + +private fun <T> value( + layoutImpl: SceneTransitionLayoutImpl, + scene: SceneKey, + element: ElementKey?, + key: ValueKey, lerp: (T, T, Float) -> T, canOverflow: Boolean, ): T { - val transition = layoutImpl.state.currentTransition - if (transition == null || !layoutImpl.isTransitionReady(transition)) { - return sharedValue.value - } + return valueOrNull(layoutImpl, scene, element, key, lerp, canOverflow) + ?: error(valueReadTooEarlyMessage(key)) +} - fun sceneValue(scene: SceneKey): Element.SharedValue<T>? { - val sharedValues = - if (element == null) { - layoutImpl.scene(scene).sharedValues - } else { - element.sceneValues[scene]?.sharedValues - } - ?: return null - val value = sharedValues[sharedValue.key] ?: return null - return value as Element.SharedValue<T> - } +private fun <T> valueOrNull( + layoutImpl: SceneTransitionLayoutImpl, + scene: SceneKey, + element: ElementKey?, + key: ValueKey, + lerp: (T, T, Float) -> T, + canOverflow: Boolean, +): T? { + val sceneToValueMap = sceneToValueMap<T>(layoutImpl, key, element) + fun sceneValue(scene: SceneKey): T? = sceneToValueMap[scene] - val fromValue = sceneValue(transition.fromScene) - val toValue = sceneValue(transition.toScene) - return if (fromValue != null && toValue != null) { - val progress = - if (canOverflow) transition.progress else transition.progress.coerceIn(0f, 1f) - lerp(fromValue.value, toValue.value, progress) - } else if (fromValue != null) { - fromValue.value - } else if (toValue != null) { - toValue.value - } else { - sharedValue.value + return when (val transition = layoutImpl.state.transitionState) { + is TransitionState.Idle -> sceneValue(transition.currentScene) + is TransitionState.Transition -> { + // Note: no need to check for transition ready here given that all target values are + // defined during composition, we should already have the correct values to interpolate + // between here. + val fromValue = sceneValue(transition.fromScene) + val toValue = sceneValue(transition.toScene) + if (fromValue != null && toValue != null) { + if (fromValue == toValue) { + // Optimization: avoid reading progress if the values are the same, so we don't + // relayout/redraw for nothing. + fromValue + } else { + val progress = + if (canOverflow) transition.progress + else transition.progress.coerceIn(0f, 1f) + lerp(fromValue, toValue, progress) + } + } else fromValue ?: toValue + } } + // TODO(b/311600838): Remove this. We should not have to fallback to the current scene value, + // but we have to because code of removed nodes can still run if they are placed with a graphics + // layer. + ?: sceneValue(scene) } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt index a85d9bff283e..280fbfb7d3d3 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt @@ -16,15 +16,10 @@ package com.android.compose.animation.scene -import android.graphics.Picture -import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -52,43 +47,22 @@ import kotlinx.coroutines.launch @Stable internal class Element(val key: ElementKey) { /** - * The last values of this element, coming from any scene. Note that this value will be unstable + * The last state of this element, coming from any scene. Note that this state will be unstable * if this element is present in multiple scenes but the shared element animation is disabled, - * given that multiple instances of the element with different states will write to these - * values. You should prefer using [TargetValues.lastValues] in the current scene if it is - * defined. + * given that multiple instances of the element with different states will write to this state. + * You should prefer using [SceneState.lastState] in the current scene when it is defined. */ - val lastSharedValues = Values() + val lastSharedState = State() - /** The mapping between a scene and the values/state this element has in that scene, if any. */ - val sceneValues = SnapshotStateMap<SceneKey, TargetValues>() - - /** - * The movable content of this element, if this element is composed using - * [SceneScope.MovableElement]. - */ - private var _movableContent: (@Composable (@Composable () -> Unit) -> Unit)? = null - val movableContent: @Composable (@Composable () -> Unit) -> Unit - get() = - _movableContent - ?: movableContentOf { content: @Composable () -> Unit -> content() } - .also { _movableContent = it } - - /** - * The [Picture] to which we save the last drawing commands of this element, if it is movable. - * This is necessary because the content of this element might not be composed in the scene it - * should currently be drawn. - */ - private var _picture: Picture? = null - val picture: Picture - get() = _picture ?: Picture().also { _picture = it } + /** The mapping between a scene and the state this element has in that scene, if any. */ + val sceneStates = mutableMapOf<SceneKey, SceneState>() override fun toString(): String { return "Element(key=$key)" } - /** The current values of this element, either in a specific scene or in a shared context. */ - class Values { + /** The state of this element, either in a specific scene or in a shared context. */ + class State { /** The offset of the element, relative to the SceneTransitionLayout containing it. */ var offset = Offset.Unspecified @@ -102,16 +76,14 @@ internal class Element(val key: ElementKey) { var alpha = AlphaUnspecified } - /** The target values of this element in a given scene. */ + /** The last and target state of this element in a given scene. */ @Stable - class TargetValues(val scene: SceneKey) { - val lastValues = Values() + class SceneState(val scene: SceneKey) { + val lastState = State() var targetSize by mutableStateOf(SizeUnspecified) var targetOffset by mutableStateOf(Offset.Unspecified) - val sharedValues = SnapshotStateMap<ValueKey, SharedValue<*>>() - /** * The attached [ElementNode] a Modifier.element() for a given element and scene. During * composition, this set could have 0 to 2 elements. After composition and after all @@ -120,12 +92,6 @@ internal class Element(val key: ElementKey) { val nodes = mutableSetOf<ElementNode>() } - /** A shared value of this element. */ - @Stable - class SharedValue<T>(val key: ValueKey, initialValue: T) { - var value by mutableStateOf(initialValue) - } - companion object { val SizeUnspecified = IntSize(Int.MAX_VALUE, Int.MAX_VALUE) val AlphaUnspecified = Float.MIN_VALUE @@ -147,27 +113,18 @@ internal fun Modifier.element( scene: Scene, key: ElementKey, ): Modifier { - val element: Element - val sceneValues: Element.TargetValues - - // Get the element associated to [key] if it was already composed in another scene, - // otherwise create it and add it to our Map<ElementKey, Element>. This is done inside a - // withoutReadObservation() because there is no need to recompose when that map is mutated. - Snapshot.withoutReadObservation { - element = layoutImpl.elements[key] ?: Element(key).also { layoutImpl.elements[key] = it } - sceneValues = - element.sceneValues[scene.key] - ?: Element.TargetValues(scene.key).also { element.sceneValues[scene.key] = it } - } - - return this.then(ElementModifier(layoutImpl, scene, element, sceneValues)) + return this.then(ElementModifier(layoutImpl, scene, key)) // TODO(b/311132415): Move this into ElementNode once we can create a delegate // IntermediateLayoutModifierNode. .intermediateLayout { measurable, constraints -> - val placeable = - measure(layoutImpl, scene, element, sceneValues, measurable, constraints) + // TODO(b/311132415): No need to fetch the element and sceneState from the map anymore + // once this is merged into ElementNode. + val element = layoutImpl.elements.getValue(key) + val sceneState = element.sceneStates.getValue(scene.key) + + val placeable = measure(layoutImpl, scene, element, sceneState, measurable, constraints) layout(placeable.width, placeable.height) { - place(layoutImpl, scene, element, sceneValues, placeable, placementScope = this) + place(layoutImpl, scene, element, sceneState, placeable, placementScope = this) } } .testTag(key.testTag) @@ -180,72 +137,89 @@ internal fun Modifier.element( private data class ElementModifier( private val layoutImpl: SceneTransitionLayoutImpl, private val scene: Scene, - private val element: Element, - private val sceneValues: Element.TargetValues, + private val key: ElementKey, ) : ModifierNodeElement<ElementNode>() { - override fun create(): ElementNode = ElementNode(layoutImpl, scene, element, sceneValues) + override fun create(): ElementNode = ElementNode(layoutImpl, scene, key) override fun update(node: ElementNode) { - node.update(layoutImpl, scene, element, sceneValues) + node.update(layoutImpl, scene, key) } } internal class ElementNode( private var layoutImpl: SceneTransitionLayoutImpl, private var scene: Scene, - private var element: Element, - private var sceneValues: Element.TargetValues, + private var key: ElementKey, ) : Modifier.Node(), DrawModifierNode { + private var _element: Element? = null + private val element: Element + get() = _element!! + + private var _sceneState: Element.SceneState? = null + private val sceneState: Element.SceneState + get() = _sceneState!! override fun onAttach() { super.onAttach() - addNodeToSceneValues() + updateElementAndSceneValues() + addNodeToSceneState() } - private fun addNodeToSceneValues() { - sceneValues.nodes.add(this) + private fun updateElementAndSceneValues() { + val element = + layoutImpl.elements[key] ?: Element(key).also { layoutImpl.elements[key] = it } + _element = element + _sceneState = + element.sceneStates[scene.key] + ?: Element.SceneState(scene.key).also { element.sceneStates[scene.key] = it } + } + + private fun addNodeToSceneState() { + sceneState.nodes.add(this) coroutineScope.launch { // At this point all [CodeLocationNode] have been attached or detached, which means that - // [sceneValues.codeLocations] should have exactly 1 element, otherwise this means that + // [sceneState.codeLocations] should have exactly 1 element, otherwise this means that // this element was composed multiple times in the same scene. - val nCodeLocations = sceneValues.nodes.size - if (nCodeLocations != 1 || !sceneValues.nodes.contains(this@ElementNode)) { - error("${element.key} was composed $nCodeLocations times in ${sceneValues.scene}") + val nCodeLocations = sceneState.nodes.size + if (nCodeLocations != 1 || !sceneState.nodes.contains(this@ElementNode)) { + error("$key was composed $nCodeLocations times in ${sceneState.scene}") } } } override fun onDetach() { super.onDetach() - removeNodeFromSceneValues() - maybePruneMaps(layoutImpl, element, sceneValues) + removeNodeFromSceneState() + maybePruneMaps(layoutImpl, element, sceneState) + + _element = null + _sceneState = null } - private fun removeNodeFromSceneValues() { - sceneValues.nodes.remove(this) + private fun removeNodeFromSceneState() { + sceneState.nodes.remove(this) } fun update( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, - element: Element, - sceneValues: Element.TargetValues, + key: ElementKey, ) { check(layoutImpl == this.layoutImpl && scene == this.scene) - removeNodeFromSceneValues() + removeNodeFromSceneState() val prevElement = this.element - val prevSceneValues = this.sceneValues - this.element = element - this.sceneValues = sceneValues + val prevSceneState = this.sceneState + this.key = key + updateElementAndSceneValues() - addNodeToSceneValues() - maybePruneMaps(layoutImpl, prevElement, prevSceneValues) + addNodeToSceneState() + maybePruneMaps(layoutImpl, prevElement, prevSceneState) } override fun ContentDrawScope.draw() { - val drawScale = getDrawScale(layoutImpl, element, scene, sceneValues) + val drawScale = getDrawScale(layoutImpl, element, scene, sceneState) if (drawScale == Scale.Default) { drawContent() } else { @@ -263,18 +237,16 @@ internal class ElementNode( private fun maybePruneMaps( layoutImpl: SceneTransitionLayoutImpl, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, ) { // If element is not composed from this scene anymore, remove the scene values. This // works because [onAttach] is called before [onDetach], so if an element is moved from // the UI tree we will first add the new code location then remove the old one. - if ( - sceneValues.nodes.isEmpty() && element.sceneValues[sceneValues.scene] == sceneValues - ) { - element.sceneValues.remove(sceneValues.scene) + if (sceneState.nodes.isEmpty() && element.sceneStates[sceneState.scene] == sceneState) { + element.sceneStates.remove(sceneState.scene) // If the element is not composed in any scene, remove it from the elements map. - if (element.sceneValues.isEmpty() && layoutImpl.elements[element.key] == element) { + if (element.sceneStates.isEmpty() && layoutImpl.elements[element.key] == element) { layoutImpl.elements.remove(element.key) } } @@ -293,8 +265,8 @@ private fun shouldDrawElement( if ( transition == null || !layoutImpl.isTransitionReady(transition) || - transition.fromScene !in element.sceneValues || - transition.toScene !in element.sceneValues + transition.fromScene !in element.sceneStates || + transition.toScene !in element.sceneStates ) { return true } @@ -310,7 +282,6 @@ private fun shouldDrawElement( transition, scene.key, element.key, - sharedTransformation, ) } @@ -319,17 +290,14 @@ internal fun shouldDrawOrComposeSharedElement( transition: TransitionState.Transition, scene: SceneKey, element: ElementKey, - sharedTransformation: SharedElementTransformation? ): Boolean { - val scenePicker = sharedTransformation?.scenePicker ?: DefaultSharedElementScenePicker + val scenePicker = element.scenePicker val fromScene = transition.fromScene val toScene = transition.toScene return scenePicker.sceneDuringTransition( element = element, - fromScene = fromScene, - toScene = toScene, - progress = transition::progress, + transition = transition, fromSceneZIndex = layoutImpl.scenes.getValue(fromScene).zIndex, toSceneZIndex = layoutImpl.scenes.getValue(toScene).zIndex, ) == scene @@ -374,28 +342,28 @@ private fun isElementOpaque( layoutImpl: SceneTransitionLayoutImpl, element: Element, scene: Scene, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, ): Boolean { val transition = layoutImpl.state.currentTransition ?: return true if (!layoutImpl.isTransitionReady(transition)) { val lastValue = - sceneValues.lastValues.alpha.takeIf { it != Element.AlphaUnspecified } - ?: element.lastSharedValues.alpha.takeIf { it != Element.AlphaUnspecified } ?: 1f + sceneState.lastState.alpha.takeIf { it != Element.AlphaUnspecified } + ?: element.lastSharedState.alpha.takeIf { it != Element.AlphaUnspecified } ?: 1f return lastValue == 1f } val fromScene = transition.fromScene val toScene = transition.toScene - val fromValues = element.sceneValues[fromScene] - val toValues = element.sceneValues[toScene] + val fromState = element.sceneStates[fromScene] + val toState = element.sceneStates[toScene] - if (fromValues == null && toValues == null) { + if (fromState == null && toState == null) { error("This should not happen, element $element is neither in $fromScene or $toScene") } - val isSharedElement = fromValues != null && toValues != null + val isSharedElement = fromState != null && toState != null if (isSharedElement && isSharedElementEnabled(layoutImpl.state, transition, element.key)) { return true } @@ -415,7 +383,7 @@ private fun elementAlpha( layoutImpl: SceneTransitionLayoutImpl, element: Element, scene: Scene, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, ): Float { return computeValue( layoutImpl, @@ -426,9 +394,8 @@ private fun elementAlpha( idleValue = 1f, currentValue = { 1f }, lastValue = { - sceneValues.lastValues.alpha.takeIf { it != Element.AlphaUnspecified } - ?: element.lastSharedValues.alpha.takeIf { it != Element.AlphaUnspecified } - ?: 1f + sceneState.lastState.alpha.takeIf { it != Element.AlphaUnspecified } + ?: element.lastSharedState.alpha.takeIf { it != Element.AlphaUnspecified } ?: 1f }, ::lerp, ) @@ -440,15 +407,15 @@ private fun IntermediateMeasureScope.measure( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, measurable: Measurable, constraints: Constraints, ): Placeable { // Update the size this element has in this scene when idle. val targetSizeInScene = lookaheadSize - if (targetSizeInScene != sceneValues.targetSize) { + if (targetSizeInScene != sceneState.targetSize) { // TODO(b/290930950): Better handle when this changes to avoid instant size jumps. - sceneValues.targetSize = targetSizeInScene + sceneState.targetSize = targetSizeInScene } // Some lambdas called (max once) by computeValue() will need to measure [measurable], in which @@ -468,8 +435,8 @@ private fun IntermediateMeasureScope.measure( idleValue = lookaheadSize, currentValue = { measurable.measure(constraints).also { maybePlaceable = it }.size() }, lastValue = { - sceneValues.lastValues.size.takeIf { it != Element.SizeUnspecified } - ?: element.lastSharedValues.size.takeIf { it != Element.SizeUnspecified } + sceneState.lastState.size.takeIf { it != Element.SizeUnspecified } + ?: element.lastSharedState.size.takeIf { it != Element.SizeUnspecified } ?: measurable.measure(constraints).also { maybePlaceable = it }.size() }, ::lerp, @@ -485,8 +452,8 @@ private fun IntermediateMeasureScope.measure( ) val size = placeable.size() - element.lastSharedValues.size = size - sceneValues.lastValues.size = size + element.lastSharedState.size = size + sceneState.lastState.size = size return placeable } @@ -494,7 +461,7 @@ private fun getDrawScale( layoutImpl: SceneTransitionLayoutImpl, element: Element, scene: Scene, - sceneValues: Element.TargetValues + sceneState: Element.SceneState ): Scale { return computeValue( layoutImpl, @@ -505,8 +472,8 @@ private fun getDrawScale( idleValue = Scale.Default, currentValue = { Scale.Default }, lastValue = { - sceneValues.lastValues.drawScale.takeIf { it != Scale.Default } - ?: element.lastSharedValues.drawScale + sceneState.lastState.drawScale.takeIf { it != Scale.Default } + ?: element.lastSharedState.drawScale }, ::lerp, ) @@ -517,7 +484,7 @@ private fun IntermediateMeasureScope.place( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, placeable: Placeable, placementScope: Placeable.PlacementScope, ) { @@ -526,14 +493,14 @@ private fun IntermediateMeasureScope.place( // when idle. val coords = coordinates ?: error("Element ${element.key} does not have any coordinates") val targetOffsetInScene = lookaheadScopeCoordinates.localLookaheadPositionOf(coords) - if (targetOffsetInScene != sceneValues.targetOffset) { + if (targetOffsetInScene != sceneState.targetOffset) { // TODO(b/290930950): Better handle when this changes to avoid instant offset jumps. - sceneValues.targetOffset = targetOffsetInScene + sceneState.targetOffset = targetOffsetInScene } val currentOffset = lookaheadScopeCoordinates.localPositionOf(coords, Offset.Zero) - val lastSharedValues = element.lastSharedValues - val lastValues = sceneValues.lastValues + val lastSharedState = element.lastSharedState + val lastSceneState = sceneState.lastState val targetOffset = computeValue( layoutImpl, @@ -544,36 +511,36 @@ private fun IntermediateMeasureScope.place( idleValue = targetOffsetInScene, currentValue = { currentOffset }, lastValue = { - lastValues.offset.takeIf { it.isSpecified } - ?: lastSharedValues.offset.takeIf { it.isSpecified } ?: currentOffset + lastSceneState.offset.takeIf { it.isSpecified } + ?: lastSharedState.offset.takeIf { it.isSpecified } ?: currentOffset }, ::lerp, ) - lastSharedValues.offset = targetOffset - lastValues.offset = targetOffset + lastSharedState.offset = targetOffset + lastSceneState.offset = targetOffset // No need to place the element in this scene if we don't want to draw it anyways. Note that - // it's still important to compute the target offset and update lastValues, otherwise it - // will be out of date. + // it's still important to compute the target offset and update last(Shared|Scene)State, + // otherwise they will be out of date. if (!shouldDrawElement(layoutImpl, scene, element)) { return } val offset = (targetOffset - currentOffset).round() - if (isElementOpaque(layoutImpl, element, scene, sceneValues)) { + if (isElementOpaque(layoutImpl, element, scene, sceneState)) { // TODO(b/291071158): Call placeWithLayer() if offset != IntOffset.Zero and size is not // animated once b/305195729 is fixed. Test that drawing is not invalidated in that // case. placeable.place(offset) - lastSharedValues.alpha = 1f - lastValues.alpha = 1f + lastSharedState.alpha = 1f + lastSceneState.alpha = 1f } else { placeable.placeWithLayer(offset) { - val alpha = elementAlpha(layoutImpl, element, scene, sceneValues) + val alpha = elementAlpha(layoutImpl, element, scene, sceneState) this.alpha = alpha - lastSharedValues.alpha = alpha - lastValues.alpha = alpha + lastSharedState.alpha = alpha + lastSceneState.alpha = alpha } } } @@ -605,7 +572,7 @@ private inline fun <T> computeValue( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValue: (Element.TargetValues) -> T, + sceneValue: (Element.SceneState) -> T, transformation: (ElementTransformations) -> PropertyTransformation<T>?, idleValue: T, currentValue: () -> T, @@ -628,10 +595,10 @@ private inline fun <T> computeValue( val fromScene = transition.fromScene val toScene = transition.toScene - val fromValues = element.sceneValues[fromScene] - val toValues = element.sceneValues[toScene] + val fromState = element.sceneStates[fromScene] + val toState = element.sceneStates[toScene] - if (fromValues == null && toValues == null) { + if (fromState == null && toState == null) { // TODO(b/311600838): Throw an exception instead once layers of disposed elements are not // run anymore. return lastValue() @@ -640,10 +607,10 @@ private inline fun <T> computeValue( // The element is shared: interpolate between the value in fromScene and the value in toScene. // TODO(b/290184746): Support non linear shared paths as well as a way to make sure that shared // elements follow the finger direction. - val isSharedElement = fromValues != null && toValues != null + val isSharedElement = fromState != null && toState != null if (isSharedElement && isSharedElementEnabled(layoutImpl.state, transition, element.key)) { - val start = sceneValue(fromValues!!) - val end = sceneValue(toValues!!) + val start = sceneValue(fromState!!) + val end = sceneValue(toState!!) // 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. @@ -659,12 +626,12 @@ private inline fun <T> computeValue( // Get the transformed value, i.e. the target value at the beginning (for entering elements) or // end (for leaving elements) of the transition. - val sceneValues = + val sceneState = checkNotNull( when { - isSharedElement && scene.key == fromScene -> fromValues - isSharedElement -> toValues - else -> fromValues ?: toValues + isSharedElement && scene.key == fromScene -> fromState + isSharedElement -> toState + else -> fromState ?: toState } ) @@ -673,7 +640,7 @@ private inline fun <T> computeValue( layoutImpl, scene, element, - sceneValues, + sceneState, transition, idleValue, ) diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt index 84d3b8647d6c..90f46bd4dcaa 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt @@ -64,10 +64,10 @@ class ElementKey( identity: Any = Object(), /** - * Whether this element is a background and usually drawn below other elements. This should be - * set to true to make sure that shared backgrounds are drawn below elements of other scenes. + * The [ElementScenePicker] to use when deciding in which scene we should draw shared Elements + * or compose MovableElements. */ - val isBackground: Boolean = false, + val scenePicker: ElementScenePicker = DefaultElementScenePicker, ) : Key(name, identity), ElementMatcher { @VisibleForTesting // TODO(b/240432457): Make internal once PlatformComposeSceneTransitionLayoutTestsUtils can diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt index 49df2f6b6062..af3c0999c97b 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt @@ -16,27 +16,36 @@ package com.android.compose.animation.scene -import android.util.Log import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Canvas -import androidx.compose.ui.graphics.drawscope.draw -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.IntSize -private const val TAG = "MovableElement" +@Composable +internal fun Element( + layoutImpl: SceneTransitionLayoutImpl, + scene: Scene, + key: ElementKey, + modifier: Modifier, + content: @Composable ElementScope<ElementContentScope>.() -> Unit, +) { + Box(modifier.element(layoutImpl, scene, key)) { + val sceneScope = scene.scope + val boxScope = this + val elementScope = + remember(layoutImpl, key, scene, sceneScope, boxScope) { + ElementScopeImpl(layoutImpl, key, scene, sceneScope, boxScope) + } + + content(elementScope) + } +} @Composable internal fun MovableElement( @@ -44,72 +53,113 @@ internal fun MovableElement( scene: Scene, key: ElementKey, modifier: Modifier, - content: @Composable MovableElementScope.() -> Unit, + content: @Composable ElementScope<MovableElementContentScope>.() -> Unit, ) { Box(modifier.element(layoutImpl, scene, key)) { - // Get the Element from the map. It will always be the same and we don't want to recompose - // every time an element is added/removed from SceneTransitionLayoutImpl.elements, so we - // disable read observation during the look-up in that map. - val element = Snapshot.withoutReadObservation { layoutImpl.elements.getValue(key) } - val movableElementScope = - remember(layoutImpl, element, scene) { - MovableElementScopeImpl(layoutImpl, element, scene) + val sceneScope = scene.scope + val boxScope = this + val elementScope = + remember(layoutImpl, key, scene, sceneScope, boxScope) { + MovableElementScopeImpl(layoutImpl, key, scene, sceneScope, boxScope) } - // The [Picture] to which we save the last drawing commands of this element. This is - // necessary because the content of this element might not be composed in this scene, in - // which case we still need to draw it. - val picture = element.picture + content(elementScope) + } +} + +private abstract class BaseElementScope<ContentScope>( + private val layoutImpl: SceneTransitionLayoutImpl, + private val element: ElementKey, + private val scene: Scene, +) : ElementScope<ContentScope> { + @Composable + override fun <T> animateElementValueAsState( + value: T, + key: ValueKey, + lerp: (start: T, stop: T, fraction: Float) -> T, + canOverflow: Boolean + ): AnimatedState<T> { + return animateSharedValueAsState( + layoutImpl, + scene.key, + element, + key, + value, + lerp, + canOverflow, + ) + } +} + +private class ElementScopeImpl( + layoutImpl: SceneTransitionLayoutImpl, + element: ElementKey, + scene: Scene, + private val sceneScope: SceneScope, + private val boxScope: BoxScope, +) : BaseElementScope<ElementContentScope>(layoutImpl, element, scene) { + private val contentScope = + object : ElementContentScope, SceneScope by sceneScope, BoxScope by boxScope {} + @Composable + override fun content(content: @Composable ElementContentScope.() -> Unit) { + contentScope.content() + } +} + +private class MovableElementScopeImpl( + private val layoutImpl: SceneTransitionLayoutImpl, + private val element: ElementKey, + private val scene: Scene, + private val sceneScope: BaseSceneScope, + private val boxScope: BoxScope, +) : BaseElementScope<MovableElementContentScope>(layoutImpl, element, scene) { + private val contentScope = + object : MovableElementContentScope, BaseSceneScope by sceneScope, BoxScope by boxScope {} + + @Composable + override fun content(content: @Composable MovableElementContentScope.() -> Unit) { // Whether we should compose the movable element here. The scene picker logic to know in // which scene we should compose/draw a movable element might depend on the current // transition progress, so we put this in a derivedStateOf to prevent many recompositions // during the transition. + // TODO(b/317026105): Use derivedStateOf only if the scene picker reads the progress in its + // logic. val shouldComposeMovableElement by remember(layoutImpl, scene.key, element) { derivedStateOf { shouldComposeMovableElement(layoutImpl, scene.key, element) } } if (shouldComposeMovableElement) { - Box( - Modifier.drawWithCache { - val width = size.width.toInt() - val height = size.height.toInt() - - onDrawWithContent { - // Save the draw commands into [picture] for later to draw the last content - // even when this movable content is not composed. - val pictureCanvas = Canvas(picture.beginRecording(width, height)) - draw(this, this.layoutDirection, pictureCanvas, this.size) { - this@onDrawWithContent.drawContent() + val movableContent: MovableElementContent = + layoutImpl.movableContents[element] + ?: movableContentOf { + contentScope: MovableElementContentScope, + content: @Composable MovableElementContentScope.() -> Unit -> + contentScope.content() } - picture.endRecording() - - // Draw the content. - drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) } - } - } - ) { - element.movableContent { movableElementScope.content() } - } + .also { layoutImpl.movableContents[element] = it } + + // Important: Don't introduce any parent Box or other layout here, because contentScope + // delegates its BoxScope implementation to the Box where this content() function is + // called, so it's important that this movableContent is composed directly under that + // Box. + movableContent(contentScope, content) } else { - // If we are not composed, we draw the previous drawing commands at the same size as the - // movable content when it was composed in this scene. - val sceneValues = element.sceneValues.getValue(scene.key) - - Spacer( - Modifier.layout { measurable, _ -> - val size = - sceneValues.targetSize.takeIf { it != Element.SizeUnspecified } - ?: IntSize.Zero - val placeable = - measurable.measure(Constraints.fixed(size.width, size.height)) - layout(size.width, size.height) { placeable.place(0, 0) } - } - .drawBehind { - drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) } - } - ) + // If we are not composed, we still need to lay out an empty space with the same *target + // size* as its movable content, i.e. the same *size when idle*. During transitions, + // this size will be used to interpolate the transition size, during the intermediate + // layout pass. + Layout { _, _ -> + // No need to measure or place anything. + val size = + placeholderContentSize( + layoutImpl, + scene.key, + layoutImpl.elements.getValue(element), + ) + layout(size.width, size.height) {} + } } } } @@ -117,7 +167,7 @@ internal fun MovableElement( private fun shouldComposeMovableElement( layoutImpl: SceneTransitionLayoutImpl, scene: SceneKey, - element: Element, + element: ElementKey, ): Boolean { val transition = layoutImpl.state.currentTransition @@ -130,72 +180,55 @@ private fun shouldComposeMovableElement( val fromReady = layoutImpl.isSceneReady(fromScene) val toReady = layoutImpl.isSceneReady(toScene) - val otherScene = - when (scene) { - fromScene -> toScene - toScene -> fromScene - else -> - error( - "shouldComposeMovableElement(scene=$scene) called with fromScene=$fromScene " + - "and toScene=$toScene" - ) - } - - val isShared = otherScene in element.sceneValues - - if (isShared && !toReady && !fromReady) { - // This should usually not happen given that fromScene should be ready, but let's log a - // warning here in case it does so it helps debugging flicker issues caused by this part of - // the code. - Log.w( - TAG, - "MovableElement $element might have to be composed for the first time in both " + - "fromScene=$fromScene and toScene=$toScene. This will probably lead to a flicker " + - "where the size of the element will jump from IntSize.Zero to its actual size " + - "during the transition." - ) - } - - // Element is not shared in this transition. - if (!isShared) { - return true - } - - // toScene is not ready (because we are composing it for the first time), so we compose it there - // first. This is the most common scenario when starting a transition that has a shared movable - // element. - if (!toReady) { + if (!fromReady && !toReady) { + // Neither of the scenes will be drawn, so where we compose it doesn't really matter. Note + // that we could have slightly more complicated logic here to optimize for this case, but + // it's not worth it given that readyScenes should disappear soon (b/316901148). return scene == toScene } - // This should usually not happen, but if we are also composing for the first time in fromScene - // then we should compose it there only. - if (!fromReady) { - return scene == fromScene - } + // If one of the scenes is not ready, compose it in the other one to make sure it is drawn. + if (!fromReady) return scene == toScene + if (!toReady) return scene == fromScene + // Always compose movable elements in the scene picked by their scene picker. return shouldDrawOrComposeSharedElement( layoutImpl, transition, scene, - element.key, - sharedElementTransformation(layoutImpl.state, transition, element.key), + element, ) } -private class MovableElementScopeImpl( - private val layoutImpl: SceneTransitionLayoutImpl, - private val element: Element, - private val scene: Scene, -) : MovableElementScope { - @Composable - override fun <T> animateSharedValueAsState( - value: T, - debugName: String, - lerp: (start: T, stop: T, fraction: Float) -> T, - canOverflow: Boolean, - ): State<T> { - val key = remember { ValueKey(debugName) } - return animateSharedValueAsState(layoutImpl, scene, element, key, value, lerp, canOverflow) +/** + * Return the size of the placeholder/space that is composed when the movable content is not + * composed in a scene. + */ +private fun placeholderContentSize( + layoutImpl: SceneTransitionLayoutImpl, + scene: SceneKey, + element: Element, +): IntSize { + // If the content of the movable element was already composed in this scene before, use that + // target size. + val targetValueInScene = element.sceneStates.getValue(scene).targetSize + if (targetValueInScene != Element.SizeUnspecified) { + return targetValueInScene } + + // This code is only run during transitions (otherwise the content would be composed and the + // placeholder would not), so it's ok to cast the state into a Transition directly. + val transition = layoutImpl.state.transitionState as TransitionState.Transition + + // If the content was already composed in the other scene, we use that target size assuming it + // doesn't change between scenes. + // TODO(b/317026105): Provide a way to give a hint size/content for cases where this is not + // true. + val otherScene = if (transition.fromScene == scene) transition.toScene else transition.fromScene + val targetValueInOtherScene = element.sceneStates[otherScene]?.targetSize + if (targetValueInOtherScene != null && targetValueInOtherScene != Element.SizeUnspecified) { + return targetValueInOtherScene + } + + return IntSize.Zero } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/PunchHole.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/PunchHole.kt index 560e92becba5..454c0ecf8ac5 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/PunchHole.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/PunchHole.kt @@ -75,8 +75,8 @@ private class PunchHoleNode( if ( bounds == null || - bounds.lastSharedValues.size == Element.SizeUnspecified || - bounds.lastSharedValues.offset == Offset.Unspecified + bounds.lastSharedState.size == Element.SizeUnspecified || + bounds.lastSharedState.offset == Offset.Unspecified ) { drawContent() return @@ -87,14 +87,14 @@ private class PunchHoleNode( canvas.withSaveLayer(size.toRect(), Paint()) { drawContent() - val offset = bounds.lastSharedValues.offset - element.lastSharedValues.offset + val offset = bounds.lastSharedState.offset - element.lastSharedState.offset translate(offset.x, offset.y) { drawHole(bounds) } } } } private fun DrawScope.drawHole(bounds: Element) { - val boundsSize = bounds.lastSharedValues.size.toSize() + val boundsSize = bounds.lastSharedState.size.toSize() if (shape == RectangleShape) { drawRect(Color.Black, size = boundsSize, blendMode = BlendMode.DstOut) return diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt index 30e50a972230..3537b7989ed5 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt @@ -20,13 +20,10 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape @@ -45,16 +42,13 @@ internal class Scene( actions: Map<UserAction, SceneKey>, zIndex: Float, ) { - private val scope = SceneScopeImpl(layoutImpl, this) + internal val scope = SceneScopeImpl(layoutImpl, this) var content by mutableStateOf(content) var userActions by mutableStateOf(actions) var zIndex by mutableFloatStateOf(zIndex) var targetSize by mutableStateOf(IntSize.Zero) - /** The shared values in this scene that are not tied to a specific element. */ - val sharedValues = SnapshotStateMap<ValueKey, Element.SharedValue<*>>() - @Composable @OptIn(ExperimentalComposeUiApi::class) fun Content(modifier: Modifier = Modifier) { @@ -77,7 +71,7 @@ internal class Scene( } } -private class SceneScopeImpl( +internal class SceneScopeImpl( private val layoutImpl: SceneTransitionLayoutImpl, private val scene: Scene, ) : SceneScope { @@ -87,6 +81,42 @@ private class SceneScopeImpl( return element(layoutImpl, scene, key) } + @Composable + override fun Element( + key: ElementKey, + modifier: Modifier, + content: @Composable (ElementScope<ElementContentScope>.() -> Unit) + ) { + Element(layoutImpl, scene, key, modifier, content) + } + + @Composable + override fun MovableElement( + key: ElementKey, + modifier: Modifier, + content: @Composable (ElementScope<MovableElementContentScope>.() -> Unit) + ) { + MovableElement(layoutImpl, scene, key, modifier, content) + } + + @Composable + override fun <T> animateSceneValueAsState( + value: T, + key: ValueKey, + lerp: (T, T, Float) -> T, + canOverflow: Boolean + ): AnimatedState<T> { + return animateSharedValueAsState( + layoutImpl = layoutImpl, + scene = scene.key, + element = null, + key = key, + value = value, + lerp = lerp, + canOverflow = canOverflow, + ) + } + override fun Modifier.horizontalNestedScrollToScene( leftBehavior: NestedScrollBehavior, rightBehavior: NestedScrollBehavior, @@ -109,45 +139,6 @@ private class SceneScopeImpl( bottomOrRightBehavior = bottomBehavior, ) - @Composable - override fun <T> animateSharedValueAsState( - value: T, - key: ValueKey, - element: ElementKey?, - lerp: (T, T, Float) -> T, - canOverflow: Boolean - ): State<T> { - val element = - element?.let { key -> - Snapshot.withoutReadObservation { - layoutImpl.elements[key] - ?: error( - "Element $key is not composed. Make sure to call " + - "animateSharedXAsState *after* Modifier.element(key)." - ) - } - } - - return animateSharedValueAsState( - layoutImpl, - scene, - element, - key, - value, - lerp, - canOverflow, - ) - } - - @Composable - override fun MovableElement( - key: ElementKey, - modifier: Modifier, - content: @Composable MovableElementScope.() -> Unit, - ) { - MovableElement(layoutImpl, scene, key, modifier, content) - } - override fun Modifier.punchHole( element: ElementKey, bounds: ElementKey, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt index 5eb339e4a5e4..84fade8937ff 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt @@ -22,9 +22,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.State import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -98,9 +98,9 @@ interface SceneTransitionLayoutScope { */ @DslMarker annotation class ElementDsl -@ElementDsl @Stable -interface SceneScope { +@ElementDsl +interface BaseSceneScope { /** The state of the [SceneTransitionLayout] in which this scene is contained. */ val layoutState: SceneTransitionLayoutState @@ -111,21 +111,74 @@ interface SceneScope { * that the element can be transformed and animated when the scene transitions in or out. * * Additionally, this [key] will be used to detect elements that are shared between scenes to - * automatically interpolate their size, offset and [shared values][animateSharedValueAsState]. + * automatically interpolate their size and offset. If you need to animate shared element values + * (i.e. values associated to this element that change depending on which scene it is composed + * in), use [Element] instead. * * Note that shared elements tagged using this function will be duplicated in each scene they * are part of, so any **internal** state (e.g. state created using `remember { * mutableStateOf(...) }`) will be lost. If you need to preserve internal state, you should use * [MovableElement] instead. * + * @see Element * @see MovableElement - * - * TODO(b/291566282): Migrate this to the new Modifier Node API and remove the @Composable - * constraint. */ fun Modifier.element(key: ElementKey): Modifier /** + * Create an element identified by [key]. + * + * Similar to [element], this creates an element that will be automatically shared when present + * in multiple scenes and that can be transformed during transitions, the same way that + * [element] does. + * + * The only difference with [element] is that the provided [ElementScope] allows you to + * [animate element values][ElementScope.animateElementValueAsState] or specify its + * [movable content][Element.movableContent] that will be "moved" and composed only once during + * transitions (as opposed to [element] that duplicates shared elements) so that any internal + * state is preserved during and after the transition. + * + * @see element + * @see MovableElement + */ + @Composable + fun Element( + key: ElementKey, + modifier: Modifier, + + // TODO(b/317026105): As discussed in http://shortn/_gJVdltF8Si, remove the @Composable + // scope here to make sure that callers specify the content in ElementScope.content {} or + // ElementScope.movableContent {}. + content: @Composable ElementScope<ElementContentScope>.() -> Unit, + ) + + /** + * Create a *movable* element identified by [key]. + * + * Similar to [Element], this creates an element that will be automatically shared when present + * in multiple scenes and that can be transformed during transitions, and you can also use the + * provided [ElementScope] to [animate element values][ElementScope.animateElementValueAsState]. + * + * The important difference with [element] and [Element] is that this element + * [content][ElementScope.content] will be "moved" and composed only once during transitions, as + * opposed to [element] and [Element] that duplicates shared elements, so that any internal + * state is preserved during and after the transition. + * + * @see element + * @see Element + */ + @Composable + fun MovableElement( + key: ElementKey, + modifier: Modifier, + + // TODO(b/317026105): As discussed in http://shortn/_gJVdltF8Si, remove the @Composable + // scope here to make sure that callers specify the content in ElementScope.content {} or + // ElementScope.movableContent {}. + content: @Composable ElementScope<MovableElementContentScope>.() -> Unit, + ) + + /** * Adds a [NestedScrollConnection] to intercept scroll events not handled by the scrollable * component. * @@ -150,82 +203,114 @@ interface SceneScope { ): Modifier /** - * Create a *movable* element identified by [key]. - * - * This creates an element that will be automatically shared when present in multiple scenes and - * that can be transformed during transitions, the same way that [element] does. The major - * difference with [element] is that elements created with [MovableElement] will be "moved" and - * composed only once during transitions (as opposed to [element] that duplicates shared - * elements) so that any internal state is preserved during and after the transition. + * Punch a hole in this [element] using the bounds of [bounds] in [scene] and the given [shape]. * - * @see element + * Punching a hole in an element will "remove" any pixel drawn by that element in the hole area. + * This can be used to make content drawn below an opaque element visible. For example, if we + * have [this lockscreen scene](http://shortn/_VYySFnJDhN) drawn below + * [this shade scene](http://shortn/_fpxGUk0Rg7) and punch a hole in the latter using the big + * clock time bounds and a RoundedCornerShape(10dp), [this](http://shortn/_qt80IvORFj) would be + * the result. */ - @Composable - fun MovableElement( - key: ElementKey, - modifier: Modifier, - content: @Composable MovableElementScope.() -> Unit, - ) + fun Modifier.punchHole(element: ElementKey, bounds: ElementKey, shape: Shape): Modifier + + /** + * Don't resize during transitions. This can for instance be used to make sure that scrollable + * lists keep a constant size during transitions even if its elements are growing/shrinking. + */ + fun Modifier.noResizeDuringTransitions(): Modifier +} +@Stable +@ElementDsl +interface SceneScope : BaseSceneScope { /** - * Animate some value of a shared element. + * Animate some value at the scene level. * * @param value the value of this shared value in the current scene. * @param key the key of this shared value. - * @param element the element associated with this value. If `null`, this value will be - * associated at the scene level, which means that [key] should be used maximum once in the - * same scene. * @param lerp the *linear* interpolation function that should be used to interpolate between * two different values. Note that it has to be linear because the [fraction] passed to this * interpolator is already interpolated. * @param canOverflow whether this value can overflow past the values it is interpolated * between, for instance because the transition is animated using a bouncy spring. - * @see animateSharedIntAsState - * @see animateSharedFloatAsState - * @see animateSharedDpAsState - * @see animateSharedColorAsState + * @see animateSceneIntAsState + * @see animateSceneFloatAsState + * @see animateSceneDpAsState + * @see animateSceneColorAsState */ @Composable - fun <T> animateSharedValueAsState( + fun <T> animateSceneValueAsState( value: T, key: ValueKey, - element: ElementKey?, lerp: (start: T, stop: T, fraction: Float) -> T, canOverflow: Boolean, - ): State<T> + ): AnimatedState<T> +} +@Stable +@ElementDsl +interface ElementScope<ContentScope> { /** - * Punch a hole in this [element] using the bounds of [bounds] in [scene] and the given [shape]. + * Animate some value associated to this element. * - * Punching a hole in an element will "remove" any pixel drawn by that element in the hole area. - * This can be used to make content drawn below an opaque element visible. For example, if we - * have [this lockscreen scene](http://shortn/_VYySFnJDhN) drawn below - * [this shade scene](http://shortn/_fpxGUk0Rg7) and punch a hole in the latter using the big - * clock time bounds and a RoundedCornerShape(10dp), [this](http://shortn/_qt80IvORFj) would be - * the result. + * @param value the value of this shared value in the current scene. + * @param key the key of this shared value. + * @param lerp the *linear* interpolation function that should be used to interpolate between + * two different values. Note that it has to be linear because the [fraction] passed to this + * interpolator is already interpolated. + * @param canOverflow whether this value can overflow past the values it is interpolated + * between, for instance because the transition is animated using a bouncy spring. + * @see animateElementIntAsState + * @see animateElementFloatAsState + * @see animateElementDpAsState + * @see animateElementColorAsState */ - fun Modifier.punchHole(element: ElementKey, bounds: ElementKey, shape: Shape): Modifier + @Composable + fun <T> animateElementValueAsState( + value: T, + key: ValueKey, + lerp: (start: T, stop: T, fraction: Float) -> T, + canOverflow: Boolean, + ): AnimatedState<T> /** - * Don't resize during transitions. This can for instance be used to make sure that scrollable - * lists keep a constant size during transitions even if its elements are growing/shrinking. + * The content of this element. + * + * Important: This must be called exactly once, after all calls to [animateElementValueAsState]. */ - fun Modifier.noResizeDuringTransitions(): Modifier + @Composable fun content(content: @Composable ContentScope.() -> Unit) } -// TODO(b/291053742): Add animateSharedValueAsState(targetValue) without any ValueKey and ElementKey -// arguments to allow sharing values inside a movable element. +/** + * The exact same scope as [androidx.compose.foundation.layout.BoxScope]. + * + * We can't reuse BoxScope directly because of the @LayoutScopeMarker annotation on it, which would + * prevent us from calling Modifier.element() and other methods of [SceneScope] inside any Box {} in + * the [content][ElementScope.content] of a [SceneScope.Element] or a [SceneScope.MovableElement]. + */ +@Stable @ElementDsl -interface MovableElementScope { - @Composable - fun <T> animateSharedValueAsState( - value: T, - debugName: String, - lerp: (start: T, stop: T, fraction: Float) -> T, - canOverflow: Boolean, - ): State<T> +interface ElementBoxScope { + /** @see [androidx.compose.foundation.layout.BoxScope.align]. */ + @Stable fun Modifier.align(alignment: Alignment): Modifier + + /** @see [androidx.compose.foundation.layout.BoxScope.matchParentSize]. */ + @Stable fun Modifier.matchParentSize(): Modifier } +/** The scope for "normal" (not movable) elements. */ +@Stable @ElementDsl interface ElementContentScope : SceneScope, ElementBoxScope + +/** + * The scope for the content of movable elements. + * + * Note that it extends [BaseSceneScope] and not [SceneScope] because movable elements should not + * call [SceneScope.animateSceneValueAsState], given that their content is not composed in all + * scenes. + */ +@Stable @ElementDsl interface MovableElementContentScope : BaseSceneScope, ElementBoxScope + /** An action performed by the user. */ sealed interface UserAction diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt index 45e1a0fa8f77..0227aba94b53 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt @@ -36,6 +36,16 @@ import androidx.compose.ui.util.fastForEach import com.android.compose.ui.util.lerp import kotlinx.coroutines.CoroutineScope +/** + * The type for the content of movable elements. + * + * TODO(b/317972419): Revert back to make this movable content have a single @Composable lambda + * parameter. + */ +internal typealias MovableElementContent = + @Composable + (MovableElementContentScope, @Composable MovableElementContentScope.() -> Unit) -> Unit + @Stable internal class SceneTransitionLayoutImpl( internal val state: SceneTransitionLayoutStateImpl, @@ -56,16 +66,47 @@ internal class SceneTransitionLayoutImpl( /** * The map of [Element]s. * - * Note that this map is *mutated* directly during composition, so it is a [SnapshotStateMap] to - * make sure that mutations are reverted if composition is cancelled. + * Important: [Element]s from this map should never be accessed during composition because the + * Elements are added when the associated Modifier.element() node is attached to the Modifier + * tree, i.e. after composition. */ - internal val elements = SnapshotStateMap<ElementKey, Element>() + internal val elements = mutableMapOf<ElementKey, Element>() + + /** + * The map of contents of movable elements. + * + * Note that given that this map is mutated directly during a composition, it has to be a + * [SnapshotStateMap] to make sure that mutations are reverted if composition is cancelled. + */ + private var _movableContents: SnapshotStateMap<ElementKey, MovableElementContent>? = null + val movableContents: SnapshotStateMap<ElementKey, MovableElementContent> + get() = + _movableContents + ?: SnapshotStateMap<ElementKey, MovableElementContent>().also { + _movableContents = it + } + + /** + * The different values of a shared value keyed by a a [ValueKey] and the different elements and + * scenes it is associated to. + */ + private var _sharedValues: + MutableMap<ValueKey, MutableMap<ElementKey?, SnapshotStateMap<SceneKey, *>>>? = + null + internal val sharedValues: + MutableMap<ValueKey, MutableMap<ElementKey?, SnapshotStateMap<SceneKey, *>>> + get() = + _sharedValues + ?: mutableMapOf<ValueKey, MutableMap<ElementKey?, SnapshotStateMap<SceneKey, *>>>() + .also { _sharedValues = it } /** * The scenes that are "ready", i.e. they were composed and fully laid-out at least once. * * Note that this map is *read* during composition, so it is a [SnapshotStateMap] to make sure * that we recompose when modifications are made to this map. + * + * TODO(b/316901148): Remove this map. */ private val readyScenes = SnapshotStateMap<SceneKey, Boolean>() diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt index d1ba582d6c23..0607aa148157 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt @@ -92,6 +92,20 @@ sealed interface TransitionState { /** Whether user input is currently driving the transition. */ abstract val isUserInputOngoing: Boolean + + /** + * Whether we are transitioning. If [from] or [to] is empty, we will also check that they + * match the scenes we are animating from and/or to. + */ + fun isTransitioning(from: SceneKey? = null, to: SceneKey? = null): Boolean { + return (from == null || fromScene == from) && (to == null || toScene == to) + } + + /** Whether we are transitioning from [scene] to [other], or from [other] to [scene]. */ + fun isTransitioningBetween(scene: SceneKey, other: SceneKey): Boolean { + return isTransitioning(from = scene, to = other) || + isTransitioning(from = other, to = scene) + } } } @@ -111,13 +125,12 @@ internal class SceneTransitionLayoutStateImpl( override fun isTransitioning(from: SceneKey?, to: SceneKey?): Boolean { val transition = currentTransition ?: return false - return (from == null || transition.fromScene == from) && - (to == null || transition.toScene == to) + return transition.isTransitioning(from, to) } override fun isTransitioningBetween(scene: SceneKey, other: SceneKey): Boolean { - return isTransitioning(from = scene, to = other) || - isTransitioning(from = other, to = scene) + val transition = currentTransition ?: return false + return transition.isTransitioningBetween(scene, other) } /** Start a new [transition], instantly interrupting any ongoing transition if there was one. */ diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt index dfa2a9a18e91..dc8505c43889 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt @@ -119,14 +119,8 @@ interface TransitionBuilder : PropertyTransformationBuilder { * * @param enabled whether the matched element(s) should actually be shared in this transition. * Defaults to true. - * @param scenePicker the [SharedElementScenePicker] to use when deciding in which scene we - * should draw or compose this shared element. */ - fun sharedElement( - matcher: ElementMatcher, - enabled: Boolean = true, - scenePicker: SharedElementScenePicker = DefaultSharedElementScenePicker, - ) + fun sharedElement(matcher: ElementMatcher, enabled: Boolean = true) /** * Adds the transformations in [builder] but in reversed order. This allows you to partially @@ -136,44 +130,132 @@ interface TransitionBuilder : PropertyTransformationBuilder { fun reversed(builder: TransitionBuilder.() -> Unit) } -interface SharedElementScenePicker { +/** + * An interface to decide where we should draw shared Elements or compose MovableElements. + * + * @see DefaultElementScenePicker + * @see HighestZIndexScenePicker + * @see LowestZIndexScenePicker + * @see MovableElementScenePicker + */ +interface ElementScenePicker { /** * Return the scene in which [element] should be drawn (when using `Modifier.element(key)`) or - * composed (when using `MovableElement(key)`) during the transition from [fromScene] to - * [toScene]. + * composed (when using `MovableElement(key)`) during the given [transition]. + * + * Important: For [MovableElements][SceneScope.MovableElement], this scene picker will *always* + * be used during transitions to decide whether we should compose that element in a given scene + * or not. Therefore, you should make sure that the returned [SceneKey] contains the movable + * element, otherwise that element will not be composed in any scene during the transition. */ fun sceneDuringTransition( element: ElementKey, - fromScene: SceneKey, - toScene: SceneKey, - progress: () -> Float, + transition: TransitionState.Transition, fromSceneZIndex: Float, toSceneZIndex: Float, ): SceneKey + + /** + * Return [transition.fromScene] if it is in [scenes] and [transition.toScene] is not, or return + * [transition.toScene] if it is in [scenes] and [transition.fromScene] is not, otherwise throw + * an exception (i.e. if neither or both of fromScene and toScene are in [scenes]). + * + * This function can be useful when computing the scene in which a movable element should be + * composed. + */ + fun pickSingleSceneIn( + scenes: Set<SceneKey>, + transition: TransitionState.Transition, + element: ElementKey, + ): SceneKey { + val fromScene = transition.fromScene + val toScene = transition.toScene + val fromSceneInScenes = scenes.contains(fromScene) + val toSceneInScenes = scenes.contains(toScene) + if (fromSceneInScenes && toSceneInScenes) { + error( + "Element $element can be in both $fromScene and $toScene. You should add a " + + "special case for this transition before calling pickSingleSceneIn()." + ) + } + + if (!fromSceneInScenes && !toSceneInScenes) { + error( + "Element $element can be neither in $fromScene and $toScene. This either means " + + "that you should add one of them in the scenes set passed to " + + "pickSingleSceneIn(), or there is an internal error and this element was " + + "composed when it shouldn't be." + ) + } + + return if (fromSceneInScenes) { + fromScene + } else { + toScene + } + } } -object DefaultSharedElementScenePicker : SharedElementScenePicker { +/** An [ElementScenePicker] that draws/composes elements in the scene with the highest z-order. */ +object HighestZIndexScenePicker : ElementScenePicker { override fun sceneDuringTransition( element: ElementKey, - fromScene: SceneKey, - toScene: SceneKey, - progress: () -> Float, + transition: TransitionState.Transition, fromSceneZIndex: Float, toSceneZIndex: Float ): SceneKey { - // By default shared elements are drawn in the highest scene possible, unless it is a - // background. - return if ( - (fromSceneZIndex > toSceneZIndex && !element.isBackground) || - (fromSceneZIndex < toSceneZIndex && element.isBackground) - ) { - fromScene + return if (fromSceneZIndex > toSceneZIndex) { + transition.fromScene } else { - toScene + transition.toScene } } } +/** An [ElementScenePicker] that draws/composes elements in the scene with the lowest z-order. */ +object LowestZIndexScenePicker : ElementScenePicker { + override fun sceneDuringTransition( + element: ElementKey, + transition: TransitionState.Transition, + fromSceneZIndex: Float, + toSceneZIndex: Float + ): SceneKey { + return if (fromSceneZIndex < toSceneZIndex) { + transition.fromScene + } else { + transition.toScene + } + } +} + +/** + * An [ElementScenePicker] that draws/composes elements in the scene we are transitioning to, iff + * that scene is in [scenes]. + * + * This picker can be useful for movable elements whose content size depends on its content (because + * it wraps it) in at least one scene. That way, the target size of the MovableElement will be + * computed in the scene we are going to and, given that this element was probably already composed + * in the scene we are going from before starting the transition, the interpolated size of the + * movable element during the transition should be correct. + * + * The downside of this picker is that the zIndex of the element when going from scene A to scene B + * is not the same as when going from scene B to scene A, so it's not usable in situations where + * z-ordering during the transition matters. + */ +class MovableElementScenePicker(private val scenes: Set<SceneKey>) : ElementScenePicker { + override fun sceneDuringTransition( + element: ElementKey, + transition: TransitionState.Transition, + fromSceneZIndex: Float, + toSceneZIndex: Float, + ): SceneKey { + return if (scenes.contains(transition.toScene)) transition.toScene else transition.fromScene + } +} + +/** The default [ElementScenePicker]. */ +val DefaultElementScenePicker = HighestZIndexScenePicker + @TransitionDsl interface PropertyTransformationBuilder { /** diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt index 70468669297c..b96f9bebb08b 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt @@ -108,12 +108,8 @@ internal class TransitionBuilderImpl : TransitionBuilder { range = null } - override fun sharedElement( - matcher: ElementMatcher, - enabled: Boolean, - scenePicker: SharedElementScenePicker, - ) { - transformations.add(SharedElementTransformation(matcher, enabled, scenePicker)) + override fun sharedElement(matcher: ElementMatcher, enabled: Boolean) { + transformations.add(SharedElementTransformation(matcher, enabled)) } override fun timestampRange( diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt index 40c814e0f25c..124ec290f42a 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt @@ -36,12 +36,12 @@ internal class AnchoredSize( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, transition: TransitionState.Transition, value: IntSize, ): IntSize { fun anchorSizeIn(scene: SceneKey): IntSize { - val size = layoutImpl.elements[anchor]?.sceneValues?.get(scene)?.targetSize + val size = layoutImpl.elements[anchor]?.sceneStates?.get(scene)?.targetSize return if (size != null && size != Element.SizeUnspecified) { IntSize( width = if (anchorWidth) size.width else value.width, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt index a1d63193bc73..7aa702b0bbd2 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt @@ -35,13 +35,13 @@ internal class AnchoredTranslate( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, transition: TransitionState.Transition, value: Offset, ): Offset { val anchor = layoutImpl.elements[anchor] ?: return value fun anchorOffsetIn(scene: SceneKey): Offset? { - return anchor.sceneValues[scene]?.targetOffset?.takeIf { it.isSpecified } + return anchor.sceneStates[scene]?.targetOffset?.takeIf { it.isSpecified } } // [element] will move the same amount as [anchor] does. diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt index d1cf8ee6ad4a..6704a3bbeff2 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt @@ -39,7 +39,7 @@ internal class DrawScale( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, transition: TransitionState.Transition, value: Scale, ): Scale { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt index 70534dde4f6f..191a8fbcd009 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt @@ -34,12 +34,12 @@ internal class EdgeTranslate( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, transition: TransitionState.Transition, value: Offset ): Offset { val sceneSize = scene.targetSize - val elementSize = sceneValues.targetSize + val elementSize = sceneState.targetSize if (elementSize == Element.SizeUnspecified) { return value } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt index 17032dc288e0..41f626e24e79 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt @@ -30,7 +30,7 @@ internal class Fade( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, transition: TransitionState.Transition, value: Float ): Float { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt index 233ae597090b..f5207dc4d345 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt @@ -37,7 +37,7 @@ internal class ScaleSize( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, transition: TransitionState.Transition, value: IntSize, ): IntSize { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt index 0cd11b9914c9..04254fbb588b 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt @@ -20,7 +20,6 @@ import com.android.compose.animation.scene.Element import com.android.compose.animation.scene.ElementMatcher import com.android.compose.animation.scene.Scene import com.android.compose.animation.scene.SceneTransitionLayoutImpl -import com.android.compose.animation.scene.SharedElementScenePicker import com.android.compose.animation.scene.TransitionState /** A transformation applied to one or more elements during a transition. */ @@ -48,7 +47,6 @@ sealed interface Transformation { internal class SharedElementTransformation( override val matcher: ElementMatcher, internal val enabled: Boolean, - internal val scenePicker: SharedElementScenePicker, ) : Transformation /** A transformation that changes the value of an element property, like its size or offset. */ @@ -62,7 +60,7 @@ internal sealed interface PropertyTransformation<T> : Transformation { layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, transition: TransitionState.Transition, value: T, ): T diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt index 864b937a3fe0..04d5914bff69 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt @@ -35,7 +35,7 @@ internal class Translate( layoutImpl: SceneTransitionLayoutImpl, scene: Scene, element: Element, - sceneValues: Element.TargetValues, + sceneState: Element.SceneState, transition: TransitionState.Transition, value: Offset, ): Offset { diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt index 5473186c14ec..a116501a298c 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt @@ -18,10 +18,11 @@ package com.android.compose.animation.scene import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp @@ -32,6 +33,7 @@ import androidx.compose.ui.unit.lerp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.compose.ui.util.lerp import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -62,17 +64,17 @@ class AnimatedSharedAsStateTest { onCurrentValueChanged: (Values) -> Unit, ) { val key = TestElements.Foo - Box(Modifier.element(key)) { - val int by animateSharedIntAsState(targetValues.int, TestValues.Value1, key) - val float by animateSharedFloatAsState(targetValues.float, TestValues.Value2, key) - val dp by animateSharedDpAsState(targetValues.dp, TestValues.Value3, key) - val color by - animateSharedColorAsState(targetValues.color, TestValues.Value4, element = null) + Element(key, Modifier) { + val int by animateElementIntAsState(targetValues.int, key = TestValues.Value1) + val float by animateElementFloatAsState(targetValues.float, key = TestValues.Value2) + val dp by animateElementDpAsState(targetValues.dp, key = TestValues.Value3) + val color by animateElementColorAsState(targetValues.color, key = TestValues.Value4) - // Make sure we read the values during composition, so that we recompose and call - // onCurrentValueChanged() with the latest values. - val currentValues = Values(int, float, dp, color) - SideEffect { onCurrentValueChanged(currentValues) } + content { + LaunchedEffect(Unit) { + snapshotFlow { Values(int, float, dp, color) }.collect(onCurrentValueChanged) + } + } } } @@ -83,30 +85,34 @@ class AnimatedSharedAsStateTest { ) { val key = TestElements.Foo MovableElement(key = key, Modifier) { - val int by - animateSharedIntAsState(targetValues.int, debugName = TestValues.Value1.debugName) - val float by - animateSharedFloatAsState( - targetValues.float, - debugName = TestValues.Value2.debugName - ) - val dp by - animateSharedDpAsState(targetValues.dp, debugName = TestValues.Value3.debugName) - val color by - animateSharedColorAsState( - targetValues.color, - debugName = TestValues.Value4.debugName - ) + val int by animateElementIntAsState(targetValues.int, key = TestValues.Value1) + val float by animateElementFloatAsState(targetValues.float, key = TestValues.Value2) + val dp by animateElementDpAsState(targetValues.dp, key = TestValues.Value3) + val color by animateElementColorAsState(targetValues.color, key = TestValues.Value4) + + LaunchedEffect(Unit) { + snapshotFlow { Values(int, float, dp, color) }.collect(onCurrentValueChanged) + } + } + } - // Make sure we read the values during composition, so that we recompose and call - // onCurrentValueChanged() with the latest values. - val currentValues = Values(int, float, dp, color) - SideEffect { onCurrentValueChanged(currentValues) } + @Composable + private fun SceneScope.SceneValues( + targetValues: Values, + onCurrentValueChanged: (Values) -> Unit, + ) { + val int by animateSceneIntAsState(targetValues.int, key = TestValues.Value1) + val float by animateSceneFloatAsState(targetValues.float, key = TestValues.Value2) + val dp by animateSceneDpAsState(targetValues.dp, key = TestValues.Value3) + val color by animateSceneColorAsState(targetValues.color, key = TestValues.Value4) + + LaunchedEffect(Unit) { + snapshotFlow { Values(int, float, dp, color) }.collect(onCurrentValueChanged) } } @Test - fun animateSharedValues() { + fun animateElementValues() { val fromValues = Values(int = 0, float = 0f, dp = 0.dp, color = Color.Red) val toValues = Values(int = 100, float = 100f, dp = 100.dp, color = Color.Blue) @@ -194,24 +200,183 @@ class AnimatedSharedAsStateTest { } at(16) { - // Given that we use MovableElement here, animateSharedXAsState is composed only - // once, in the highest scene (in this case, in toScene). - assertThat(lastValueInFrom).isEqualTo(fromValues) + assertThat(lastValueInFrom).isEqualTo(lerp(fromValues, toValues, fraction = 0.25f)) assertThat(lastValueInTo).isEqualTo(lerp(fromValues, toValues, fraction = 0.25f)) } at(32) { - assertThat(lastValueInFrom).isEqualTo(fromValues) + assertThat(lastValueInFrom).isEqualTo(lerp(fromValues, toValues, fraction = 0.5f)) assertThat(lastValueInTo).isEqualTo(lerp(fromValues, toValues, fraction = 0.5f)) } at(48) { - assertThat(lastValueInFrom).isEqualTo(fromValues) + assertThat(lastValueInFrom).isEqualTo(lerp(fromValues, toValues, fraction = 0.75f)) assertThat(lastValueInTo).isEqualTo(lerp(fromValues, toValues, fraction = 0.75f)) } after { + assertThat(lastValueInFrom).isEqualTo(toValues) + assertThat(lastValueInTo).isEqualTo(toValues) + } + } + } + + @Test + fun animateSceneValues() { + val fromValues = Values(int = 0, float = 0f, dp = 0.dp, color = Color.Red) + val toValues = Values(int = 100, float = 100f, dp = 100.dp, color = Color.Blue) + + var lastValueInFrom = fromValues + var lastValueInTo = toValues + + rule.testTransition( + fromSceneContent = { + SceneValues( + targetValues = fromValues, + onCurrentValueChanged = { lastValueInFrom = it } + ) + }, + toSceneContent = { + SceneValues(targetValues = toValues, onCurrentValueChanged = { lastValueInTo = it }) + }, + transition = { + // The transition lasts 64ms = 4 frames. + spec = tween(durationMillis = 16 * 4, easing = LinearEasing) + }, + fromScene = TestScenes.SceneA, + toScene = TestScenes.SceneB, + ) { + before { assertThat(lastValueInFrom).isEqualTo(fromValues) + + // to was not composed yet, so lastValueInTo was not set yet. + assertThat(lastValueInTo).isEqualTo(toValues) + } + + at(16) { + // Given that we use scene values here, animateSceneXAsState is composed in both + // scenes and values should be interpolated with the transition fraction. + val expectedValues = lerp(fromValues, toValues, fraction = 0.25f) + assertThat(lastValueInFrom).isEqualTo(expectedValues) + assertThat(lastValueInTo).isEqualTo(expectedValues) + } + + at(32) { + val expectedValues = lerp(fromValues, toValues, fraction = 0.5f) + assertThat(lastValueInFrom).isEqualTo(expectedValues) + assertThat(lastValueInTo).isEqualTo(expectedValues) + } + + at(48) { + val expectedValues = lerp(fromValues, toValues, fraction = 0.75f) + assertThat(lastValueInFrom).isEqualTo(expectedValues) + assertThat(lastValueInTo).isEqualTo(expectedValues) + } + + after { + assertThat(lastValueInFrom).isEqualTo(toValues) + assertThat(lastValueInTo).isEqualTo(toValues) + } + } + } + + @Test + fun readingAnimatedStateValueDuringCompositionThrows() { + assertThrows(IllegalStateException::class.java) { + rule.testTransition( + fromSceneContent = { animateSceneIntAsState(0, TestValues.Value1).value }, + toSceneContent = {}, + transition = {}, + ) {} + } + } + + @Test + fun readingAnimatedStateValueDuringCompositionIsStillPossible() { + @Composable + fun SceneScope.SceneValuesDuringComposition( + targetValues: Values, + onCurrentValueChanged: (Values) -> Unit, + ) { + val int by + animateSceneIntAsState(targetValues.int, key = TestValues.Value1) + .unsafeCompositionState(targetValues.int) + val float by + animateSceneFloatAsState(targetValues.float, key = TestValues.Value2) + .unsafeCompositionState(targetValues.float) + val dp by + animateSceneDpAsState(targetValues.dp, key = TestValues.Value3) + .unsafeCompositionState(targetValues.dp) + val color by + animateSceneColorAsState(targetValues.color, key = TestValues.Value4) + .unsafeCompositionState(targetValues.color) + + val values = Values(int, float, dp, color) + SideEffect { onCurrentValueChanged(values) } + } + + val fromValues = Values(int = 0, float = 0f, dp = 0.dp, color = Color.Red) + val toValues = Values(int = 100, float = 100f, dp = 100.dp, color = Color.Blue) + + var lastValueInFrom = fromValues + var lastValueInTo = toValues + + rule.testTransition( + fromSceneContent = { + SceneValuesDuringComposition( + targetValues = fromValues, + onCurrentValueChanged = { lastValueInFrom = it }, + ) + }, + toSceneContent = { + SceneValuesDuringComposition( + targetValues = toValues, + onCurrentValueChanged = { lastValueInTo = it }, + ) + }, + transition = { + // The transition lasts 64ms = 4 frames. + spec = tween(durationMillis = 16 * 4, easing = LinearEasing) + }, + ) { + before { + assertThat(lastValueInFrom).isEqualTo(fromValues) + + // to was not composed yet, so lastValueInTo was not set yet. + assertThat(lastValueInTo).isEqualTo(toValues) + } + + at(16) { + // Because we are using unsafeCompositionState(), values are one frame behind their + // expected progress so at this first frame we are at progress = 0% instead of 25%. + val expectedValues = lerp(fromValues, toValues, fraction = 0f) + assertThat(lastValueInFrom).isEqualTo(expectedValues) + assertThat(lastValueInTo).isEqualTo(expectedValues) + } + + at(32) { + // One frame behind, so 25% instead of 50%. + val expectedValues = lerp(fromValues, toValues, fraction = 0.25f) + assertThat(lastValueInFrom).isEqualTo(expectedValues) + assertThat(lastValueInTo).isEqualTo(expectedValues) + } + + at(48) { + // One frame behind, so 50% instead of 75%. + val expectedValues = lerp(fromValues, toValues, fraction = 0.5f) + assertThat(lastValueInFrom).isEqualTo(expectedValues) + assertThat(lastValueInTo).isEqualTo(expectedValues) + } + + after { + // from should have been last composed at progress = 100% before it is removed from + // composition, but given that we are one frame behind the last values are stuck at + // 75%. + assertThat(lastValueInFrom).isEqualTo(lerp(fromValues, toValues, fraction = 0.75f)) + + // The after {} block resumes the clock and will run as many frames as necessary so + // that the application is idle, so the toScene settle to the idle state and to the + // final values. assertThat(lastValueInTo).isEqualTo(toValues) } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementScenePickerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementScenePickerTest.kt new file mode 100644 index 000000000000..3b022e8adc72 --- /dev/null +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementScenePickerTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.compose.animation.scene + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ElementScenePickerTest { + @get:Rule val rule = createComposeRule() + + @Test + fun highestZIndexPicker() { + val key = ElementKey("TestElement", scenePicker = HighestZIndexScenePicker) + rule.testTransition( + fromSceneContent = { Box(Modifier.element(key).size(10.dp)) }, + toSceneContent = { Box(Modifier.element(key).size(10.dp)) }, + transition = { spec = tween(4 * 16, easing = LinearEasing) }, + fromScene = TestScenes.SceneA, + toScene = TestScenes.SceneB, + ) { + before { + onElement(key, TestScenes.SceneA).assertIsDisplayed() + onElement(key, TestScenes.SceneB).assertDoesNotExist() + } + at(32) { + // Scene B has the highest index, so the element is placed only there. + onElement(key, TestScenes.SceneA).assertExists().assertIsNotDisplayed() + onElement(key, TestScenes.SceneB).assertIsDisplayed() + } + after { + onElement(key, TestScenes.SceneA).assertDoesNotExist() + onElement(key, TestScenes.SceneB).assertIsDisplayed() + } + } + } + + @Test + fun lowestZIndexPicker() { + val key = ElementKey("TestElement", scenePicker = LowestZIndexScenePicker) + rule.testTransition( + fromSceneContent = { Box(Modifier.element(key).size(10.dp)) }, + toSceneContent = { Box(Modifier.element(key).size(10.dp)) }, + transition = { spec = tween(4 * 16, easing = LinearEasing) }, + fromScene = TestScenes.SceneA, + toScene = TestScenes.SceneB, + ) { + before { + onElement(key, TestScenes.SceneA).assertIsDisplayed() + onElement(key, TestScenes.SceneB).assertDoesNotExist() + } + at(32) { + // Scene A has the lowest index, so the element is placed only there. + onElement(key, TestScenes.SceneA).assertIsDisplayed() + onElement(key, TestScenes.SceneB).assertExists().assertIsNotDisplayed() + } + after { + onElement(key, TestScenes.SceneA).assertDoesNotExist() + onElement(key, TestScenes.SceneB).assertIsDisplayed() + } + } + } +} diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt index da5a0a04ed63..54c5de710f77 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt @@ -306,7 +306,7 @@ class ElementTest { assertThat(layoutImpl.elements.keys).containsExactly(key) val element = layoutImpl.elements.getValue(key) - assertThat(element.sceneValues.keys).containsExactly(TestScenes.SceneB) + assertThat(element.sceneStates.keys).containsExactly(TestScenes.SceneB) // Scene C, state 0: the same element is reused. currentScene = TestScenes.SceneC @@ -315,7 +315,7 @@ class ElementTest { assertThat(layoutImpl.elements.keys).containsExactly(key) assertThat(layoutImpl.elements.getValue(key)).isSameInstanceAs(element) - assertThat(element.sceneValues.keys).containsExactly(TestScenes.SceneC) + assertThat(element.sceneStates.keys).containsExactly(TestScenes.SceneC) // Scene C, state 1: the same element is reused. sceneCState = 1 @@ -323,7 +323,7 @@ class ElementTest { assertThat(layoutImpl.elements.keys).containsExactly(key) assertThat(layoutImpl.elements.getValue(key)).isSameInstanceAs(element) - assertThat(element.sceneValues.keys).containsExactly(TestScenes.SceneC) + assertThat(element.sceneStates.keys).containsExactly(TestScenes.SceneC) // Scene D, state 0: the same element is reused. currentScene = TestScenes.SceneD @@ -332,7 +332,7 @@ class ElementTest { assertThat(layoutImpl.elements.keys).containsExactly(key) assertThat(layoutImpl.elements.getValue(key)).isSameInstanceAs(element) - assertThat(element.sceneValues.keys).containsExactly(TestScenes.SceneD) + assertThat(element.sceneStates.keys).containsExactly(TestScenes.SceneD) // Scene D, state 1: the same element is reused. sceneDState = 1 @@ -340,13 +340,13 @@ class ElementTest { assertThat(layoutImpl.elements.keys).containsExactly(key) assertThat(layoutImpl.elements.getValue(key)).isSameInstanceAs(element) - assertThat(element.sceneValues.keys).containsExactly(TestScenes.SceneD) + assertThat(element.sceneStates.keys).containsExactly(TestScenes.SceneD) // Scene D, state 2: the element is removed from the map. sceneDState = 2 rule.waitForIdle() - assertThat(element.sceneValues).isEmpty() + assertThat(element.sceneStates).isEmpty() assertThat(layoutImpl.elements).isEmpty() } @@ -442,7 +442,7 @@ class ElementTest { // There is only Foo in the elements map. assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Foo) val fooElement = layoutImpl.elements.getValue(TestElements.Foo) - assertThat(fooElement.sceneValues.keys).containsExactly(TestScenes.SceneA) + assertThat(fooElement.sceneStates.keys).containsExactly(TestScenes.SceneA) key = TestElements.Bar @@ -450,8 +450,8 @@ class ElementTest { rule.waitForIdle() assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Bar) val barElement = layoutImpl.elements.getValue(TestElements.Bar) - assertThat(barElement.sceneValues.keys).containsExactly(TestScenes.SceneA) - assertThat(fooElement.sceneValues).isEmpty() + assertThat(barElement.sceneStates.keys).containsExactly(TestScenes.SceneA) + assertThat(fooElement.sceneStates).isEmpty() } @Test @@ -505,7 +505,7 @@ class ElementTest { // There is only Foo in the elements map. assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Foo) val element = layoutImpl.elements.getValue(TestElements.Foo) - val sceneValues = element.sceneValues + val sceneValues = element.sceneStates assertThat(sceneValues.keys).containsExactly(TestScenes.SceneA) // Get the ElementModifier node that should be reused later on when coming back to this @@ -528,7 +528,7 @@ class ElementTest { assertThat(layoutImpl.elements.keys).containsExactly(TestElements.Foo) val newElement = layoutImpl.elements.getValue(TestElements.Foo) - val newSceneValues = newElement.sceneValues + val newSceneValues = newElement.sceneStates assertThat(newElement).isNotEqualTo(element) assertThat(newSceneValues).isNotEqualTo(sceneValues) assertThat(newSceneValues.keys).containsExactly(TestScenes.SceneA) @@ -579,11 +579,11 @@ class ElementTest { fun foo() = layoutImpl().elements[TestElements.Foo] ?: error("Foo not in elements map") - fun Element.lastSharedOffset() = lastSharedValues.offset.toDpOffset() + fun Element.lastSharedOffset() = lastSharedState.offset.toDpOffset() fun Element.lastOffsetIn(scene: SceneKey) = - (sceneValues[scene] ?: error("$scene not in sceneValues map")) - .lastValues + (sceneStates[scene] ?: error("$scene not in sceneValues map")) + .lastState .offset .toDpOffset() diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementScenePickerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementScenePickerTest.kt new file mode 100644 index 000000000000..fb46a34e3cab --- /dev/null +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementScenePickerTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.compose.animation.scene + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MovableElementScenePickerTest { + @Test + fun toSceneInScenes() { + val picker = MovableElementScenePicker(scenes = setOf(TestScenes.SceneA, TestScenes.SceneB)) + assertThat( + picker.sceneDuringTransition( + TestElements.Foo, + transition(from = TestScenes.SceneA, to = TestScenes.SceneB), + fromSceneZIndex = 0f, + toSceneZIndex = 1f, + ) + ) + .isEqualTo(TestScenes.SceneB) + } + + @Test + fun toSceneNotInScenes() { + val picker = MovableElementScenePicker(scenes = emptySet()) + assertThat( + picker.sceneDuringTransition( + TestElements.Foo, + transition(from = TestScenes.SceneA, to = TestScenes.SceneB), + fromSceneZIndex = 0f, + toSceneZIndex = 1f, + ) + ) + .isEqualTo(TestScenes.SceneA) + } +} diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt index 3cd65cde274e..35cb691e6e37 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt @@ -28,19 +28,24 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertPositionInRootIsEqualTo import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.compose.test.assertSizeIsEqualTo import com.google.common.truth.Truth.assertThat +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -58,7 +63,7 @@ class MovableElementTest { @Composable private fun SceneScope.MovableCounter(key: ElementKey, modifier: Modifier) { - MovableElement(key, modifier) { Counter() } + MovableElement(key, modifier) { content { Counter() } } } @Test @@ -142,39 +147,37 @@ class MovableElementTest { @Test fun movableElementIsMovedAndComposedOnlyOnce() { - rule.testTransition( - fromSceneContent = { MovableCounter(TestElements.Foo, Modifier.size(50.dp)) }, - toSceneContent = { MovableCounter(TestElements.Foo, Modifier.size(100.dp)) }, - transition = { - spec = tween(durationMillis = 16 * 4, easing = LinearEasing) - sharedElement( - TestElements.Foo, - scenePicker = - object : SharedElementScenePicker { - override fun sceneDuringTransition( - element: ElementKey, - fromScene: SceneKey, - toScene: SceneKey, - progress: () -> Float, - fromSceneZIndex: Float, - toSceneZIndex: Float - ): SceneKey { - assertThat(fromScene).isEqualTo(TestScenes.SceneA) - assertThat(toScene).isEqualTo(TestScenes.SceneB) - assertThat(fromSceneZIndex).isEqualTo(0) - assertThat(toSceneZIndex).isEqualTo(1) + val key = + ElementKey( + "Foo", + scenePicker = + object : ElementScenePicker { + override fun sceneDuringTransition( + element: ElementKey, + transition: TransitionState.Transition, + fromSceneZIndex: Float, + toSceneZIndex: Float + ): SceneKey { + assertThat(transition.fromScene).isEqualTo(TestScenes.SceneA) + assertThat(transition.toScene).isEqualTo(TestScenes.SceneB) + assertThat(fromSceneZIndex).isEqualTo(0) + assertThat(toSceneZIndex).isEqualTo(1) - // Compose Foo in Scene A if progress < 0.65f, otherwise compose it - // in Scene B. - return if (progress() < 0.65f) { - TestScenes.SceneA - } else { - TestScenes.SceneB - } + // Compose Foo in Scene A if progress < 0.65f, otherwise compose it + // in Scene B. + return if (transition.progress < 0.65f) { + TestScenes.SceneA + } else { + TestScenes.SceneB } } - ) - }, + } + ) + + rule.testTransition( + fromSceneContent = { MovableCounter(key, Modifier.size(50.dp)) }, + toSceneContent = { MovableCounter(key, Modifier.size(100.dp)) }, + transition = { spec = tween(durationMillis = 16 * 4, easing = LinearEasing) }, fromScene = TestScenes.SceneA, toScene = TestScenes.SceneB, ) { @@ -257,4 +260,73 @@ class MovableElementTest { } } } + + @Test + @Ignore("b/317972419#comment2") + fun movableElementContentIsRecomposedIfContentParametersChange() { + @Composable + fun SceneScope.MovableFoo(text: String, modifier: Modifier = Modifier) { + MovableElement(TestElements.Foo, modifier) { content { Text(text) } } + } + + rule.testTransition( + fromSceneContent = { MovableFoo(text = "fromScene") }, + toSceneContent = { MovableFoo(text = "toScene") }, + transition = { spec = tween(durationMillis = 16 * 4, easing = LinearEasing) }, + fromScene = TestScenes.SceneA, + toScene = TestScenes.SceneB, + ) { + // Before the transition, only fromScene is composed. + before { + rule.onNodeWithText("fromScene").assertIsDisplayed() + rule.onNodeWithText("toScene").assertDoesNotExist() + } + + // During the transition, the element is composed in toScene. + at(32) { + rule.onNodeWithText("fromScene").assertDoesNotExist() + rule.onNodeWithText("toScene").assertIsDisplayed() + } + + // At the end of the transition, the element is composed in toScene. + after { + rule.onNodeWithText("fromScene").assertDoesNotExist() + rule.onNodeWithText("toScene").assertIsDisplayed() + } + } + } + + @Test + fun elementScopeExtendsBoxScope() { + rule.setContent { + TestSceneScope { + Element(TestElements.Foo, Modifier.size(200.dp)) { + content { + Box(Modifier.testTag("bottomEnd").align(Alignment.BottomEnd)) + Box(Modifier.testTag("matchParentSize").matchParentSize()) + } + } + } + } + + rule.onNodeWithTag("bottomEnd").assertPositionInRootIsEqualTo(200.dp, 200.dp) + rule.onNodeWithTag("matchParentSize").assertSizeIsEqualTo(200.dp, 200.dp) + } + + @Test + fun movableElementScopeExtendsBoxScope() { + rule.setContent { + TestSceneScope { + MovableElement(TestElements.Foo, Modifier.size(200.dp)) { + content { + Box(Modifier.testTag("bottomEnd").align(Alignment.BottomEnd)) + Box(Modifier.testTag("matchParentSize").matchParentSize()) + } + } + } + } + + rule.onNodeWithTag("bottomEnd").assertPositionInRootIsEqualTo(200.dp, 200.dp) + rule.onNodeWithTag("matchParentSize").assertSizeIsEqualTo(200.dp, 200.dp) + } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt index c5b8d9ae0d10..75dee47a91cd 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt @@ -50,13 +50,4 @@ class SceneTransitionLayoutStateTest { assertThat(state.isTransitioning(to = TestScenes.SceneA)).isFalse() assertThat(state.isTransitioning(from = TestScenes.SceneA, to = TestScenes.SceneB)).isTrue() } - - private fun transition(from: SceneKey, to: SceneKey): TransitionState.Transition { - return object : TransitionState.Transition(from, to) { - override val currentScene: SceneKey = from - override val progress: Float = 0f - override val isInitiatedByUserInput: Boolean = false - override val isUserInputOngoing: Boolean = false - } - } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt index ebbd5006be55..649e4991434e 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt @@ -113,25 +113,21 @@ class SceneTransitionLayoutTest { @Composable private fun SceneScope.SharedFoo(size: Dp, childOffset: Dp, modifier: Modifier = Modifier) { - Box( - modifier - .size(size) - .background(Color.Red) - .element(TestElements.Foo) - .testTag(TestElements.Foo.debugName) - ) { + Element(TestElements.Foo, modifier.size(size).background(Color.Red)) { // Offset the single child of Foo by some animated shared offset. - val offset by animateSharedDpAsState(childOffset, TestValues.Value1, TestElements.Foo) - - Box( - Modifier.offset { - val pxOffset = offset.roundToPx() - IntOffset(pxOffset, pxOffset) - } - .size(30.dp) - .background(Color.Blue) - .testTag(TestElements.Bar.debugName) - ) + val offset by animateElementDpAsState(childOffset, TestValues.Value1) + + content { + Box( + Modifier.offset { + val pxOffset = offset.roundToPx() + IntOffset(pxOffset, pxOffset) + } + .size(30.dp) + .background(Color.Blue) + .testTag(TestElements.Bar.debugName) + ) + } } } diff --git a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/Transition.kt b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/Transition.kt new file mode 100644 index 000000000000..238b21e1ea37 --- /dev/null +++ b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/Transition.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.compose.animation.scene + +/** A utility to easily create a [TransitionState.Transition] in tests. */ +fun transition( + from: SceneKey, + to: SceneKey, + progress: () -> Float = { 0f }, + isInitiatedByUserInput: Boolean = false, + isUserInputOngoing: Boolean = false, +): TransitionState.Transition { + return object : TransitionState.Transition(from, to) { + override val currentScene: SceneKey = from + override val progress: Float = progress() + override val isInitiatedByUserInput: Boolean = isInitiatedByUserInput + override val isUserInputOngoing: Boolean = isUserInputOngoing + } +} |