diff options
16 files changed, 650 insertions, 101 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateContent.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateContent.kt index 2bc8c87978a8..b16673702b49 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateContent.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateContent.kt @@ -26,15 +26,15 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch internal fun CoroutineScope.animateContent( + layoutState: MutableSceneTransitionLayoutStateImpl, transition: TransitionState.Transition, oneOffAnimation: OneOffAnimation, targetProgress: Float, - startTransition: () -> Unit, - finishTransition: () -> Unit, + chain: Boolean = true, ) { // Start the transition. This will compute the TransformationSpec associated to [transition], // which we need to initialize the Animatable that will actually animate it. - startTransition() + layoutState.startTransition(transition, chain) // The transition now contains the transformation spec that we should use to instantiate the // Animatable. @@ -59,7 +59,7 @@ internal fun CoroutineScope.animateContent( try { animatable.animateTo(targetProgress, animationSpec, initialVelocity) } finally { - finishTransition() + layoutState.finishTransition(transition) } } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateOverlay.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateOverlay.kt new file mode 100644 index 000000000000..e020f14a9a02 --- /dev/null +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateOverlay.kt @@ -0,0 +1,144 @@ +/* + * 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 com.android.compose.animation.scene.content.state.TransitionState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job + +/** Trigger a one-off transition to show or hide an overlay. */ +internal fun CoroutineScope.showOrHideOverlay( + layoutState: MutableSceneTransitionLayoutStateImpl, + overlay: OverlayKey, + fromOrToScene: SceneKey, + isShowing: Boolean, + transitionKey: TransitionKey?, + replacedTransition: TransitionState.Transition.ShowOrHideOverlay?, + reversed: Boolean, +): TransitionState.Transition.ShowOrHideOverlay { + val targetProgress = if (reversed) 0f else 1f + val (fromContent, toContent) = + if (isShowing xor reversed) { + fromOrToScene to overlay + } else { + overlay to fromOrToScene + } + + val oneOffAnimation = OneOffAnimation() + val transition = + OneOffShowOrHideOverlayTransition( + overlay = overlay, + fromOrToScene = fromOrToScene, + fromContent = fromContent, + toContent = toContent, + isEffectivelyShown = isShowing, + key = transitionKey, + replacedTransition = replacedTransition, + oneOffAnimation = oneOffAnimation, + ) + + animateContent( + layoutState = layoutState, + transition = transition, + oneOffAnimation = oneOffAnimation, + targetProgress = targetProgress, + ) + + return transition +} + +/** Trigger a one-off transition to replace an overlay by another one. */ +internal fun CoroutineScope.replaceOverlay( + layoutState: MutableSceneTransitionLayoutStateImpl, + fromOverlay: OverlayKey, + toOverlay: OverlayKey, + transitionKey: TransitionKey?, + replacedTransition: TransitionState.Transition.ReplaceOverlay?, + reversed: Boolean, +): TransitionState.Transition.ReplaceOverlay { + val targetProgress = if (reversed) 0f else 1f + val effectivelyShownOverlay = if (reversed) fromOverlay else toOverlay + + val oneOffAnimation = OneOffAnimation() + val transition = + OneOffOverlayReplacingTransition( + fromOverlay = fromOverlay, + toOverlay = toOverlay, + effectivelyShownOverlay = effectivelyShownOverlay, + key = transitionKey, + replacedTransition = replacedTransition, + oneOffAnimation = oneOffAnimation, + ) + + animateContent( + layoutState = layoutState, + transition = transition, + oneOffAnimation = oneOffAnimation, + targetProgress = targetProgress, + ) + + return transition +} + +private class OneOffShowOrHideOverlayTransition( + overlay: OverlayKey, + fromOrToScene: SceneKey, + fromContent: ContentKey, + toContent: ContentKey, + override val isEffectivelyShown: Boolean, + override val key: TransitionKey?, + replacedTransition: TransitionState.Transition?, + private val oneOffAnimation: OneOffAnimation, +) : + TransitionState.Transition.ShowOrHideOverlay( + overlay, + fromOrToScene, + fromContent, + toContent, + replacedTransition, + ) { + override val progress: Float + get() = oneOffAnimation.progress + + override val progressVelocity: Float + get() = oneOffAnimation.progressVelocity + + override val isInitiatedByUserInput: Boolean = false + override val isUserInputOngoing: Boolean = false + + override fun finish(): Job = oneOffAnimation.finish() +} + +private class OneOffOverlayReplacingTransition( + fromOverlay: OverlayKey, + toOverlay: OverlayKey, + override val effectivelyShownOverlay: OverlayKey, + override val key: TransitionKey?, + replacedTransition: TransitionState.Transition?, + private val oneOffAnimation: OneOffAnimation, +) : TransitionState.Transition.ReplaceOverlay(fromOverlay, toOverlay, replacedTransition) { + override val progress: Float + get() = oneOffAnimation.progress + + override val progressVelocity: Float + get() = oneOffAnimation.progressVelocity + + override val isInitiatedByUserInput: Boolean = false + override val isUserInputOngoing: Boolean = false + + override fun finish(): Job = oneOffAnimation.finish() +} 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 7eef5d63d7fd..4aa50b586c1b 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 @@ -412,7 +412,7 @@ private class AnimatedStateImpl<T, Delta>( if (canOverflow) transition.progress else transition.progress.fastCoerceIn(0f, 1f) } - overscrollSpec.scene == transition.toContent -> 1f + overscrollSpec.content == transition.toContent -> 1f else -> 0f } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt index 8aa069067347..abe079a4ab64 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt @@ -166,11 +166,11 @@ private fun CoroutineScope.animateToScene( } animateContent( + layoutState = layoutState, transition = transition, oneOffAnimation = oneOffAnimation, targetProgress = targetProgress, - startTransition = { layoutState.startTransition(transition, chain) }, - finishTransition = { layoutState.finishTransition(transition) }, + chain = chain, ) return transition 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 6ea0285742af..9b1740dc700a 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 @@ -310,9 +310,10 @@ internal class ElementNode( val transition = elementState as? TransitionState.Transition - // If this element is not supposed to be laid out now because the other content of its - // transition is overscrolling, then lay out the element normally and don't place it. - val overscrollScene = transition?.currentOverscrollSpec?.scene + // If this element is not supposed to be laid out now, either because it is not part of any + // ongoing transition or the other content of its transition is overscrolling, then lay out + // the element normally and don't place it. + val overscrollScene = transition?.currentOverscrollSpec?.content val isOtherSceneOverscrolling = overscrollScene != null && overscrollScene != content.key if (isOtherSceneOverscrolling) { return doNotPlace(measurable, constraints) @@ -845,7 +846,7 @@ internal fun shouldPlaceOrComposeSharedElement( transition: TransitionState.Transition, ): Boolean { // If we are overscrolling, only place/compose the element in the overscrolling scene. - val overscrollScene = transition.currentOverscrollSpec?.scene + val overscrollScene = transition.currentOverscrollSpec?.content if (overscrollScene != null) { return content == overscrollScene } @@ -1184,7 +1185,7 @@ private inline fun <T> computeValue( val currentContent = currentContentState.content if (transition is TransitionState.HasOverscrollProperties) { val overscroll = transition.currentOverscrollSpec - if (overscroll?.scene == currentContent) { + if (overscroll?.content == currentContent) { val elementSpec = overscroll.transformationSpec.transformations(element.key, currentContent) val propertySpec = transformation(elementSpec) ?: return currentValue() @@ -1210,7 +1211,7 @@ private inline fun <T> computeValue( // TODO(b/290184746): Make sure that we don't overflow transformations associated to a // range. val directionSign = if (transition.isUpOrLeft) -1 else 1 - val isToContent = overscroll.scene == transition.toContent + val isToContent = overscroll.content == transition.toContent val linearProgress = transition.progress.let { if (isToContent) it - 1f else it } val progressConverter = overscroll.progressConverter 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 21f11e4a4f9e..5f5141e1f153 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 @@ -432,7 +432,7 @@ private class LayoutNode(var layoutImpl: SceneTransitionLayoutImpl) : val progress = when { overscrollSpec == null -> transition.progress - overscrollSpec.scene == transition.toScene -> 1f + overscrollSpec.content == transition.toScene -> 1f else -> 0f } 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 74cd136b9911..0ac69124f7bc 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 @@ -81,12 +81,15 @@ sealed interface SceneTransitionLayoutState { /** * 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. + * the contents we are animating from and/or to. */ - fun isTransitioning(from: SceneKey? = null, to: SceneKey? = null): Boolean + fun isTransitioning(from: ContentKey? = null, to: ContentKey? = null): Boolean - /** Whether we are transitioning from [scene] to [other], or from [other] to [scene]. */ - fun isTransitioningBetween(scene: SceneKey, other: SceneKey): Boolean + /** Whether we are transitioning from [content] to [other], or from [other] to [content]. */ + fun isTransitioningBetween(content: ContentKey, other: ContentKey): Boolean + + /** Whether we are transitioning from or to [content]. */ + fun isTransitioningFromOrTo(content: ContentKey): Boolean } /** A [SceneTransitionLayoutState] whose target scene can be imperatively set. */ @@ -260,14 +263,19 @@ internal class MutableSceneTransitionLayoutStateImpl( } } - override fun isTransitioning(from: SceneKey?, to: SceneKey?): Boolean { + override fun isTransitioning(from: ContentKey?, to: ContentKey?): Boolean { val transition = currentTransition ?: return false return transition.isTransitioning(from, to) } - override fun isTransitioningBetween(scene: SceneKey, other: SceneKey): Boolean { + override fun isTransitioningBetween(content: ContentKey, other: ContentKey): Boolean { val transition = currentTransition ?: return false - return transition.isTransitioningBetween(scene, other) + return transition.isTransitioningBetween(content, other) + } + + override fun isTransitioningFromOrTo(content: ContentKey): Boolean { + val transition = currentTransition ?: return false + return transition.isTransitioningFromOrTo(content) } override fun setTargetScene( @@ -293,10 +301,7 @@ internal class MutableSceneTransitionLayoutStateImpl( * * Important: you *must* call [finishTransition] once the transition is finished. */ - internal fun startTransition( - transition: TransitionState.Transition.ChangeCurrentScene, - chain: Boolean = true, - ) { + internal fun startTransition(transition: TransitionState.Transition, chain: Boolean = true) { checkThread() // Set the current scene and overlays on the transition. @@ -305,23 +310,23 @@ internal class MutableSceneTransitionLayoutStateImpl( transition.currentOverlaysWhenTransitionStarted = currentState.currentOverlays // Compute the [TransformationSpec] when the transition starts. - val fromScene = transition.fromScene - val toScene = transition.toScene + val fromContent = transition.fromContent + val toContent = transition.toContent val orientation = (transition as? TransitionState.HasOverscrollProperties)?.orientation // Update the transition specs. transition.transformationSpec = transitions - .transitionSpec(fromScene, toScene, key = transition.key) + .transitionSpec(fromContent, toContent, key = transition.key) .transformationSpec() transition.previewTransformationSpec = transitions - .transitionSpec(fromScene, toScene, key = transition.key) + .transitionSpec(fromContent, toContent, key = transition.key) .previewTransformationSpec() if (orientation != null) { transition.updateOverscrollSpecs( - fromSpec = transitions.overscrollSpec(fromScene, orientation), - toSpec = transitions.overscrollSpec(toScene, orientation), + fromSpec = transitions.overscrollSpec(fromContent, orientation), + toSpec = transitions.overscrollSpec(toContent, orientation), ) } else { transition.updateOverscrollSpecs(fromSpec = null, toSpec = null) @@ -402,32 +407,27 @@ internal class MutableSceneTransitionLayoutStateImpl( } private fun setupTransitionLinks(transition: TransitionState.Transition) { - when (transition) { - is TransitionState.Transition.ChangeCurrentScene -> { - stateLinks.fastForEach { stateLink -> - val matchingLinks = - stateLink.transitionLinks.fastFilter { it.isMatchingLink(transition) } - if (matchingLinks.isEmpty()) return@fastForEach - if (matchingLinks.size > 1) error("More than one link matched.") - - val targetCurrentScene = stateLink.target.transitionState.currentScene - val matchingLink = matchingLinks[0] - - if (!matchingLink.targetIsInValidState(targetCurrentScene)) return@fastForEach - - val linkedTransition = - LinkedTransition( - originalTransition = transition, - fromScene = targetCurrentScene, - toScene = matchingLink.targetTo, - key = matchingLink.targetTransitionKey, - ) - - stateLink.target.startTransition(linkedTransition) - transition.activeTransitionLinks[stateLink] = linkedTransition - } - } - else -> error("transition links are not supported with overlays yet") + stateLinks.fastForEach { stateLink -> + val matchingLinks = + stateLink.transitionLinks.fastFilter { it.isMatchingLink(transition) } + if (matchingLinks.isEmpty()) return@fastForEach + if (matchingLinks.size > 1) error("More than one link matched.") + + val targetCurrentScene = stateLink.target.transitionState.currentScene + val matchingLink = matchingLinks[0] + + if (!matchingLink.targetIsInValidState(targetCurrentScene)) return@fastForEach + + val linkedTransition = + LinkedTransition( + originalTransition = transition, + fromScene = targetCurrentScene, + toScene = matchingLink.targetTo, + key = matchingLink.targetTransitionKey, + ) + + stateLink.target.startTransition(linkedTransition) + transition.activeTransitionLinks[stateLink] = linkedTransition } } @@ -552,12 +552,39 @@ internal class MutableSceneTransitionLayoutStateImpl( checkThread() // Overlay is already shown, do nothing. - if (overlay in transitionState.currentOverlays) { + val currentState = transitionState + if (overlay in currentState.currentOverlays) { return } - // TODO(b/353679003): Animate the overlay instead of instantly snapping to an Idle state. - snapToScene(transitionState.currentScene, transitionState.currentOverlays + overlay) + val fromScene = currentState.currentScene + fun animate( + replacedTransition: TransitionState.Transition.ShowOrHideOverlay? = null, + reversed: Boolean = false, + ) { + animationScope.showOrHideOverlay( + layoutState = this@MutableSceneTransitionLayoutStateImpl, + overlay = overlay, + fromOrToScene = fromScene, + isShowing = true, + transitionKey = transitionKey, + replacedTransition = replacedTransition, + reversed = reversed, + ) + } + + if ( + currentState is TransitionState.Transition.ShowOrHideOverlay && + currentState.overlay == overlay && + currentState.fromOrToScene == fromScene + ) { + animate( + replacedTransition = currentState, + reversed = overlay == currentState.fromContent + ) + } else { + animate() + } } override fun hideOverlay( @@ -568,12 +595,36 @@ internal class MutableSceneTransitionLayoutStateImpl( checkThread() // Overlay is not shown, do nothing. - if (!transitionState.currentOverlays.contains(overlay)) { + val currentState = transitionState + if (!currentState.currentOverlays.contains(overlay)) { return } - // TODO(b/353679003): Animate the overlay instead of instantly snapping to an Idle state. - snapToScene(transitionState.currentScene, transitionState.currentOverlays - overlay) + val toScene = currentState.currentScene + fun animate( + replacedTransition: TransitionState.Transition.ShowOrHideOverlay? = null, + reversed: Boolean = false, + ) { + animationScope.showOrHideOverlay( + layoutState = this@MutableSceneTransitionLayoutStateImpl, + overlay = overlay, + fromOrToScene = toScene, + isShowing = false, + transitionKey = transitionKey, + replacedTransition = replacedTransition, + reversed = reversed, + ) + } + + if ( + currentState is TransitionState.Transition.ShowOrHideOverlay && + currentState.overlay == overlay && + currentState.fromOrToScene == toScene + ) { + animate(replacedTransition = currentState, reversed = overlay == currentState.toContent) + } else { + animate() + } } override fun replaceOverlay( @@ -583,16 +634,45 @@ internal class MutableSceneTransitionLayoutStateImpl( transitionKey: TransitionKey? ) { checkThread() - require(from in currentOverlays) { + + val currentState = transitionState + require(from != to) { + "replaceOverlay must be called with different overlays (from = to = ${from.debugName})" + } + require(from in currentState.currentOverlays) { "Overlay ${from.debugName} is not shown so it can't be replaced by ${to.debugName}" } - require(to !in currentOverlays) { + require(to !in currentState.currentOverlays) { "Overlay ${to.debugName} is already shown so it can't replace ${from.debugName}" } - // TODO(b/353679003): Animate from into to instead of hiding/showing the overlays - // separately. - snapToScene(transitionState.currentScene, transitionState.currentOverlays - from + to) + fun animate( + replacedTransition: TransitionState.Transition.ReplaceOverlay? = null, + reversed: Boolean = false, + ) { + animationScope.replaceOverlay( + layoutState = this@MutableSceneTransitionLayoutStateImpl, + fromOverlay = if (reversed) to else from, + toOverlay = if (reversed) from else to, + transitionKey = transitionKey, + replacedTransition = replacedTransition, + reversed = reversed, + ) + } + + if (currentState is TransitionState.Transition.ReplaceOverlay) { + if (currentState.fromOverlay == from && currentState.toOverlay == to) { + animate(replacedTransition = currentState, reversed = false) + return + } + + if (currentState.fromOverlay == to && currentState.toOverlay == from) { + animate(replacedTransition = currentState, reversed = true) + return + } + } + + animate() } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt index d35d95685d22..cefcff75f13a 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt @@ -49,16 +49,16 @@ internal constructor( ) { private val transitionCache = mutableMapOf< - SceneKey, - MutableMap<SceneKey, MutableMap<TransitionKey?, TransitionSpecImpl>> + ContentKey, + MutableMap<ContentKey, MutableMap<TransitionKey?, TransitionSpecImpl>> >() private val overscrollCache = - mutableMapOf<SceneKey, MutableMap<Orientation, OverscrollSpecImpl?>>() + mutableMapOf<ContentKey, MutableMap<Orientation, OverscrollSpecImpl?>>() internal fun transitionSpec( - from: SceneKey, - to: SceneKey, + from: ContentKey, + to: ContentKey, key: TransitionKey?, ): TransitionSpecImpl { return transitionCache @@ -67,7 +67,11 @@ internal constructor( .getOrPut(key) { findSpec(from, to, key) } } - private fun findSpec(from: SceneKey, to: SceneKey, key: TransitionKey?): TransitionSpecImpl { + private fun findSpec( + from: ContentKey, + to: ContentKey, + key: TransitionKey? + ): TransitionSpecImpl { val spec = transition(from, to, key) { it.from == from && it.to == to } if (spec != null) { return spec @@ -93,8 +97,8 @@ internal constructor( } private fun transition( - from: SceneKey, - to: SceneKey, + from: ContentKey, + to: ContentKey, key: TransitionKey?, filter: (TransitionSpecImpl) -> Boolean, ): TransitionSpecImpl? { @@ -110,16 +114,16 @@ internal constructor( return match } - private fun defaultTransition(from: SceneKey, to: SceneKey) = + private fun defaultTransition(from: ContentKey, to: ContentKey) = TransitionSpecImpl(key = null, from, to, null, null, TransformationSpec.EmptyProvider) - internal fun overscrollSpec(scene: SceneKey, orientation: Orientation): OverscrollSpecImpl? = + internal fun overscrollSpec(scene: ContentKey, orientation: Orientation): OverscrollSpecImpl? = overscrollCache .getOrPut(scene) { mutableMapOf() } - .getOrPut(orientation) { overscroll(scene, orientation) { it.scene == scene } } + .getOrPut(orientation) { overscroll(scene, orientation) { it.content == scene } } private fun overscroll( - scene: SceneKey, + scene: ContentKey, orientation: Orientation, filter: (OverscrollSpecImpl) -> Boolean, ): OverscrollSpecImpl? { @@ -264,10 +268,10 @@ internal class TransitionSpecImpl( previewTransformationSpec?.invoke() } -/** The definition of the overscroll behavior of the [scene]. */ +/** The definition of the overscroll behavior of the [content]. */ interface OverscrollSpec { /** The scene we are over scrolling. */ - val scene: SceneKey + val content: ContentKey /** The orientation of this [OverscrollSpec]. */ val orientation: Orientation @@ -288,7 +292,7 @@ interface OverscrollSpec { } internal class OverscrollSpecImpl( - override val scene: SceneKey, + override val content: ContentKey, override val orientation: Orientation, override val transformationSpec: TransformationSpecImpl, override val progressConverter: ProgressConverter?, 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 523e5bdd7203..18e356f71768 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 @@ -107,7 +107,7 @@ private class SceneTransitionsBuilderImpl : SceneTransitionsBuilder { ): OverscrollSpec { val spec = OverscrollSpecImpl( - scene = scene, + content = scene, orientation = orientation, transformationSpec = TransformationSpecImpl( diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt index 6f608cbc1d7f..6bc1754150fe 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt @@ -96,6 +96,8 @@ internal sealed class Content( .approachLayout( isMeasurementApproachInProgress = { layoutImpl.state.isTransitioning() } ) { measurable, constraints -> + // TODO(b/353679003): Use the ModifierNode API to set this *before* the approach + // pass is started. targetSize = lookaheadSize val placeable = measurable.measure(constraints) layout(placeable.width, placeable.height) { placeable.place(0, 0) } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt index 89b004046475..59ddb1354073 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.Job /** A linked transition which is driven by a [originalTransition]. */ internal class LinkedTransition( - private val originalTransition: TransitionState.Transition.ChangeCurrentScene, + private val originalTransition: TransitionState.Transition, fromScene: SceneKey, toScene: SceneKey, override val key: TransitionKey? = null, @@ -32,8 +32,8 @@ internal class LinkedTransition( override val currentScene: SceneKey get() { return when (originalTransition.currentScene) { - originalTransition.fromScene -> fromScene - originalTransition.toScene -> toScene + originalTransition.fromContent -> fromScene + originalTransition.toContent -> toScene else -> error("Original currentScene is neither FromScene nor ToScene") } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/StateLink.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/StateLink.kt index c29bf212ec9c..c830ca4fa7c0 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/StateLink.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/StateLink.kt @@ -16,6 +16,7 @@ package com.android.compose.animation.scene.transition.link +import com.android.compose.animation.scene.ContentKey import com.android.compose.animation.scene.MutableSceneTransitionLayoutStateImpl import com.android.compose.animation.scene.SceneKey import com.android.compose.animation.scene.SceneTransitionLayoutState @@ -35,8 +36,8 @@ class StateLink(target: SceneTransitionLayoutState, val transitionLinks: List<Tr * target to `SceneA` from any current scene. */ class TransitionLink( - val sourceFrom: SceneKey?, - val sourceTo: SceneKey?, + val sourceFrom: ContentKey?, + val sourceTo: ContentKey?, val targetFrom: SceneKey?, val targetTo: SceneKey, val targetTransitionKey: TransitionKey? = null, @@ -50,15 +51,15 @@ class StateLink(target: SceneTransitionLayoutState, val transitionLinks: List<Tr } internal fun isMatchingLink( - transition: TransitionState.Transition.ChangeCurrentScene, + transition: TransitionState.Transition, ): Boolean { - return (sourceFrom == null || sourceFrom == transition.fromScene) && - (sourceTo == null || sourceTo == transition.toScene) + return (sourceFrom == null || sourceFrom == transition.fromContent) && + (sourceTo == null || sourceTo == transition.toContent) } - internal fun targetIsInValidState(targetCurrentScene: SceneKey): Boolean { - return (targetFrom == null || targetFrom == targetCurrentScene) && - targetTo != targetCurrentScene + internal fun targetIsInValidState(targetCurrentContent: ContentKey): Boolean { + return (targetFrom == null || targetFrom == targetCurrentContent) && + targetTo != targetCurrentContent } } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt index 25be3f97a2c4..7d8e898e9ab2 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt @@ -1020,7 +1020,7 @@ class DraggableHandlerTest { // We scrolled down, under scene C there is nothing, so we can use the overscroll spec assertThat(layoutState.currentTransition?.currentOverscrollSpec).isNotNull() - assertThat(layoutState.currentTransition?.currentOverscrollSpec?.scene).isEqualTo(SceneC) + assertThat(layoutState.currentTransition?.currentOverscrollSpec?.content).isEqualTo(SceneC) val transition = layoutState.currentTransition assertThat(transition).isNotNull() assertThat(transition!!.progress).isEqualTo(-0.1f) diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt index d4391e09fc3d..85db418f6020 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt @@ -16,6 +16,8 @@ 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.fillMaxSize import androidx.compose.foundation.layout.size @@ -33,6 +35,7 @@ import androidx.compose.ui.test.assertPositionInRootIsEqualTo import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.compose.animation.scene.TestOverlays.OverlayA @@ -49,8 +52,8 @@ class OverlayTest { @get:Rule val rule = createComposeRule() @Composable - private fun ContentScope.Foo() { - Box(Modifier.element(TestElements.Foo).size(100.dp)) + private fun ContentScope.Foo(width: Dp = 100.dp, height: Dp = 100.dp) { + Box(Modifier.element(TestElements.Foo).size(width, height)) } @Test @@ -316,4 +319,209 @@ class OverlayTest { // of the layout. rule.onNodeWithTag(contentTag).assertSizeIsEqualTo(100.dp) } + + @Test + fun showAnimation() { + rule.testShowOverlayTransition( + fromSceneContent = { + Box(Modifier.size(width = 180.dp, height = 120.dp)) { + Foo(width = 60.dp, height = 40.dp) + } + }, + overlayContent = { Foo(width = 100.dp, height = 80.dp) }, + transition = { + // 4 frames of animation + spec = tween(4 * 16, easing = LinearEasing) + }, + ) { + // Foo moves from (0,0) with a size of 60x40dp to centered (in a 180x120dp Box) with a + // size of 100x80dp, so at (40,20). + before { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertSizeIsEqualTo(60.dp, 40.dp) + .assertPositionInRootIsEqualTo(0.dp, 0.dp) + rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist() + } + + at(16) { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(70.dp, 50.dp) + .assertPositionInRootIsEqualTo(10.dp, 5.dp) + } + + at(32) { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(80.dp, 60.dp) + .assertPositionInRootIsEqualTo(20.dp, 10.dp) + } + + at(48) { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(90.dp, 70.dp) + .assertPositionInRootIsEqualTo(30.dp, 15.dp) + } + + after { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(100.dp, 80.dp) + .assertPositionInRootIsEqualTo(40.dp, 20.dp) + } + } + } + + @Test + fun hideAnimation() { + rule.testHideOverlayTransition( + toSceneContent = { + Box(Modifier.size(width = 180.dp, height = 120.dp)) { + Foo(width = 60.dp, height = 40.dp) + } + }, + overlayContent = { Foo(width = 100.dp, height = 80.dp) }, + transition = { + // 4 frames of animation + spec = tween(4 * 16, easing = LinearEasing) + }, + ) { + // Foo moves from centered (in a 180x120dp Box) with a size of 100x80dp, so at (40,20), + // to (0,0) with a size of 60x40dp. + before { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(100.dp, 80.dp) + .assertPositionInRootIsEqualTo(40.dp, 20.dp) + } + + at(16) { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(90.dp, 70.dp) + .assertPositionInRootIsEqualTo(30.dp, 15.dp) + } + + at(32) { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(80.dp, 60.dp) + .assertPositionInRootIsEqualTo(20.dp, 10.dp) + } + + at(48) { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(70.dp, 50.dp) + .assertPositionInRootIsEqualTo(10.dp, 5.dp) + } + + after { + rule + .onNode(isElement(TestElements.Foo, content = SceneA)) + .assertSizeIsEqualTo(60.dp, 40.dp) + .assertPositionInRootIsEqualTo(0.dp, 0.dp) + rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist() + } + } + } + + @Test + fun replaceAnimation() { + rule.testReplaceOverlayTransition( + currentSceneContent = { Box(Modifier.size(width = 180.dp, height = 120.dp)) }, + fromContent = { Foo(width = 60.dp, height = 40.dp) }, + fromAlignment = Alignment.TopStart, + toContent = { Foo(width = 100.dp, height = 80.dp) }, + transition = { + // 4 frames of animation + spec = tween(4 * 16, easing = LinearEasing) + }, + ) { + // Foo moves from (0,0) with a size of 60x40dp to centered (in a 180x120dp Box) with a + // size of 100x80dp, so at (40,20). + before { + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertSizeIsEqualTo(60.dp, 40.dp) + .assertPositionInRootIsEqualTo(0.dp, 0.dp) + rule.onNode(isElement(TestElements.Foo, content = OverlayB)).assertDoesNotExist() + } + + at(16) { + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayB)) + .assertSizeIsEqualTo(70.dp, 50.dp) + .assertPositionInRootIsEqualTo(10.dp, 5.dp) + } + + at(32) { + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayB)) + .assertSizeIsEqualTo(80.dp, 60.dp) + .assertPositionInRootIsEqualTo(20.dp, 10.dp) + } + + at(48) { + rule + .onNode(isElement(TestElements.Foo, content = OverlayA)) + .assertExists() + .assertIsNotDisplayed() + rule + .onNode(isElement(TestElements.Foo, content = OverlayB)) + .assertSizeIsEqualTo(90.dp, 70.dp) + .assertPositionInRootIsEqualTo(30.dp, 15.dp) + } + + after { + rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist() + rule + .onNode(isElement(TestElements.Foo, content = OverlayB)) + .assertSizeIsEqualTo(100.dp, 80.dp) + .assertPositionInRootIsEqualTo(40.dp, 20.dp) + } + } + } } 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 3422a8e47a3d..69f2cbace276 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 @@ -495,7 +495,7 @@ class SceneTransitionLayoutStateTest { // overscroll for SceneB is defined progress.value = 1.1f val overscrollSpec = assertThat(transition).hasOverscrollSpec() - assertThat(overscrollSpec.scene).isEqualTo(SceneB) + assertThat(overscrollSpec.content).isEqualTo(SceneB) } @Test @@ -516,7 +516,7 @@ class SceneTransitionLayoutStateTest { // overscroll for SceneA is defined progress.value = -0.1f val overscrollSpec = assertThat(transition).hasOverscrollSpec() - assertThat(overscrollSpec.scene).isEqualTo(SceneA) + assertThat(overscrollSpec.content).isEqualTo(SceneA) // scroll from SceneA to SceneB progress.value = 0.5f diff --git a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt index 7f26b9855a9a..1ebd3d98471b 100644 --- a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt +++ b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt @@ -16,9 +16,12 @@ package com.android.compose.animation.scene +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.test.SemanticsNodeInteraction @@ -115,6 +118,97 @@ fun ComposeContentTestRule.testTransition( ) } +/** Test the transition when showing [overlay] from [fromScene]. */ +fun ComposeContentTestRule.testShowOverlayTransition( + fromSceneContent: @Composable ContentScope.() -> Unit, + overlayContent: @Composable ContentScope.() -> Unit, + transition: TransitionBuilder.() -> Unit, + fromScene: SceneKey = TestScenes.SceneA, + overlay: OverlayKey = TestOverlays.OverlayA, + builder: TransitionTestBuilder.() -> Unit, +) { + testTransition( + state = + runOnUiThread { + MutableSceneTransitionLayoutState( + fromScene, + transitions = transitions { from(fromScene, overlay, builder = transition) }, + ) + }, + transitionLayout = { state -> + SceneTransitionLayout(state) { + scene(fromScene) { fromSceneContent() } + overlay(overlay) { overlayContent() } + } + }, + changeState = { state -> state.showOverlay(overlay, animationScope = this) }, + builder = builder, + ) +} + +/** Test the transition when hiding [overlay] to [toScene]. */ +fun ComposeContentTestRule.testHideOverlayTransition( + toSceneContent: @Composable ContentScope.() -> Unit, + overlayContent: @Composable ContentScope.() -> Unit, + transition: TransitionBuilder.() -> Unit, + toScene: SceneKey = TestScenes.SceneA, + overlay: OverlayKey = TestOverlays.OverlayA, + builder: TransitionTestBuilder.() -> Unit, +) { + testTransition( + state = + runOnUiThread { + MutableSceneTransitionLayoutState( + toScene, + initialOverlays = setOf(overlay), + transitions = transitions { from(overlay, toScene, builder = transition) }, + ) + }, + transitionLayout = { state -> + SceneTransitionLayout(state) { + scene(toScene) { toSceneContent() } + overlay(overlay) { overlayContent() } + } + }, + changeState = { state -> state.hideOverlay(overlay, animationScope = this) }, + builder = builder, + ) +} + +/** Test the transition when replace [from] to [to]. */ +fun ComposeContentTestRule.testReplaceOverlayTransition( + fromContent: @Composable ContentScope.() -> Unit, + toContent: @Composable ContentScope.() -> Unit, + transition: TransitionBuilder.() -> Unit, + currentSceneContent: @Composable ContentScope.() -> Unit = { Box(Modifier.fillMaxSize()) }, + fromAlignment: Alignment = Alignment.Center, + toAlignment: Alignment = Alignment.Center, + from: OverlayKey = TestOverlays.OverlayA, + to: OverlayKey = TestOverlays.OverlayB, + currentScene: SceneKey = TestScenes.SceneA, + builder: TransitionTestBuilder.() -> Unit, +) { + testTransition( + state = + runOnUiThread { + MutableSceneTransitionLayoutState( + currentScene, + initialOverlays = setOf(from), + transitions = transitions { from(from, to, builder = transition) }, + ) + }, + transitionLayout = { state -> + SceneTransitionLayout(state) { + scene(currentScene) { currentSceneContent() } + overlay(from, fromAlignment) { fromContent() } + overlay(to, toAlignment) { toContent() } + } + }, + changeState = { state -> state.replaceOverlay(from, to, animationScope = this) }, + builder = builder, + ) +} + data class TransitionRecordingSpec( val recordBefore: Boolean = true, val recordAfter: Boolean = true, @@ -188,6 +282,21 @@ fun ComposeContentTestRule.testTransition( "(${currentScene.debugName})" } + testTransition( + state = state, + changeState = { state -> state.setTargetScene(to, coroutineScope = this) }, + transitionLayout = transitionLayout, + builder = builder, + ) +} + +/** Test the transition from [state] to [to]. */ +fun ComposeContentTestRule.testTransition( + state: MutableSceneTransitionLayoutState, + changeState: CoroutineScope.(MutableSceneTransitionLayoutState) -> Unit, + transitionLayout: @Composable (state: MutableSceneTransitionLayoutState) -> Unit, + builder: TransitionTestBuilder.() -> Unit, +) { val test = transitionTest(builder) val assertionScope = object : TransitionTestAssertionScope { @@ -213,7 +322,7 @@ fun ComposeContentTestRule.testTransition( mainClock.autoAdvance = false // Change the current scene. - runOnUiThread { state.setTargetScene(to, coroutineScope) } + runOnUiThread { coroutineScope.changeState(state) } waitForIdle() mainClock.advanceTimeByFrame() waitForIdle() |