Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +94 −39 Original line number Diff line number Diff line Loading @@ -342,10 +342,8 @@ private fun elementTransition( if (transition != previousTransition && transition != null && previousTransition != null) { // The previous transition was interrupted by another transition. prepareInterruption(element) } if (transition == null && previousTransition != null) { prepareInterruption(element, transition, previousTransition) } else if (transition == null && previousTransition != null) { // The transition was just finished. element.sceneStates.values.forEach { it.clearValuesBeforeInterruption() Loading @@ -356,46 +354,103 @@ private fun elementTransition( return transition } private fun prepareInterruption(element: Element) { // We look for the last unique state of this element so that we animate the delta with its // future state. val sceneStates = element.sceneStates.values var lastUniqueState: Element.SceneState? = null for (sceneState in sceneStates) { val offset = sceneState.lastOffset // If the element was placed in this scene... if (offset != Offset.Unspecified) { // ... and it is the first (and potentially the only) scene where the element was // placed, save the state for later. if (lastUniqueState == null) { lastUniqueState = sceneState } else { // The element was placed in multiple scenes: we abort the interruption for this // element. // TODO(b/290930950): Better support cases where a shared element animation is // disabled and the same element is drawn/placed in multiple scenes at the same // time. lastUniqueState = null break private fun prepareInterruption( element: Element, transition: TransitionState.Transition, previousTransition: TransitionState.Transition, ) { val previousUniqueState = reconcileStates(element, previousTransition) if (previousUniqueState == null) { reconcileStates(element, transition) return } val fromSceneState = element.sceneStates[transition.fromScene] val toSceneState = element.sceneStates[transition.toScene] if ( fromSceneState == null || toSceneState == null || sharedElementTransformation(element.key, transition)?.enabled != false ) { // If there is only one copy of the element or if the element is shared, animate deltas in // both scenes. fromSceneState?.updateValuesBeforeInterruption(previousUniqueState) toSceneState?.updateValuesBeforeInterruption(previousUniqueState) } } val lastOffset = lastUniqueState?.lastOffset ?: Offset.Unspecified val lastSize = lastUniqueState?.lastSize ?: Element.SizeUnspecified val lastScale = lastUniqueState?.lastScale ?: Scale.Unspecified val lastAlpha = lastUniqueState?.lastAlpha ?: Element.AlphaUnspecified /** * Reconcile the state of [element] in the fromScene and toScene of [transition] so that the values * before interruption have their expected values, taking shared transitions into account. * * If the element had a unique state, i.e. it is shared in [transition] or it is only present in one * of the scenes, return it. */ private fun reconcileStates( element: Element, transition: TransitionState.Transition, ): Element.SceneState? { val fromSceneState = element.sceneStates[transition.fromScene] val toSceneState = element.sceneStates[transition.toScene] when { // Element is in both scenes. fromSceneState != null && toSceneState != null -> { val isSharedTransformationDisabled = sharedElementTransformation(element.key, transition)?.enabled == false when { // Element shared transition is disabled so the element is placed in both scenes. isSharedTransformationDisabled -> { fromSceneState.updateValuesBeforeInterruption(fromSceneState) toSceneState.updateValuesBeforeInterruption(toSceneState) return null } // Store the state of the element before the interruption and reset the deltas. sceneStates.forEach { sceneState -> sceneState.offsetBeforeInterruption = lastOffset sceneState.sizeBeforeInterruption = lastSize sceneState.scaleBeforeInterruption = lastScale sceneState.alphaBeforeInterruption = lastAlpha // Element is shared and placed in fromScene only. fromSceneState.lastOffset != Offset.Unspecified -> { fromSceneState.updateValuesBeforeInterruption(fromSceneState) toSceneState.updateValuesBeforeInterruption(fromSceneState) return fromSceneState } sceneState.clearInterruptionDeltas() // Element is shared and placed in toScene only. toSceneState.lastOffset != Offset.Unspecified -> { fromSceneState.updateValuesBeforeInterruption(toSceneState) toSceneState.updateValuesBeforeInterruption(toSceneState) return toSceneState } // Element is in none of the scenes. else -> { fromSceneState.updateValuesBeforeInterruption(null) toSceneState.updateValuesBeforeInterruption(null) return null } } } // Element is only in fromScene. fromSceneState != null -> { fromSceneState.updateValuesBeforeInterruption(fromSceneState) return fromSceneState } // Element is only in toScene. toSceneState != null -> { toSceneState.updateValuesBeforeInterruption(toSceneState) return toSceneState } else -> return null } } private fun Element.SceneState.updateValuesBeforeInterruption(lastState: Element.SceneState?) { offsetBeforeInterruption = lastState?.lastOffset ?: Offset.Unspecified sizeBeforeInterruption = lastState?.lastSize ?: Element.SizeUnspecified scaleBeforeInterruption = lastState?.lastScale ?: Scale.Unspecified alphaBeforeInterruption = lastState?.lastAlpha ?: Element.AlphaUnspecified clearInterruptionDeltas() } private fun Element.SceneState.clearInterruptionDeltas() { Loading packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt +106 −0 Original line number Diff line number Diff line Loading @@ -1225,4 +1225,110 @@ class ElementTest { assertThat(stateInC.scaleInterruptionDelta).isEqualTo(Scale.Zero) assertThat(stateInC.alphaInterruptionDelta).isEqualTo(0f) } @Test fun interruption_sharedTransitionDisabled() = runTest { // 4 frames of animation. val duration = 4 * 16 val layoutSize = DpSize(200.dp, 100.dp) val fooSize = 100.dp val state = rule.runOnUiThread { MutableSceneTransitionLayoutStateImpl( SceneA, transitions { from(SceneA, to = SceneB) { spec = tween(duration, easing = LinearEasing) } // Disable the shared transition during B => C. from(SceneB, to = SceneC) { spec = tween(duration, easing = LinearEasing) sharedElement(TestElements.Foo, enabled = false) } }, ) } @Composable fun SceneScope.Foo(modifier: Modifier = Modifier) { Box(modifier.element(TestElements.Foo).size(fooSize)) } rule.setContent { SceneTransitionLayout(state, Modifier.size(layoutSize)) { scene(SceneA) { Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopStart)) } } scene(SceneB) { Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopEnd)) } } scene(SceneC) { Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.BottomEnd)) } } } } // The offset of Foo when idle in A, B or C. val offsetInA = DpOffset.Zero val offsetInB = DpOffset(layoutSize.width - fooSize, 0.dp) val offsetInC = DpOffset(layoutSize.width - fooSize, layoutSize.height - fooSize) // State is a transition A => B at 50% interrupted by B => C at 30%. val aToB = transition(from = SceneA, to = SceneB, progress = { 0.5f }, onFinish = neverFinish()) var bToCInterruptionProgress by mutableStateOf(1f) val bToC = transition( from = SceneB, to = SceneC, progress = { 0.3f }, interruptionProgress = { bToCInterruptionProgress }, onFinish = neverFinish(), ) rule.runOnUiThread { state.startTransition(aToB, transitionKey = null) } rule.waitForIdle() rule.runOnUiThread { state.startTransition(bToC, transitionKey = null) } // Foo is placed in both B and C given that the shared transition is disabled. In B, its // offset is impacted by the interruption but in C it is not. val offsetInAToB = lerp(offsetInA, offsetInB, 0.5f) val interruptionDelta = offsetInAToB - offsetInB assertThat(interruptionDelta).isNotEqualTo(Offset.Zero) rule .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo( offsetInB.x + interruptionDelta.x, offsetInB.y + interruptionDelta.y, ) rule .onNode(isElement(TestElements.Foo, SceneC)) .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) // Manually finish A => B so only B => C is remaining. bToCInterruptionProgress = 0f rule.runOnUiThread { state.finishTransition(aToB, SceneB) } rule .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo(offsetInB.x, offsetInB.y) rule .onNode(isElement(TestElements.Foo, SceneC)) .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) // Interrupt B => C by B => A, starting directly at 70% val bToA = transition( from = SceneB, to = SceneA, progress = { 0.7f }, interruptionProgress = { 1f }, ) rule.runOnUiThread { state.startTransition(bToA, transitionKey = null) } // Foo should have the position it had in B right before the interruption. rule .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo(offsetInB.x, offsetInB.y) } } Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +94 −39 Original line number Diff line number Diff line Loading @@ -342,10 +342,8 @@ private fun elementTransition( if (transition != previousTransition && transition != null && previousTransition != null) { // The previous transition was interrupted by another transition. prepareInterruption(element) } if (transition == null && previousTransition != null) { prepareInterruption(element, transition, previousTransition) } else if (transition == null && previousTransition != null) { // The transition was just finished. element.sceneStates.values.forEach { it.clearValuesBeforeInterruption() Loading @@ -356,46 +354,103 @@ private fun elementTransition( return transition } private fun prepareInterruption(element: Element) { // We look for the last unique state of this element so that we animate the delta with its // future state. val sceneStates = element.sceneStates.values var lastUniqueState: Element.SceneState? = null for (sceneState in sceneStates) { val offset = sceneState.lastOffset // If the element was placed in this scene... if (offset != Offset.Unspecified) { // ... and it is the first (and potentially the only) scene where the element was // placed, save the state for later. if (lastUniqueState == null) { lastUniqueState = sceneState } else { // The element was placed in multiple scenes: we abort the interruption for this // element. // TODO(b/290930950): Better support cases where a shared element animation is // disabled and the same element is drawn/placed in multiple scenes at the same // time. lastUniqueState = null break private fun prepareInterruption( element: Element, transition: TransitionState.Transition, previousTransition: TransitionState.Transition, ) { val previousUniqueState = reconcileStates(element, previousTransition) if (previousUniqueState == null) { reconcileStates(element, transition) return } val fromSceneState = element.sceneStates[transition.fromScene] val toSceneState = element.sceneStates[transition.toScene] if ( fromSceneState == null || toSceneState == null || sharedElementTransformation(element.key, transition)?.enabled != false ) { // If there is only one copy of the element or if the element is shared, animate deltas in // both scenes. fromSceneState?.updateValuesBeforeInterruption(previousUniqueState) toSceneState?.updateValuesBeforeInterruption(previousUniqueState) } } val lastOffset = lastUniqueState?.lastOffset ?: Offset.Unspecified val lastSize = lastUniqueState?.lastSize ?: Element.SizeUnspecified val lastScale = lastUniqueState?.lastScale ?: Scale.Unspecified val lastAlpha = lastUniqueState?.lastAlpha ?: Element.AlphaUnspecified /** * Reconcile the state of [element] in the fromScene and toScene of [transition] so that the values * before interruption have their expected values, taking shared transitions into account. * * If the element had a unique state, i.e. it is shared in [transition] or it is only present in one * of the scenes, return it. */ private fun reconcileStates( element: Element, transition: TransitionState.Transition, ): Element.SceneState? { val fromSceneState = element.sceneStates[transition.fromScene] val toSceneState = element.sceneStates[transition.toScene] when { // Element is in both scenes. fromSceneState != null && toSceneState != null -> { val isSharedTransformationDisabled = sharedElementTransformation(element.key, transition)?.enabled == false when { // Element shared transition is disabled so the element is placed in both scenes. isSharedTransformationDisabled -> { fromSceneState.updateValuesBeforeInterruption(fromSceneState) toSceneState.updateValuesBeforeInterruption(toSceneState) return null } // Store the state of the element before the interruption and reset the deltas. sceneStates.forEach { sceneState -> sceneState.offsetBeforeInterruption = lastOffset sceneState.sizeBeforeInterruption = lastSize sceneState.scaleBeforeInterruption = lastScale sceneState.alphaBeforeInterruption = lastAlpha // Element is shared and placed in fromScene only. fromSceneState.lastOffset != Offset.Unspecified -> { fromSceneState.updateValuesBeforeInterruption(fromSceneState) toSceneState.updateValuesBeforeInterruption(fromSceneState) return fromSceneState } sceneState.clearInterruptionDeltas() // Element is shared and placed in toScene only. toSceneState.lastOffset != Offset.Unspecified -> { fromSceneState.updateValuesBeforeInterruption(toSceneState) toSceneState.updateValuesBeforeInterruption(toSceneState) return toSceneState } // Element is in none of the scenes. else -> { fromSceneState.updateValuesBeforeInterruption(null) toSceneState.updateValuesBeforeInterruption(null) return null } } } // Element is only in fromScene. fromSceneState != null -> { fromSceneState.updateValuesBeforeInterruption(fromSceneState) return fromSceneState } // Element is only in toScene. toSceneState != null -> { toSceneState.updateValuesBeforeInterruption(toSceneState) return toSceneState } else -> return null } } private fun Element.SceneState.updateValuesBeforeInterruption(lastState: Element.SceneState?) { offsetBeforeInterruption = lastState?.lastOffset ?: Offset.Unspecified sizeBeforeInterruption = lastState?.lastSize ?: Element.SizeUnspecified scaleBeforeInterruption = lastState?.lastScale ?: Scale.Unspecified alphaBeforeInterruption = lastState?.lastAlpha ?: Element.AlphaUnspecified clearInterruptionDeltas() } private fun Element.SceneState.clearInterruptionDeltas() { Loading
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt +106 −0 Original line number Diff line number Diff line Loading @@ -1225,4 +1225,110 @@ class ElementTest { assertThat(stateInC.scaleInterruptionDelta).isEqualTo(Scale.Zero) assertThat(stateInC.alphaInterruptionDelta).isEqualTo(0f) } @Test fun interruption_sharedTransitionDisabled() = runTest { // 4 frames of animation. val duration = 4 * 16 val layoutSize = DpSize(200.dp, 100.dp) val fooSize = 100.dp val state = rule.runOnUiThread { MutableSceneTransitionLayoutStateImpl( SceneA, transitions { from(SceneA, to = SceneB) { spec = tween(duration, easing = LinearEasing) } // Disable the shared transition during B => C. from(SceneB, to = SceneC) { spec = tween(duration, easing = LinearEasing) sharedElement(TestElements.Foo, enabled = false) } }, ) } @Composable fun SceneScope.Foo(modifier: Modifier = Modifier) { Box(modifier.element(TestElements.Foo).size(fooSize)) } rule.setContent { SceneTransitionLayout(state, Modifier.size(layoutSize)) { scene(SceneA) { Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopStart)) } } scene(SceneB) { Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.TopEnd)) } } scene(SceneC) { Box(Modifier.fillMaxSize()) { Foo(Modifier.align(Alignment.BottomEnd)) } } } } // The offset of Foo when idle in A, B or C. val offsetInA = DpOffset.Zero val offsetInB = DpOffset(layoutSize.width - fooSize, 0.dp) val offsetInC = DpOffset(layoutSize.width - fooSize, layoutSize.height - fooSize) // State is a transition A => B at 50% interrupted by B => C at 30%. val aToB = transition(from = SceneA, to = SceneB, progress = { 0.5f }, onFinish = neverFinish()) var bToCInterruptionProgress by mutableStateOf(1f) val bToC = transition( from = SceneB, to = SceneC, progress = { 0.3f }, interruptionProgress = { bToCInterruptionProgress }, onFinish = neverFinish(), ) rule.runOnUiThread { state.startTransition(aToB, transitionKey = null) } rule.waitForIdle() rule.runOnUiThread { state.startTransition(bToC, transitionKey = null) } // Foo is placed in both B and C given that the shared transition is disabled. In B, its // offset is impacted by the interruption but in C it is not. val offsetInAToB = lerp(offsetInA, offsetInB, 0.5f) val interruptionDelta = offsetInAToB - offsetInB assertThat(interruptionDelta).isNotEqualTo(Offset.Zero) rule .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo( offsetInB.x + interruptionDelta.x, offsetInB.y + interruptionDelta.y, ) rule .onNode(isElement(TestElements.Foo, SceneC)) .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) // Manually finish A => B so only B => C is remaining. bToCInterruptionProgress = 0f rule.runOnUiThread { state.finishTransition(aToB, SceneB) } rule .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo(offsetInB.x, offsetInB.y) rule .onNode(isElement(TestElements.Foo, SceneC)) .assertPositionInRootIsEqualTo(offsetInC.x, offsetInC.y) // Interrupt B => C by B => A, starting directly at 70% val bToA = transition( from = SceneB, to = SceneA, progress = { 0.7f }, interruptionProgress = { 1f }, ) rule.runOnUiThread { state.startTransition(bToA, transitionKey = null) } // Foo should have the position it had in B right before the interruption. rule .onNode(isElement(TestElements.Foo, SceneB)) .assertPositionInRootIsEqualTo(offsetInB.x, offsetInB.y) } }