Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +12 −3 Original line number Diff line number Diff line Loading @@ -395,10 +395,11 @@ private class DragControllerImpl( if ( distance != DistanceUnspecified && shouldCommitSwipe( offset, distance, velocity, offset = offset, distance = distance, velocity = velocity, wasCommitted = swipeTransition._currentScene == toScene, requiresFullDistanceSwipe = swipeTransition.requiresFullDistanceSwipe, ) ) { targetScene = toScene Loading Loading @@ -472,7 +473,12 @@ private class DragControllerImpl( distance: Float, velocity: Float, wasCommitted: Boolean, requiresFullDistanceSwipe: Boolean, ): Boolean { if (requiresFullDistanceSwipe && !wasCommitted) { return offset / distance >= 1f } fun isCloserToTarget(): Boolean { return (offset - distance).absoluteValue < offset.absoluteValue } Loading Loading @@ -530,6 +536,7 @@ private fun SwipeTransition( userActionDistanceScope = layoutImpl.userActionDistanceScope, orientation = orientation, isUpOrLeft = isUpOrLeft, requiresFullDistanceSwipe = result.requiresFullDistanceSwipe, ) } Loading @@ -545,6 +552,7 @@ private fun SwipeTransition(old: SwipeTransition): SwipeTransition { orientation = old.orientation, isUpOrLeft = old.isUpOrLeft, lastDistance = old.lastDistance, requiresFullDistanceSwipe = old.requiresFullDistanceSwipe, ) .apply { _currentScene = old._currentScene Loading @@ -562,6 +570,7 @@ private class SwipeTransition( val userActionDistanceScope: UserActionDistanceScope, override val orientation: Orientation, override val isUpOrLeft: Boolean, val requiresFullDistanceSwipe: Boolean, var lastDistance: Float = DistanceUnspecified, ) : TransitionState.Transition(_fromScene.key, _toScene.key), Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +13 −4 Original line number Diff line number Diff line Loading @@ -306,7 +306,6 @@ internal class ElementNode( return layout(placeable.width, placeable.height) { place(transition, placeable) } } @OptIn(ExperimentalComposeUiApi::class) private fun Placeable.PlacementScope.place( transition: TransitionState.Transition?, placeable: Placeable, Loading Loading @@ -561,10 +560,20 @@ private fun reconcileStates( } private fun Element.SceneState.selfUpdateValuesBeforeInterruption() { offsetBeforeInterruption = lastOffset sizeBeforeInterruption = lastSize if (lastAlpha > 0f) { offsetBeforeInterruption = lastOffset scaleBeforeInterruption = lastScale alphaBeforeInterruption = lastAlpha } else { // Consider the element as not placed in this scene if it was fully transparent. // TODO(b/290930950): Look into using derived state inside place() instead to not even place // the element at all when alpha == 0f. offsetBeforeInterruption = Offset.Unspecified scaleBeforeInterruption = Scale.Unspecified alphaBeforeInterruption = Element.AlphaUnspecified } } private fun Element.SceneState.updateValuesBeforeInterruption(lastState: Element.SceneState) { Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt +7 −0 Original line number Diff line number Diff line Loading @@ -459,6 +459,13 @@ data class UserActionResult( /** The key of the transition that should be used. */ val transitionKey: TransitionKey? = null, /** * If `true`, the swipe will be committed and we will settle to [toScene] if only if the user * swiped at least the swipe distance, i.e. the transition progress was already equal to or * bigger than 100% when the user released their finger. ` */ val requiresFullDistanceSwipe: Boolean = false, ) fun interface UserActionDistance { Loading packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +18 −0 Original line number Diff line number Diff line Loading @@ -1215,4 +1215,22 @@ class DraggableHandlerTest { onDragStartedImmediately() assertTransition(fromScene = SceneA, toScene = SceneB, progress = 50f / 75f) } @Test fun requireFullDistanceSwipe() = runGestureTest { mutableUserActionsA[Swipe.Up] = UserActionResult(SceneB, requiresFullDistanceSwipe = true) val controller = onDragStarted(overSlop = up(fractionOfScreen = 0.9f)) assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.9f) controller.onDragStopped(velocity = 0f) advanceUntilIdle() assertIdle(SceneA) val otherController = onDragStarted(overSlop = up(fractionOfScreen = 1f)) assertTransition(fromScene = SceneA, toScene = SceneB, progress = 1f) otherController.onDragStopped(velocity = 0f) advanceUntilIdle() assertIdle(SceneB) } } packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt +83 −5 Original line number Diff line number Diff line Loading @@ -851,7 +851,8 @@ class ElementTest { rule.runOnUiThread { MutableSceneTransitionLayoutState( initialScene = SceneA, transitions = transitions { transitions = transitions { from(SceneA, to = SceneB) { translate(TestElements.Foo, y = translateY) } Loading Loading @@ -2010,4 +2011,81 @@ class ElementTest { ) .isEqualTo(Element.SizeUnspecified) } @Test fun transparentElementIsNotImpactingInterruption() = runTest { val state = rule.runOnIdle { MutableSceneTransitionLayoutStateImpl( SceneA, transitions { from(SceneA, to = SceneB) { // In A => B, Foo is not shared and first fades out from A then fades in // B. sharedElement(TestElements.Foo, enabled = false) fractionRange(end = 0.5f) { fade(TestElements.Foo.inScene(SceneA)) } fractionRange(start = 0.5f) { fade(TestElements.Foo.inScene(SceneB)) } } from(SceneB, to = SceneA) { // In B => A, Foo is shared. sharedElement(TestElements.Foo, enabled = true) } } ) } @Composable fun SceneScope.Foo(modifier: Modifier = Modifier) { Box(modifier.element(TestElements.Foo).size(10.dp)) } rule.setContent { SceneTransitionLayout(state) { scene(SceneB) { Foo(Modifier.offset(40.dp, 60.dp)) } // Define A after B so that Foo is placed in A during A <=> B. scene(SceneA) { Foo() } } } // Start A => B at 70%. rule.runOnUiThread { state.startTransition( transition( from = SceneA, to = SceneB, progress = { 0.7f }, onFinish = neverFinish(), ) ) } rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(0.dp, 0.dp) rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(40.dp, 60.dp) // Start B => A at 50% with interruptionProgress = 100%. Foo is placed in A and should still // be at (40dp, 60dp) given that it was fully transparent in A before the interruption. var interruptionProgress by mutableStateOf(1f) rule.runOnUiThread { state.startTransition( transition( from = SceneB, to = SceneA, progress = { 0.5f }, interruptionProgress = { interruptionProgress }, onFinish = neverFinish(), ) ) } rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(40.dp, 60.dp) rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed() // Set the interruption progress to 0%. Foo should be at (20dp, 30dp) given that B => is at // 50%. interruptionProgress = 0f rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(20.dp, 30.dp) rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed() } } Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +12 −3 Original line number Diff line number Diff line Loading @@ -395,10 +395,11 @@ private class DragControllerImpl( if ( distance != DistanceUnspecified && shouldCommitSwipe( offset, distance, velocity, offset = offset, distance = distance, velocity = velocity, wasCommitted = swipeTransition._currentScene == toScene, requiresFullDistanceSwipe = swipeTransition.requiresFullDistanceSwipe, ) ) { targetScene = toScene Loading Loading @@ -472,7 +473,12 @@ private class DragControllerImpl( distance: Float, velocity: Float, wasCommitted: Boolean, requiresFullDistanceSwipe: Boolean, ): Boolean { if (requiresFullDistanceSwipe && !wasCommitted) { return offset / distance >= 1f } fun isCloserToTarget(): Boolean { return (offset - distance).absoluteValue < offset.absoluteValue } Loading Loading @@ -530,6 +536,7 @@ private fun SwipeTransition( userActionDistanceScope = layoutImpl.userActionDistanceScope, orientation = orientation, isUpOrLeft = isUpOrLeft, requiresFullDistanceSwipe = result.requiresFullDistanceSwipe, ) } Loading @@ -545,6 +552,7 @@ private fun SwipeTransition(old: SwipeTransition): SwipeTransition { orientation = old.orientation, isUpOrLeft = old.isUpOrLeft, lastDistance = old.lastDistance, requiresFullDistanceSwipe = old.requiresFullDistanceSwipe, ) .apply { _currentScene = old._currentScene Loading @@ -562,6 +570,7 @@ private class SwipeTransition( val userActionDistanceScope: UserActionDistanceScope, override val orientation: Orientation, override val isUpOrLeft: Boolean, val requiresFullDistanceSwipe: Boolean, var lastDistance: Float = DistanceUnspecified, ) : TransitionState.Transition(_fromScene.key, _toScene.key), Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +13 −4 Original line number Diff line number Diff line Loading @@ -306,7 +306,6 @@ internal class ElementNode( return layout(placeable.width, placeable.height) { place(transition, placeable) } } @OptIn(ExperimentalComposeUiApi::class) private fun Placeable.PlacementScope.place( transition: TransitionState.Transition?, placeable: Placeable, Loading Loading @@ -561,10 +560,20 @@ private fun reconcileStates( } private fun Element.SceneState.selfUpdateValuesBeforeInterruption() { offsetBeforeInterruption = lastOffset sizeBeforeInterruption = lastSize if (lastAlpha > 0f) { offsetBeforeInterruption = lastOffset scaleBeforeInterruption = lastScale alphaBeforeInterruption = lastAlpha } else { // Consider the element as not placed in this scene if it was fully transparent. // TODO(b/290930950): Look into using derived state inside place() instead to not even place // the element at all when alpha == 0f. offsetBeforeInterruption = Offset.Unspecified scaleBeforeInterruption = Scale.Unspecified alphaBeforeInterruption = Element.AlphaUnspecified } } private fun Element.SceneState.updateValuesBeforeInterruption(lastState: Element.SceneState) { Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt +7 −0 Original line number Diff line number Diff line Loading @@ -459,6 +459,13 @@ data class UserActionResult( /** The key of the transition that should be used. */ val transitionKey: TransitionKey? = null, /** * If `true`, the swipe will be committed and we will settle to [toScene] if only if the user * swiped at least the swipe distance, i.e. the transition progress was already equal to or * bigger than 100% when the user released their finger. ` */ val requiresFullDistanceSwipe: Boolean = false, ) fun interface UserActionDistance { Loading
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +18 −0 Original line number Diff line number Diff line Loading @@ -1215,4 +1215,22 @@ class DraggableHandlerTest { onDragStartedImmediately() assertTransition(fromScene = SceneA, toScene = SceneB, progress = 50f / 75f) } @Test fun requireFullDistanceSwipe() = runGestureTest { mutableUserActionsA[Swipe.Up] = UserActionResult(SceneB, requiresFullDistanceSwipe = true) val controller = onDragStarted(overSlop = up(fractionOfScreen = 0.9f)) assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.9f) controller.onDragStopped(velocity = 0f) advanceUntilIdle() assertIdle(SceneA) val otherController = onDragStarted(overSlop = up(fractionOfScreen = 1f)) assertTransition(fromScene = SceneA, toScene = SceneB, progress = 1f) otherController.onDragStopped(velocity = 0f) advanceUntilIdle() assertIdle(SceneB) } }
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt +83 −5 Original line number Diff line number Diff line Loading @@ -851,7 +851,8 @@ class ElementTest { rule.runOnUiThread { MutableSceneTransitionLayoutState( initialScene = SceneA, transitions = transitions { transitions = transitions { from(SceneA, to = SceneB) { translate(TestElements.Foo, y = translateY) } Loading Loading @@ -2010,4 +2011,81 @@ class ElementTest { ) .isEqualTo(Element.SizeUnspecified) } @Test fun transparentElementIsNotImpactingInterruption() = runTest { val state = rule.runOnIdle { MutableSceneTransitionLayoutStateImpl( SceneA, transitions { from(SceneA, to = SceneB) { // In A => B, Foo is not shared and first fades out from A then fades in // B. sharedElement(TestElements.Foo, enabled = false) fractionRange(end = 0.5f) { fade(TestElements.Foo.inScene(SceneA)) } fractionRange(start = 0.5f) { fade(TestElements.Foo.inScene(SceneB)) } } from(SceneB, to = SceneA) { // In B => A, Foo is shared. sharedElement(TestElements.Foo, enabled = true) } } ) } @Composable fun SceneScope.Foo(modifier: Modifier = Modifier) { Box(modifier.element(TestElements.Foo).size(10.dp)) } rule.setContent { SceneTransitionLayout(state) { scene(SceneB) { Foo(Modifier.offset(40.dp, 60.dp)) } // Define A after B so that Foo is placed in A during A <=> B. scene(SceneA) { Foo() } } } // Start A => B at 70%. rule.runOnUiThread { state.startTransition( transition( from = SceneA, to = SceneB, progress = { 0.7f }, onFinish = neverFinish(), ) ) } rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(0.dp, 0.dp) rule.onNode(isElement(TestElements.Foo, SceneB)).assertPositionInRootIsEqualTo(40.dp, 60.dp) // Start B => A at 50% with interruptionProgress = 100%. Foo is placed in A and should still // be at (40dp, 60dp) given that it was fully transparent in A before the interruption. var interruptionProgress by mutableStateOf(1f) rule.runOnUiThread { state.startTransition( transition( from = SceneB, to = SceneA, progress = { 0.5f }, interruptionProgress = { interruptionProgress }, onFinish = neverFinish(), ) ) } rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(40.dp, 60.dp) rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed() // Set the interruption progress to 0%. Foo should be at (20dp, 30dp) given that B => is at // 50%. interruptionProgress = 0f rule.onNode(isElement(TestElements.Foo, SceneA)).assertPositionInRootIsEqualTo(20.dp, 30.dp) rule.onNode(isElement(TestElements.Foo, SceneB)).assertIsNotDisplayed() } }