summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Omar Miatello <omarmt@google.com> 2023-10-23 08:46:10 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2023-10-23 08:46:10 +0000
commit65c80f3144d300d6ed4695fb13448ebaedb61cd8 (patch)
tree46fb888423e84fd0db2d79709f2510d83095b02d
parentfb54dc0b9785e97ce15be08bd85417a169f5ae42 (diff)
parent2c02d345b36b503dbe967d9c042871ccd177d819 (diff)
Merge "Introduction of the GestureHandler interface" into main
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt20
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt17
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt6
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt1018
-rw-r--r--packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt284
-rw-r--r--packages/SystemUI/compose/scene/tests/src/com/android/compose/test/RunMonotonicClockTest.kt27
6 files changed, 842 insertions, 530 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt
new file mode 100644
index 000000000000..d005413fcbcf
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt
@@ -0,0 +1,20 @@
+package com.android.compose.animation.scene
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import kotlinx.coroutines.CoroutineScope
+
+interface GestureHandler {
+ val draggable: DraggableHandler
+ val nestedScroll: NestedScrollHandler
+}
+
+interface DraggableHandler {
+ suspend fun onDragStarted(coroutineScope: CoroutineScope, startedPosition: Offset)
+ fun onDelta(pixels: Float)
+ suspend fun onDragStopped(coroutineScope: CoroutineScope, velocity: Float)
+}
+
+interface NestedScrollHandler {
+ val connection: NestedScrollConnection
+}
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 3fd6828fca6b..9c799b282571 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
@@ -16,6 +16,7 @@
package com.android.compose.animation.scene
+import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
@@ -100,3 +101,19 @@ private class SceneScopeImpl(
MovableElement(layoutImpl, scene, key, modifier, content)
}
}
+
+/** The destination scene when swiping up or left from [upOrLeft]. */
+internal fun Scene.upOrLeft(orientation: Orientation): SceneKey? {
+ return when (orientation) {
+ Orientation.Vertical -> userActions[Swipe.Up]
+ Orientation.Horizontal -> userActions[Swipe.Left]
+ }
+}
+
+/** The destination scene when swiping down or right from [downOrRight]. */
+internal fun Scene.downOrRight(orientation: Orientation): SceneKey? {
+ return when (orientation) {
+ Orientation.Vertical -> userActions[Swipe.Down]
+ Orientation.Horizontal -> userActions[Swipe.Right]
+ }
+}
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 4952270cb5f2..a40b29991877 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
@@ -17,6 +17,7 @@
package com.android.compose.animation.scene
import androidx.activity.compose.BackHandler
+import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
@@ -39,7 +40,8 @@ import androidx.compose.ui.unit.IntSize
import com.android.compose.ui.util.fastForEach
import kotlinx.coroutines.channels.Channel
-internal class SceneTransitionLayoutImpl(
+@VisibleForTesting
+class SceneTransitionLayoutImpl(
onChangeScene: (SceneKey) -> Unit,
builder: SceneTransitionLayoutScope.() -> Unit,
transitions: SceneTransitions,
@@ -60,7 +62,7 @@ internal class SceneTransitionLayoutImpl(
* The size of this layout. Note that this could be [IntSize.Zero] if this layour does not have
* any scene configured or right before the first measure pass of the layout.
*/
- internal var size by mutableStateOf(IntSize.Zero)
+ @VisibleForTesting var size by mutableStateOf(IntSize.Zero)
init {
setScenes(builder)
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
index 1cbfe3057ff0..6496507218a5 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
@@ -16,10 +16,10 @@
package com.android.compose.animation.scene
+import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
-import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
@@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import com.android.compose.nestedscroll.PriorityPostNestedScrollConnection
@@ -51,612 +50,575 @@ internal fun Modifier.swipeToScene(
layoutImpl: SceneTransitionLayoutImpl,
orientation: Orientation,
): Modifier {
- val state = layoutImpl.state.transitionState
- val currentScene = layoutImpl.scene(state.currentScene)
- val transition = remember {
- // Note that the currentScene here does not matter, it's only used for initializing the
- // transition and will be replaced when a drag event starts.
- SwipeTransition(initialScene = currentScene)
- }
-
- val enabled = state == transition || currentScene.shouldEnableSwipes(orientation)
-
- // Immediately start the drag if this our [transition] is currently animating to a scene (i.e.
- // the user released their input pointer after swiping in this orientation) and the user can't
- // swipe in the other direction.
- val startDragImmediately =
- state == transition &&
- transition.isAnimatingOffset &&
- !currentScene.shouldEnableSwipes(orientation.opposite())
-
- // The velocity threshold at which the intent of the user is to swipe up or down. It is the same
- // as SwipeableV2Defaults.VelocityThreshold.
- val velocityThreshold = with(LocalDensity.current) { 125.dp.toPx() }
-
- // The positional threshold at which the intent of the user is to swipe to the next scene. It is
- // the same as SwipeableV2Defaults.PositionalThreshold.
- val positionalThreshold = with(LocalDensity.current) { 56.dp.toPx() }
-
- val draggableState = rememberDraggableState { delta ->
- onDrag(layoutImpl, transition, orientation, delta)
- }
-
- return nestedScroll(
- connection =
- rememberSwipeToSceneNestedScrollConnection(
- orientation = orientation,
- coroutineScope = rememberCoroutineScope(),
- draggableState = draggableState,
- transition = transition,
- layoutImpl = layoutImpl,
- velocityThreshold = velocityThreshold,
- positionalThreshold = positionalThreshold
- ),
+ val gestureHandler = rememberSceneGestureHandler(layoutImpl, orientation)
+
+ /** Whether swipe should be enabled in the given [orientation]. */
+ fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean =
+ upOrLeft(orientation) != null || downOrRight(orientation) != null
+
+ val currentScene = gestureHandler.currentScene
+ val canSwipe = currentScene.shouldEnableSwipes(orientation)
+ val canOppositeSwipe =
+ currentScene.shouldEnableSwipes(
+ when (orientation) {
+ Orientation.Vertical -> Orientation.Horizontal
+ Orientation.Horizontal -> Orientation.Vertical
+ }
)
+
+ return nestedScroll(connection = gestureHandler.nestedScroll.connection)
.draggable(
- state = draggableState,
+ state = rememberDraggableState(onDelta = gestureHandler.draggable::onDelta),
orientation = orientation,
- enabled = enabled,
- startDragImmediately = startDragImmediately,
- onDragStarted = { onDragStarted(layoutImpl, transition, orientation) },
- onDragStopped = { velocity ->
- onDragStopped(
- layoutImpl = layoutImpl,
- transition = transition,
- velocity = velocity,
- velocityThreshold = velocityThreshold,
- positionalThreshold = positionalThreshold,
- )
- },
+ enabled = gestureHandler.isDrivingTransition || canSwipe,
+ // Immediately start the drag if this our [transition] is currently animating to a scene
+ // (i.e. the user released their input pointer after swiping in this orientation) and
+ // the user can't swipe in the other direction.
+ startDragImmediately =
+ gestureHandler.isDrivingTransition &&
+ gestureHandler.isAnimatingOffset &&
+ !canOppositeSwipe,
+ onDragStarted = gestureHandler.draggable::onDragStarted,
+ onDragStopped = gestureHandler.draggable::onDragStopped,
)
}
-private class SwipeTransition(initialScene: Scene) : TransitionState.Transition {
- var _currentScene by mutableStateOf(initialScene)
- override val currentScene: SceneKey
- get() = _currentScene.key
-
- var _fromScene by mutableStateOf(initialScene)
- override val fromScene: SceneKey
- get() = _fromScene.key
-
- var _toScene by mutableStateOf(initialScene)
- override val toScene: SceneKey
- get() = _toScene.key
-
- override val progress: Float
- get() {
- val offset = if (isAnimatingOffset) offsetAnimatable.value else dragOffset
- if (distance == 0f) {
- // This can happen only if fromScene == toScene.
- error(
- "Transition.progress should be called only when Transition.fromScene != " +
- "Transition.toScene"
- )
- }
- return offset / distance
+@Composable
+private fun rememberSceneGestureHandler(
+ layoutImpl: SceneTransitionLayoutImpl,
+ orientation: Orientation,
+): SceneGestureHandler {
+ val coroutineScope = rememberCoroutineScope()
+
+ val gestureHandler =
+ remember(layoutImpl, orientation, coroutineScope) {
+ SceneGestureHandler(layoutImpl, orientation, coroutineScope)
}
- override val isUserInputDriven = true
+ // Make sure we reset the scroll connection when this handler is removed from composition
+ val connection = gestureHandler.nestedScroll.connection
+ DisposableEffect(connection) { onDispose { connection.reset() } }
- /** The current offset caused by the drag gesture. */
- var dragOffset by mutableFloatStateOf(0f)
+ return gestureHandler
+}
+
+@VisibleForTesting
+class SceneGestureHandler(
+ private val layoutImpl: SceneTransitionLayoutImpl,
+ internal val orientation: Orientation,
+ private val coroutineScope: CoroutineScope,
+) : GestureHandler {
+ override val draggable: DraggableHandler = SceneDraggableHandler(this)
+
+ override val nestedScroll: SceneNestedScrollHandler = SceneNestedScrollHandler(this)
+
+ private var transitionState
+ get() = layoutImpl.state.transitionState
+ set(value) {
+ layoutImpl.state.transitionState = value
+ }
/**
- * Whether the offset is animated (the user lifted their finger) or if it is driven by gesture.
+ * The transition controlled by this gesture handler. It will be set as the [transitionState] in
+ * the [SceneTransitionLayoutImpl] whenever this handler is driving the current transition.
+ *
+ * Note: the initialScene here does not matter, it's only used for initializing the transition
+ * and will be replaced when a drag event starts.
*/
- var isAnimatingOffset by mutableStateOf(false)
+ private val swipeTransition = SwipeTransition(initialScene = currentScene)
- /** The animatable used to animate the offset once the user lifted its finger. */
- val offsetAnimatable = Animatable(0f, visibilityThreshold = OffsetVisibilityThreshold)
+ internal val currentScene: Scene
+ get() = layoutImpl.scene(transitionState.currentScene)
- /** Job to check that there is at most one offset animation in progress. */
- private var offsetAnimationJob: Job? = null
+ internal val isDrivingTransition
+ get() = transitionState == swipeTransition
- /** Ends any previous [offsetAnimationJob] and runs the new [job]. */
- fun startOffsetAnimation(job: () -> Job) {
- stopOffsetAnimation()
- offsetAnimationJob = job()
- }
+ internal var isAnimatingOffset
+ get() = swipeTransition.isAnimatingOffset
+ private set(value) {
+ swipeTransition.isAnimatingOffset = value
+ }
- /** Stops any ongoing offset animation. */
- fun stopOffsetAnimation() {
- offsetAnimationJob?.cancel()
- }
+ internal val swipeTransitionToScene
+ get() = swipeTransition._toScene
- /** The absolute distance between [fromScene] and [toScene]. */
- var absoluteDistance = 0f
+ /**
+ * The velocity threshold at which the intent of the user is to swipe up or down. It is the same
+ * as SwipeableV2Defaults.VelocityThreshold.
+ */
+ @VisibleForTesting val velocityThreshold = with(layoutImpl.density) { 125.dp.toPx() }
/**
- * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above
- * or to the left of [toScene].
+ * The positional threshold at which the intent of the user is to swipe to the next scene. It is
+ * the same as SwipeableV2Defaults.PositionalThreshold.
*/
- var _distance by mutableFloatStateOf(0f)
- val distance: Float
- get() = _distance
-}
+ private val positionalThreshold = with(layoutImpl.density) { 56.dp.toPx() }
+
+ internal fun onDragStarted() {
+ if (isDrivingTransition) {
+ // This [transition] was already driving the animation: simply take over it.
+ if (isAnimatingOffset) {
+ // Stop animating and start from where the current offset. Setting the animation job
+ // to `null` will effectively cancel the animation.
+ swipeTransition.stopOffsetAnimation()
+ swipeTransition.dragOffset = swipeTransition.offsetAnimatable.value
+ }
-/** The destination scene when swiping up or left from [this@upOrLeft]. */
-private fun Scene.upOrLeft(orientation: Orientation): SceneKey? {
- return when (orientation) {
- Orientation.Vertical -> userActions[Swipe.Up]
- Orientation.Horizontal -> userActions[Swipe.Left]
- }
-}
+ return
+ }
-/** The destination scene when swiping down or right from [this@downOrRight]. */
-private fun Scene.downOrRight(orientation: Orientation): SceneKey? {
- return when (orientation) {
- Orientation.Vertical -> userActions[Swipe.Down]
- Orientation.Horizontal -> userActions[Swipe.Right]
- }
-}
+ // TODO(b/290184746): Better handle interruptions here if state != idle.
-/** Whether swipe should be enabled in the given [orientation]. */
-private fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean {
- return upOrLeft(orientation) != null || downOrRight(orientation) != null
-}
+ val fromScene = currentScene
-private fun Orientation.opposite(): Orientation {
- return when (this) {
- Orientation.Vertical -> Orientation.Horizontal
- Orientation.Horizontal -> Orientation.Vertical
- }
-}
+ swipeTransition._currentScene = fromScene
+ swipeTransition._fromScene = fromScene
-private fun onDragStarted(
- layoutImpl: SceneTransitionLayoutImpl,
- transition: SwipeTransition,
- orientation: Orientation,
-) {
- if (layoutImpl.state.transitionState == transition) {
- // This [transition] was already driving the animation: simply take over it.
- if (transition.isAnimatingOffset) {
- // Stop animating and start from where the current offset. Setting the animation job to
- // `null` will effectively cancel the animation.
- transition.stopOffsetAnimation()
- transition.dragOffset = transition.offsetAnimatable.value
- }
+ // We don't know where we are transitioning to yet given that the drag just started, so set
+ // it to fromScene, which will effectively be treated the same as Idle(fromScene).
+ swipeTransition._toScene = fromScene
- return
- }
+ swipeTransition.stopOffsetAnimation()
+ swipeTransition.dragOffset = 0f
- // TODO(b/290184746): Better handle interruptions here if state != idle.
+ // Use the layout size in the swipe orientation for swipe distance.
+ // TODO(b/290184746): Also handle custom distances for transitions. With smaller distances,
+ // we will also have to make sure that we correctly handle overscroll.
+ swipeTransition.absoluteDistance =
+ when (orientation) {
+ Orientation.Horizontal -> layoutImpl.size.width
+ Orientation.Vertical -> layoutImpl.size.height
+ }.toFloat()
- val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
+ if (swipeTransition.absoluteDistance > 0f) {
+ transitionState = swipeTransition
+ }
+ }
- transition._currentScene = fromScene
- transition._fromScene = fromScene
+ internal fun onDrag(delta: Float) {
+ swipeTransition.dragOffset += delta
- // We don't know where we are transitioning to yet given that the drag just started, so set it
- // to fromScene, which will effectively be treated the same as Idle(fromScene).
- transition._toScene = fromScene
+ // First check transition.fromScene should be changed for the case where the user quickly
+ // swiped twice in a row to accelerate the transition and go from A => B then B => C really
+ // fast.
+ maybeHandleAcceleratedSwipe()
- transition.stopOffsetAnimation()
- transition.dragOffset = 0f
+ val offset = swipeTransition.dragOffset
+ val fromScene = swipeTransition._fromScene
- // Use the layout size in the swipe orientation for swipe distance.
- // TODO(b/290184746): Also handle custom distances for transitions. With smaller distances, we
- // will also have to make sure that we correctly handle overscroll.
- transition.absoluteDistance =
- when (orientation) {
- Orientation.Horizontal -> layoutImpl.size.width
- Orientation.Vertical -> layoutImpl.size.height
- }.toFloat()
+ // Compute the target scene depending on the current offset.
+ val target = fromScene.findTargetSceneAndDistance(offset)
- if (transition.absoluteDistance > 0f) {
- layoutImpl.state.transitionState = transition
- }
-}
+ if (swipeTransition._toScene.key != target.sceneKey) {
+ swipeTransition._toScene = layoutImpl.scenes.getValue(target.sceneKey)
+ }
-private fun onDrag(
- layoutImpl: SceneTransitionLayoutImpl,
- transition: SwipeTransition,
- orientation: Orientation,
- delta: Float,
-) {
- transition.dragOffset += delta
+ if (swipeTransition._distance != target.distance) {
+ swipeTransition._distance = target.distance
+ }
+ }
- // First check transition.fromScene should be changed for the case where the user quickly swiped
- // twice in a row to accelerate the transition and go from A => B then B => C really fast.
- maybeHandleAcceleratedSwipe(transition, orientation)
+ /**
+ * Change fromScene in the case where the user quickly swiped multiple times in the same
+ * direction to accelerate the transition from A => B then B => C.
+ */
+ private fun maybeHandleAcceleratedSwipe() {
+ val toScene = swipeTransition._toScene
+ val fromScene = swipeTransition._fromScene
- val offset = transition.dragOffset
- val fromScene = transition._fromScene
+ // If the swipe was not committed, don't do anything.
+ if (fromScene == toScene || swipeTransition._currentScene != toScene) {
+ return
+ }
- // Compute the target scene depending on the current offset.
- val target = fromScene.findTargetSceneAndDistance(orientation, offset, layoutImpl)
+ // If the offset is past the distance then let's change fromScene so that the user can swipe
+ // to the next screen or go back to the previous one.
+ val offset = swipeTransition.dragOffset
+ val absoluteDistance = swipeTransition.absoluteDistance
+ if (offset <= -absoluteDistance && fromScene.upOrLeft(orientation) == toScene.key) {
+ swipeTransition.dragOffset += absoluteDistance
+ swipeTransition._fromScene = toScene
+ } else if (
+ offset >= absoluteDistance && fromScene.downOrRight(orientation) == toScene.key
+ ) {
+ swipeTransition.dragOffset -= absoluteDistance
+ swipeTransition._fromScene = toScene
+ }
- if (transition._toScene.key != target.sceneKey) {
- transition._toScene = layoutImpl.scenes.getValue(target.sceneKey)
+ // Important note: toScene and distance will be updated right after this function is called,
+ // using fromScene and dragOffset.
}
- if (transition._distance != target.distance) {
- transition._distance = target.distance
+ private class TargetScene(
+ val sceneKey: SceneKey,
+ val distance: Float,
+ )
+
+ private fun Scene.findTargetSceneAndDistance(directionOffset: Float): TargetScene {
+ val maxDistance =
+ when (orientation) {
+ Orientation.Horizontal -> layoutImpl.size.width
+ Orientation.Vertical -> layoutImpl.size.height
+ }.toFloat()
+
+ val upOrLeft = upOrLeft(orientation)
+ val downOrRight = downOrRight(orientation)
+
+ // Compute the target scene depending on the current offset.
+ return when {
+ directionOffset < 0f && upOrLeft != null -> {
+ TargetScene(
+ sceneKey = upOrLeft,
+ distance = -maxDistance,
+ )
+ }
+ directionOffset > 0f && downOrRight != null -> {
+ TargetScene(
+ sceneKey = downOrRight,
+ distance = maxDistance,
+ )
+ }
+ else -> {
+ TargetScene(
+ sceneKey = key,
+ distance = 0f,
+ )
+ }
+ }
}
-}
-/**
- * Change fromScene in the case where the user quickly swiped multiple times in the same direction
- * to accelerate the transition from A => B then B => C.
- */
-private fun maybeHandleAcceleratedSwipe(
- transition: SwipeTransition,
- orientation: Orientation,
-) {
- val toScene = transition._toScene
- val fromScene = transition._fromScene
+ internal fun onDragStopped(velocity: Float, canChangeScene: Boolean) {
+ // The state was changed since the drag started; don't do anything.
+ if (!isDrivingTransition) {
+ return
+ }
- // If the swipe was not committed, don't do anything.
- if (fromScene == toScene || transition._currentScene != toScene) {
- return
- }
+ // We were not animating.
+ if (swipeTransition._fromScene == swipeTransition._toScene) {
+ transitionState = TransitionState.Idle(swipeTransition._fromScene.key)
+ return
+ }
- // If the offset is past the distance then let's change fromScene so that the user can swipe to
- // the next screen or go back to the previous one.
- val offset = transition.dragOffset
- val absoluteDistance = transition.absoluteDistance
- if (offset <= -absoluteDistance && fromScene.upOrLeft(orientation) == toScene.key) {
- transition.dragOffset += absoluteDistance
- transition._fromScene = toScene
- } else if (offset >= absoluteDistance && fromScene.downOrRight(orientation) == toScene.key) {
- transition.dragOffset -= absoluteDistance
- transition._fromScene = toScene
- }
+ // Compute the destination scene (and therefore offset) to settle in.
+ val targetOffset: Float
+ val targetScene: Scene
+ val offset = swipeTransition.dragOffset
+ val distance = swipeTransition.distance
+ if (
+ canChangeScene &&
+ shouldCommitSwipe(
+ offset,
+ distance,
+ velocity,
+ wasCommitted = swipeTransition._currentScene == swipeTransition._toScene,
+ )
+ ) {
+ targetOffset = distance
+ targetScene = swipeTransition._toScene
+ } else {
+ targetOffset = 0f
+ targetScene = swipeTransition._fromScene
+ }
- // Important note: toScene and distance will be updated right after this function is called,
- // using fromScene and dragOffset.
-}
+ // If the effective current scene changed, it should be reflected right now in the current
+ // scene state, even before the settle animation is ongoing. That way all the swipeables and
+ // back handlers will be refreshed and the user can for instance quickly swipe vertically
+ // from A => B then horizontally from B => C, or swipe from A => B then immediately go back
+ // B => A.
+ if (targetScene != swipeTransition._currentScene) {
+ swipeTransition._currentScene = targetScene
+ layoutImpl.onChangeScene(targetScene.key)
+ }
-private data class TargetScene(
- val sceneKey: SceneKey,
- val distance: Float,
-)
+ animateOffset(
+ initialVelocity = velocity,
+ targetOffset = targetOffset,
+ targetScene = targetScene.key
+ )
+ }
-private fun Scene.findTargetSceneAndDistance(
- orientation: Orientation,
- directionOffset: Float,
- layoutImpl: SceneTransitionLayoutImpl,
-): TargetScene {
- val maxDistance =
- when (orientation) {
- Orientation.Horizontal -> layoutImpl.size.width
- Orientation.Vertical -> layoutImpl.size.height
- }.toFloat()
-
- val upOrLeft = upOrLeft(orientation)
- val downOrRight = downOrRight(orientation)
-
- // Compute the target scene depending on the current offset.
- return when {
- directionOffset < 0f && upOrLeft != null -> {
- TargetScene(
- sceneKey = upOrLeft,
- distance = -maxDistance,
- )
+ /**
+ * Whether the swipe to the target scene should be committed or not. This is inspired by
+ * SwipeableV2.computeTarget().
+ */
+ private fun shouldCommitSwipe(
+ offset: Float,
+ distance: Float,
+ velocity: Float,
+ wasCommitted: Boolean,
+ ): Boolean {
+ fun isCloserToTarget(): Boolean {
+ return (offset - distance).absoluteValue < offset.absoluteValue
}
- directionOffset > 0f && downOrRight != null -> {
- TargetScene(
- sceneKey = downOrRight,
- distance = maxDistance,
- )
+
+ // Swiping up or left.
+ if (distance < 0f) {
+ return if (offset > 0f || velocity >= velocityThreshold) {
+ false
+ } else {
+ velocity <= -velocityThreshold ||
+ (offset <= -positionalThreshold && !wasCommitted) ||
+ isCloserToTarget()
+ }
}
- else -> {
- TargetScene(
- sceneKey = key,
- distance = 0f,
- )
+
+ // Swiping down or right.
+ return if (offset < 0f || velocity <= -velocityThreshold) {
+ false
+ } else {
+ velocity >= velocityThreshold ||
+ (offset >= positionalThreshold && !wasCommitted) ||
+ isCloserToTarget()
}
}
-}
-private fun CoroutineScope.onDragStopped(
- layoutImpl: SceneTransitionLayoutImpl,
- transition: SwipeTransition,
- velocity: Float,
- velocityThreshold: Float,
- positionalThreshold: Float,
- canChangeScene: Boolean = true,
-) {
- // The state was changed since the drag started; don't do anything.
- if (layoutImpl.state.transitionState != transition) {
- return
- }
+ private fun animateOffset(
+ initialVelocity: Float,
+ targetOffset: Float,
+ targetScene: SceneKey,
+ ) {
+ swipeTransition.startOffsetAnimation {
+ coroutineScope
+ .launch {
+ if (!isAnimatingOffset) {
+ swipeTransition.offsetAnimatable.snapTo(swipeTransition.dragOffset)
+ }
+ isAnimatingOffset = true
+
+ swipeTransition.offsetAnimatable.animateTo(
+ targetOffset,
+ // TODO(b/290184746): Make this spring spec configurable.
+ spring(
+ stiffness = Spring.StiffnessMediumLow,
+ visibilityThreshold = OffsetVisibilityThreshold
+ ),
+ initialVelocity = initialVelocity,
+ )
- // We were not animating.
- if (transition._fromScene == transition._toScene) {
- layoutImpl.state.transitionState = TransitionState.Idle(transition._fromScene.key)
- return
+ // Now that the animation is done, the state should be idle. Note that if the
+ // state was changed since this animation started, some external code changed it
+ // and we shouldn't do anything here. Note also that this job will be cancelled
+ // in the case where the user intercepts this swipe.
+ if (isDrivingTransition) {
+ transitionState = TransitionState.Idle(targetScene)
+ }
+ }
+ .also { it.invokeOnCompletion { isAnimatingOffset = false } }
+ }
}
- // Compute the destination scene (and therefore offset) to settle in.
- val targetScene: Scene
- val targetOffset: Float
- val offset = transition.dragOffset
- val distance = transition.distance
- if (
- canChangeScene &&
- shouldCommitSwipe(
- offset,
- distance,
- velocity,
- velocityThreshold,
- positionalThreshold,
- wasCommitted = transition._currentScene == transition._toScene,
- )
- ) {
- targetOffset = distance
- targetScene = transition._toScene
- } else {
- targetOffset = 0f
- targetScene = transition._fromScene
- }
+ internal fun animateOverscroll(velocity: Velocity): Velocity {
+ val velocityAmount =
+ when (orientation) {
+ Orientation.Vertical -> velocity.y
+ Orientation.Horizontal -> velocity.x
+ }
- // If the effective current scene changed, it should be reflected right now in the current scene
- // state, even before the settle animation is ongoing. That way all the swipeables and back
- // handlers will be refreshed and the user can for instance quickly swipe vertically from A => B
- // then horizontally from B => C, or swipe from A => B then immediately go back B => A.
- if (targetScene != transition._currentScene) {
- transition._currentScene = targetScene
- layoutImpl.onChangeScene(targetScene.key)
- }
+ if (velocityAmount == 0f) {
+ // There is no remaining velocity
+ return Velocity.Zero
+ }
- animateOffset(
- transition = transition,
- layoutImpl = layoutImpl,
- initialVelocity = velocity,
- targetOffset = targetOffset,
- targetScene = targetScene.key
- )
-}
+ val fromScene = currentScene
+ val target = fromScene.findTargetSceneAndDistance(velocityAmount)
+ val isValidTarget = target.distance != 0f && target.sceneKey != fromScene.key
-/**
- * Whether the swipe to the target scene should be committed or not. This is inspired by
- * SwipeableV2.computeTarget().
- */
-private fun shouldCommitSwipe(
- offset: Float,
- distance: Float,
- velocity: Float,
- velocityThreshold: Float,
- positionalThreshold: Float,
- wasCommitted: Boolean,
-): Boolean {
- fun isCloserToTarget(): Boolean {
- return (offset - distance).absoluteValue < offset.absoluteValue
+ if (!isValidTarget || isDrivingTransition) {
+ // We have not found a valid target or we are already in a transition
+ return Velocity.Zero
+ }
+
+ swipeTransition._currentScene = fromScene
+ swipeTransition._fromScene = fromScene
+ swipeTransition._toScene = layoutImpl.scene(target.sceneKey)
+ swipeTransition._distance = target.distance
+ swipeTransition.absoluteDistance = target.distance.absoluteValue
+ swipeTransition.stopOffsetAnimation()
+ swipeTransition.dragOffset = 0f
+
+ transitionState = swipeTransition
+
+ animateOffset(
+ initialVelocity = velocityAmount,
+ targetOffset = 0f,
+ targetScene = fromScene.key
+ )
+
+ // The animateOffset animation consumes any remaining velocity.
+ return velocity
}
- // Swiping up or left.
- if (distance < 0f) {
- return if (offset > 0f || velocity >= velocityThreshold) {
- false
- } else {
- velocity <= -velocityThreshold ||
- (offset <= -positionalThreshold && !wasCommitted) ||
- isCloserToTarget()
+ private class SwipeTransition(initialScene: Scene) : TransitionState.Transition {
+ var _currentScene by mutableStateOf(initialScene)
+ override val currentScene: SceneKey
+ get() = _currentScene.key
+
+ var _fromScene by mutableStateOf(initialScene)
+ override val fromScene: SceneKey
+ get() = _fromScene.key
+
+ var _toScene by mutableStateOf(initialScene)
+ override val toScene: SceneKey
+ get() = _toScene.key
+
+ override val progress: Float
+ get() {
+ val offset = if (isAnimatingOffset) offsetAnimatable.value else dragOffset
+ if (distance == 0f) {
+ // This can happen only if fromScene == toScene.
+ error(
+ "Transition.progress should be called only when Transition.fromScene != " +
+ "Transition.toScene"
+ )
+ }
+ return offset / distance
+ }
+
+ override val isUserInputDriven = true
+
+ /** The current offset caused by the drag gesture. */
+ var dragOffset by mutableFloatStateOf(0f)
+
+ /**
+ * Whether the offset is animated (the user lifted their finger) or if it is driven by
+ * gesture.
+ */
+ var isAnimatingOffset by mutableStateOf(false)
+
+ /** The animatable used to animate the offset once the user lifted its finger. */
+ val offsetAnimatable = Animatable(0f, OffsetVisibilityThreshold)
+
+ /** Job to check that there is at most one offset animation in progress. */
+ private var offsetAnimationJob: Job? = null
+
+ /** Ends any previous [offsetAnimationJob] and runs the new [job]. */
+ fun startOffsetAnimation(job: () -> Job) {
+ stopOffsetAnimation()
+ offsetAnimationJob = job()
}
- }
- // Swiping down or right.
- return if (offset < 0f || velocity <= -velocityThreshold) {
- false
- } else {
- velocity >= velocityThreshold ||
- (offset >= positionalThreshold && !wasCommitted) ||
- isCloserToTarget()
+ /** Stops any ongoing offset animation. */
+ fun stopOffsetAnimation() {
+ offsetAnimationJob?.cancel()
+ }
+
+ /** The absolute distance between [fromScene] and [toScene]. */
+ var absoluteDistance = 0f
+
+ /**
+ * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is
+ * above or to the left of [toScene].
+ */
+ var _distance by mutableFloatStateOf(0f)
+ val distance: Float
+ get() = _distance
}
}
-private fun CoroutineScope.animateOffset(
- transition: SwipeTransition,
- layoutImpl: SceneTransitionLayoutImpl,
- initialVelocity: Float,
- targetOffset: Float,
- targetScene: SceneKey,
-) {
- transition.startOffsetAnimation {
- launch {
- if (!transition.isAnimatingOffset) {
- transition.offsetAnimatable.snapTo(transition.dragOffset)
- }
- transition.isAnimatingOffset = true
-
- transition.offsetAnimatable.animateTo(
- targetOffset,
- // TODO(b/290184746): Make this spring spec configurable.
- spring(
- stiffness = Spring.StiffnessMediumLow,
- visibilityThreshold = OffsetVisibilityThreshold
- ),
- initialVelocity = initialVelocity,
- )
+private class SceneDraggableHandler(
+ private val gestureHandler: SceneGestureHandler,
+) : DraggableHandler {
+ override suspend fun onDragStarted(coroutineScope: CoroutineScope, startedPosition: Offset) {
+ gestureHandler.onDragStarted()
+ }
- // Now that the animation is done, the state should be idle. Note that if the state
- // was changed since this animation started, some external code changed it and we
- // shouldn't do anything here. Note also that this job will be cancelled in the case
- // where the user intercepts this swipe.
- if (layoutImpl.state.transitionState == transition) {
- layoutImpl.state.transitionState = TransitionState.Idle(targetScene)
- }
- }
- .also { it.invokeOnCompletion { transition.isAnimatingOffset = false } }
+ override fun onDelta(pixels: Float) {
+ gestureHandler.onDrag(delta = pixels)
+ }
+
+ override suspend fun onDragStopped(coroutineScope: CoroutineScope, velocity: Float) {
+ gestureHandler.onDragStopped(velocity = velocity, canChangeScene = true)
}
}
-private fun CoroutineScope.animateOverscroll(
- layoutImpl: SceneTransitionLayoutImpl,
- transition: SwipeTransition,
- velocity: Velocity,
- orientation: Orientation,
-): Velocity {
- val velocityAmount =
- when (orientation) {
- Orientation.Vertical -> velocity.y
- Orientation.Horizontal -> velocity.x
+@VisibleForTesting
+class SceneNestedScrollHandler(
+ private val gestureHandler: SceneGestureHandler,
+) : NestedScrollHandler {
+ override val connection: PriorityPostNestedScrollConnection = nestedScrollConnection()
+
+ private fun Offset.toAmount() =
+ when (gestureHandler.orientation) {
+ Orientation.Horizontal -> x
+ Orientation.Vertical -> y
}
- if (velocityAmount == 0f) {
- // There is no remaining velocity
- return Velocity.Zero
- }
+ private fun Velocity.toAmount() =
+ when (gestureHandler.orientation) {
+ Orientation.Horizontal -> x
+ Orientation.Vertical -> y
+ }
- val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
- val target = fromScene.findTargetSceneAndDistance(orientation, velocityAmount, layoutImpl)
- val isValidTarget = target.distance != 0f && target.sceneKey != fromScene.key
+ private fun Float.toOffset() =
+ when (gestureHandler.orientation) {
+ Orientation.Horizontal -> Offset(x = this, y = 0f)
+ Orientation.Vertical -> Offset(x = 0f, y = this)
+ }
- if (!isValidTarget || layoutImpl.state.transitionState == transition) {
- // We have not found a valid target or we are already in a transition
- return Velocity.Zero
- }
+ private fun nestedScrollConnection(): PriorityPostNestedScrollConnection {
+ // The next potential scene is calculated during the canStart
+ var nextScene: SceneKey? = null
- transition._currentScene = fromScene
- transition._fromScene = fromScene
- transition._toScene = layoutImpl.scene(target.sceneKey)
- transition._distance = target.distance
- transition.absoluteDistance = target.distance.absoluteValue
- transition.stopOffsetAnimation()
- transition.dragOffset = 0f
-
- layoutImpl.state.transitionState = transition
-
- animateOffset(
- transition = transition,
- layoutImpl = layoutImpl,
- initialVelocity = velocityAmount,
- targetOffset = 0f,
- targetScene = fromScene.key
- )
+ // This is the scene on which we will have priority during the scroll gesture.
+ var priorityScene: SceneKey? = null
- // The animateOffset animation consumes any remaining velocity.
- return velocity
-}
+ // If we performed a long gesture before entering priority mode, we would have to avoid
+ // moving on to the next scene.
+ var gestureStartedOnNestedChild = false
-/**
- * The number of pixels below which there won't be a visible difference in the transition and from
- * which the animation can stop.
- */
-private const val OffsetVisibilityThreshold = 0.5f
+ return PriorityPostNestedScrollConnection(
+ canStart = { offsetAvailable, offsetBeforeStart ->
+ val amount = offsetAvailable.toAmount()
+ if (amount == 0f) return@PriorityPostNestedScrollConnection false
-@Composable
-private fun rememberSwipeToSceneNestedScrollConnection(
- orientation: Orientation,
- coroutineScope: CoroutineScope,
- draggableState: DraggableState,
- transition: SwipeTransition,
- layoutImpl: SceneTransitionLayoutImpl,
- velocityThreshold: Float,
- positionalThreshold: Float,
-): PriorityPostNestedScrollConnection {
- val density = LocalDensity.current
- val scrollConnection =
- remember(
- orientation,
- coroutineScope,
- draggableState,
- transition,
- layoutImpl,
- velocityThreshold,
- positionalThreshold,
- density,
- ) {
- fun Offset.toAmount() =
- when (orientation) {
- Orientation.Horizontal -> x
- Orientation.Vertical -> y
- }
+ gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
- fun Velocity.toAmount() =
- when (orientation) {
- Orientation.Horizontal -> x
- Orientation.Vertical -> y
- }
+ val fromScene = gestureHandler.currentScene
+ nextScene =
+ when {
+ amount < 0f -> fromScene.upOrLeft(gestureHandler.orientation)
+ amount > 0f -> fromScene.downOrRight(gestureHandler.orientation)
+ else -> null
+ }
- fun Float.toOffset() =
- when (orientation) {
- Orientation.Horizontal -> Offset(x = this, y = 0f)
- Orientation.Vertical -> Offset(x = 0f, y = this)
- }
+ nextScene != null
+ },
+ canContinueScroll = { priorityScene == gestureHandler.swipeTransitionToScene.key },
+ onStart = {
+ priorityScene = nextScene
+ gestureHandler.onDragStarted()
+ },
+ onScroll = { offsetAvailable ->
+ val amount = offsetAvailable.toAmount()
- // The next potential scene is calculated during the canStart
- var nextScene: SceneKey? = null
-
- // This is the scene on which we will have priority during the scroll gesture.
- var priorityScene: SceneKey? = null
-
- // If we performed a long gesture before entering priority mode, we would have to avoid
- // moving on to the next scene.
- var gestureStartedOnNestedChild = false
-
- PriorityPostNestedScrollConnection(
- canStart = { offsetAvailable, offsetBeforeStart ->
- val amount = offsetAvailable.toAmount()
- if (amount == 0f) return@PriorityPostNestedScrollConnection false
-
- gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
-
- val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
- nextScene =
- when {
- amount < 0f -> fromScene.upOrLeft(orientation)
- amount > 0f -> fromScene.downOrRight(orientation)
- else -> null
- }
-
- nextScene != null
- },
- canContinueScroll = { priorityScene == transition._toScene.key },
- onStart = {
- priorityScene = nextScene
- onDragStarted(layoutImpl, transition, orientation)
- },
- onScroll = { offsetAvailable ->
- val amount = offsetAvailable.toAmount()
-
- // TODO(b/297842071) We should handle the overscroll or slow drag if the gesture
- // is initiated in a nested child.
-
- // Appends a new coroutine to attempt to drag by [amount] px. In this case we
- // are assuming that the [coroutineScope] is tied to the main thread and that
- // calls to [launch] are therefore queued.
- coroutineScope.launch { draggableState.drag { dragBy(amount) } }
-
- amount.toOffset()
- },
- onStop = { velocityAvailable ->
- priorityScene = null
-
- coroutineScope.onDragStopped(
- layoutImpl = layoutImpl,
- transition = transition,
- velocity = velocityAvailable.toAmount(),
- velocityThreshold = velocityThreshold,
- positionalThreshold = positionalThreshold,
- canChangeScene = !gestureStartedOnNestedChild
- )
+ // TODO(b/297842071) We should handle the overscroll or slow drag if the gesture is
+ // initiated in a nested child.
+ gestureHandler.onDrag(amount)
- // The onDragStopped animation consumes any remaining velocity.
- velocityAvailable
- },
- onPostFling = { velocityAvailable ->
- // If there is any velocity left, we can try running an overscroll animation
- // between scenes.
- coroutineScope.animateOverscroll(
- layoutImpl = layoutImpl,
- transition = transition,
- velocity = velocityAvailable,
- orientation = orientation
- )
- },
- )
- }
- DisposableEffect(scrollConnection) {
- onDispose {
- coroutineScope.launch {
- // This should ensure that the draggableState is in a consistent state and that it
- // does not cause any unexpected behavior.
- scrollConnection.reset()
- }
- }
+ amount.toOffset()
+ },
+ onStop = { velocityAvailable ->
+ priorityScene = null
+
+ gestureHandler.onDragStopped(
+ velocity = velocityAvailable.toAmount(),
+ canChangeScene = !gestureStartedOnNestedChild
+ )
+
+ // The onDragStopped animation consumes any remaining velocity.
+ velocityAvailable
+ },
+ onPostFling = { velocityAvailable ->
+ // If there is any velocity left, we can try running an overscroll animation between
+ // scenes.
+ gestureHandler.animateOverscroll(velocity = velocityAvailable)
+ },
+ )
}
- return scrollConnection
}
+
+/**
+ * The number of pixels below which there won't be a visible difference in the transition and from
+ * which the animation can stop.
+ */
+private const val OffsetVisibilityThreshold = 0.5f
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt
new file mode 100644
index 000000000000..3e0f7ba1bf78
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt
@@ -0,0 +1,284 @@
+package com.android.compose.animation.scene
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.Velocity
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.TestScenes.SceneA
+import com.android.compose.animation.scene.TestScenes.SceneB
+import com.android.compose.animation.scene.TestScenes.SceneC
+import com.android.compose.animation.scene.TransitionState.Idle
+import com.android.compose.animation.scene.TransitionState.Transition
+import com.android.compose.test.MonotonicClockTestScope
+import com.android.compose.test.runMonotonicClockTest
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val SCREEN_SIZE = 100f
+
+@RunWith(AndroidJUnit4::class)
+class SceneGestureHandlerTest {
+ private class TestGestureScope(
+ val coroutineScope: MonotonicClockTestScope,
+ ) {
+ private var internalCurrentScene: SceneKey by mutableStateOf(SceneA)
+
+ private val layoutState: SceneTransitionLayoutState =
+ SceneTransitionLayoutState(internalCurrentScene)
+
+ private val scenesBuilder: SceneTransitionLayoutScope.() -> Unit = {
+ scene(
+ key = SceneA,
+ userActions = mapOf(Swipe.Up to SceneB, Swipe.Down to SceneC),
+ ) {
+ Text("SceneA")
+ }
+ scene(SceneB) { Text("SceneB") }
+ scene(SceneC) { Text("SceneC") }
+ }
+
+ private val sceneGestureHandler =
+ SceneGestureHandler(
+ layoutImpl =
+ SceneTransitionLayoutImpl(
+ onChangeScene = { internalCurrentScene = it },
+ builder = scenesBuilder,
+ transitions = EmptyTestTransitions,
+ state = layoutState,
+ density = Density(1f)
+ )
+ .also { it.size = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt()) },
+ orientation = Orientation.Vertical,
+ coroutineScope = coroutineScope,
+ )
+
+ val draggable = sceneGestureHandler.draggable
+
+ val nestedScroll = sceneGestureHandler.nestedScroll.connection
+
+ val velocityThreshold = sceneGestureHandler.velocityThreshold
+
+ // 10% of the screen
+ val deltaInPixels10 = SCREEN_SIZE * 0.1f
+
+ // Offset y: 10% of the screen
+ val offsetY10 = Offset(x = 0f, y = deltaInPixels10)
+
+ val transitionState: TransitionState
+ get() = layoutState.transitionState
+
+ fun advanceUntilIdle() {
+ coroutineScope.testScheduler.advanceUntilIdle()
+ }
+
+ fun assertScene(currentScene: SceneKey, isIdle: Boolean) {
+ val idleMsg = if (isIdle) "MUST" else "MUST NOT"
+ assertWithMessage("transitionState $idleMsg be Idle")
+ .that(transitionState is Idle)
+ .isEqualTo(isIdle)
+ assertThat(transitionState.currentScene).isEqualTo(currentScene)
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ private fun runGestureTest(block: suspend TestGestureScope.() -> Unit) {
+ runMonotonicClockTest { TestGestureScope(coroutineScope = this).block() }
+ }
+
+ @Test
+ fun testPreconditions() = runGestureTest { assertScene(currentScene = SceneA, isIdle = true) }
+
+ @Test
+ fun onDragStarted_shouldStartATransition() = runGestureTest {
+ draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
+ assertScene(currentScene = SceneA, isIdle = false)
+ }
+
+ @Test
+ fun afterSceneTransitionIsStarted_interceptDragEvents() = runGestureTest {
+ draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
+ assertScene(currentScene = SceneA, isIdle = false)
+ val transition = transitionState as Transition
+
+ draggable.onDelta(pixels = deltaInPixels10)
+ assertThat(transition.progress).isEqualTo(0.1f)
+
+ draggable.onDelta(pixels = deltaInPixels10)
+ assertThat(transition.progress).isEqualTo(0.2f)
+ }
+
+ @Test
+ fun onDragStoppedAfterDrag_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
+ draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ draggable.onDelta(pixels = deltaInPixels10)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ draggable.onDragStopped(
+ coroutineScope = coroutineScope,
+ velocity = velocityThreshold - 0.01f,
+ )
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ // wait for the stop animation
+ advanceUntilIdle()
+ assertScene(currentScene = SceneA, isIdle = true)
+ }
+
+ @Test
+ fun onDragStoppedAfterDrag_velocityAtLeastThreshold_goToNextScene() = runGestureTest {
+ draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ draggable.onDelta(pixels = deltaInPixels10)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ draggable.onDragStopped(
+ coroutineScope = coroutineScope,
+ velocity = velocityThreshold,
+ )
+ assertScene(currentScene = SceneC, isIdle = false)
+
+ // wait for the stop animation
+ advanceUntilIdle()
+ assertScene(currentScene = SceneC, isIdle = true)
+ }
+
+ @Test
+ fun onDragStoppedAfterStarted_returnImmediatelyToIdle() = runGestureTest {
+ draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ draggable.onDragStopped(coroutineScope = coroutineScope, velocity = 0f)
+ assertScene(currentScene = SceneA, isIdle = true)
+ }
+
+ @Test
+ fun onInitialPreScroll_doNotChangeState() = runGestureTest {
+ nestedScroll.onPreScroll(available = offsetY10, source = NestedScrollSource.Drag)
+ assertScene(currentScene = SceneA, isIdle = true)
+ }
+
+ @Test
+ fun onPostScrollWithNothingAvailable_doNotChangeState() = runGestureTest {
+ val consumed =
+ nestedScroll.onPostScroll(
+ consumed = Offset.Zero,
+ available = Offset.Zero,
+ source = NestedScrollSource.Drag
+ )
+
+ assertScene(currentScene = SceneA, isIdle = true)
+ assertThat(consumed).isEqualTo(Offset.Zero)
+ }
+
+ @Test
+ fun onPostScrollWithSomethingAvailable_startSceneTransition() = runGestureTest {
+ val consumed =
+ nestedScroll.onPostScroll(
+ consumed = Offset.Zero,
+ available = offsetY10,
+ source = NestedScrollSource.Drag
+ )
+
+ assertScene(currentScene = SceneA, isIdle = false)
+ val transition = transitionState as Transition
+ assertThat(transition.progress).isEqualTo(0.1f)
+ assertThat(consumed).isEqualTo(offsetY10)
+ }
+
+ private fun TestGestureScope.nestedScrollEvents(
+ available: Offset,
+ consumedByScroll: Offset = Offset.Zero,
+ ) {
+ val consumedByPreScroll =
+ nestedScroll.onPreScroll(available = available, source = NestedScrollSource.Drag)
+ val consumed = consumedByPreScroll + consumedByScroll
+ nestedScroll.onPostScroll(
+ consumed = consumed,
+ available = available - consumed,
+ source = NestedScrollSource.Drag
+ )
+ }
+
+ @Test
+ fun afterSceneTransitionIsStarted_interceptPreScrollEvents() = runGestureTest {
+ nestedScrollEvents(available = offsetY10)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ val transition = transitionState as Transition
+ assertThat(transition.progress).isEqualTo(0.1f)
+
+ // start intercept preScroll
+ val consumed =
+ nestedScroll.onPreScroll(available = offsetY10, source = NestedScrollSource.Drag)
+ assertThat(transition.progress).isEqualTo(0.2f)
+
+ // do nothing on postScroll
+ nestedScroll.onPostScroll(
+ consumed = consumed,
+ available = Offset.Zero,
+ source = NestedScrollSource.Drag
+ )
+ assertThat(transition.progress).isEqualTo(0.2f)
+
+ nestedScrollEvents(available = offsetY10)
+ assertThat(transition.progress).isEqualTo(0.3f)
+ assertScene(currentScene = SceneA, isIdle = false)
+ }
+
+ @Test
+ fun onPreFling_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
+ nestedScrollEvents(available = offsetY10)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ nestedScroll.onPreFling(available = Velocity.Zero)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ // wait for the stop animation
+ advanceUntilIdle()
+ assertScene(currentScene = SceneA, isIdle = true)
+ }
+
+ @Test
+ fun onPreFling_velocityAtLeastThreshold_goToNextScene() = runGestureTest {
+ nestedScrollEvents(available = offsetY10)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))
+ assertScene(currentScene = SceneC, isIdle = false)
+
+ // wait for the stop animation
+ advanceUntilIdle()
+ assertScene(currentScene = SceneC, isIdle = true)
+ }
+
+ @Test
+ fun scrollStartedInScene_doOverscrollAnimation() = runGestureTest {
+ // we started the scroll in the scene
+ nestedScrollEvents(available = offsetY10, consumedByScroll = offsetY10)
+
+ // now we can intercept the scroll events
+ nestedScrollEvents(available = offsetY10)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))
+ // should start an overscroll animation (the gesture started in the scene)
+ assertScene(currentScene = SceneA, isIdle = false)
+
+ // wait for the stop animation
+ advanceUntilIdle()
+ assertScene(currentScene = SceneA, isIdle = true)
+ }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/RunMonotonicClockTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/RunMonotonicClockTest.kt
new file mode 100644
index 000000000000..cb122dc8e25e
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/test/RunMonotonicClockTest.kt
@@ -0,0 +1,27 @@
+package com.android.compose.test
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.TestMonotonicFrameClock
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+
+/**
+ * This method creates a [CoroutineScope] that can be used in animations created in a composable
+ * function.
+ *
+ * The [TestCoroutineScheduler] is passed to provide the functionality to wait for idle.
+ */
+@ExperimentalTestApi
+fun runMonotonicClockTest(block: suspend MonotonicClockTestScope.() -> Unit) = runTest {
+ // We need a CoroutineScope (like a TestScope) to create a TestMonotonicFrameClock.
+ withContext(TestMonotonicFrameClock(this)) {
+ MonotonicClockTestScope(coroutineScope = this, testScheduler = testScheduler).block()
+ }
+}
+
+class MonotonicClockTestScope(
+ coroutineScope: CoroutineScope,
+ val testScheduler: TestCoroutineScheduler
+) : CoroutineScope by coroutineScope