diff options
| author | 2023-10-23 08:46:10 +0000 | |
|---|---|---|
| committer | 2023-10-23 08:46:10 +0000 | |
| commit | 65c80f3144d300d6ed4695fb13448ebaedb61cd8 (patch) | |
| tree | 46fb888423e84fd0db2d79709f2510d83095b02d | |
| parent | fb54dc0b9785e97ce15be08bd85417a169f5ae42 (diff) | |
| parent | 2c02d345b36b503dbe967d9c042871ccd177d819 (diff) | |
Merge "Introduction of the GestureHandler interface" into main
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 |