Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt +16 −7 Original line number Diff line number Diff line Loading @@ -21,6 +21,7 @@ import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.SpringSpec import kotlin.math.absoluteValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch /** Loading Loading @@ -147,13 +148,16 @@ private fun CoroutineScope.animate( } // Animate the progress to its target value. transition.job = launch { animatable.animateTo(targetProgress, animationSpec) } .invokeOnCompletion { // Settle the state to Idle(target). Note that this will do nothing if this transition // was replaced/interrupted by another one, and this also runs if this coroutine is // cancelled, i.e. if [this] coroutine scope is cancelled. .apply { invokeOnCompletion { // Settle the state to Idle(target). Note that this will do nothing if this // transition was replaced/interrupted by another one, and this also runs if // this coroutine is cancelled, i.e. if [this] coroutine scope is cancelled. layoutState.finishTransition(transition, target) } } return transition } Loading @@ -174,8 +178,13 @@ private class OneOffTransition( */ lateinit var animatable: Animatable<Float, AnimationVector1D> /** The job that is animating [animatable]. */ lateinit var job: Job override val progress: Float get() = animatable.value override fun finish(): Job = job } // TODO(b/290184746): Compute a good default visibility threshold that depends on the layout size Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +98 −32 Original line number Diff line number Diff line Loading @@ -97,9 +97,15 @@ internal class DraggableHandlerImpl( return false } val swipeTransition = dragController.swipeTransition // Don't intercept a transition that is finishing. if (swipeTransition.isFinishing) { return false } // Only intercept the current transition if one of the 2 swipes results is also a transition // between the same pair of scenes. val swipeTransition = dragController.swipeTransition val fromScene = swipeTransition._currentScene val swipes = computeSwipes(fromScene, startedPosition, pointersDown = 1) val (upOrLeft, downOrRight) = swipes.computeSwipesResults(fromScene) Loading Loading @@ -150,15 +156,24 @@ internal class DraggableHandlerImpl( val fromScene = layoutImpl.scene(transitionState.currentScene) val swipes = computeSwipes(fromScene, startedPosition, pointersDown) val result = swipes.findUserActionResult(fromScene, overSlop, true) // As we were unable to locate a valid target scene, the initial SwipeTransition cannot be // defined. Consequently, a simple NoOp Controller will be returned. if (result == null) return NoOpDragController val result = swipes.findUserActionResult(fromScene, overSlop, true) // As we were unable to locate a valid target scene, the initial SwipeTransition // cannot be defined. Consequently, a simple NoOp Controller will be returned. ?: return NoOpDragController return updateDragController( swipes = swipes, swipeTransition = SwipeTransition(fromScene, result, swipes, layoutImpl, orientation) swipeTransition = SwipeTransition( layoutImpl.state, coroutineScope, fromScene, result, swipes, layoutImpl, orientation, ) ) } Loading Loading @@ -278,7 +293,7 @@ private class DragControllerImpl( * @return the consumed delta */ override fun onDrag(delta: Float) { if (delta == 0f || !isDrivingTransition) return if (delta == 0f || !isDrivingTransition || swipeTransition.isFinishing) return swipeTransition.dragOffset += delta val (fromScene, acceleratedOffset) = Loading Loading @@ -306,6 +321,8 @@ private class DragControllerImpl( ) { val swipeTransition = SwipeTransition( layoutState = layoutState, coroutineScope = draggableHandler.coroutineScope, fromScene = fromScene, result = result, swipes = swipes, Loading Loading @@ -356,15 +373,9 @@ private class DragControllerImpl( } } private fun snapToScene(scene: SceneKey) { if (!isDrivingTransition) return swipeTransition.cancelOffsetAnimation() layoutState.finishTransition(swipeTransition, idleScene = scene) } override fun onStop(velocity: Float, canChangeScene: Boolean) { // The state was changed since the drag started; don't do anything. if (!isDrivingTransition) { if (!isDrivingTransition || swipeTransition.isFinishing) { return } Loading @@ -390,7 +401,7 @@ private class DragControllerImpl( coroutineScope = draggableHandler.coroutineScope, initialVelocity = velocity, targetOffset = targetOffset, onAnimationCompleted = { snapToScene(targetScene.key) } targetScene = targetScene.key, ) } Loading Loading @@ -452,12 +463,14 @@ private class DragControllerImpl( val result = swipes.findUserActionResultStrict(velocity) if (result == null) { // We will not animate snapToScene(fromScene.key) swipeTransition.snapToScene(fromScene.key) return } val newSwipeTransition = SwipeTransition( layoutState = layoutState, coroutineScope = draggableHandler.coroutineScope, fromScene = fromScene, result = result, swipes = swipes, Loading Loading @@ -515,6 +528,8 @@ private class DragControllerImpl( } private fun SwipeTransition( layoutState: BaseSceneTransitionLayoutState, coroutineScope: CoroutineScope, fromScene: Scene, result: UserActionResult, swipes: Swipes, Loading @@ -531,6 +546,8 @@ private fun SwipeTransition( } return SwipeTransition( layoutState = layoutState, coroutineScope = coroutineScope, key = result.transitionKey, _fromScene = fromScene, _toScene = layoutImpl.scene(result.toScene), Loading @@ -542,6 +559,8 @@ private fun SwipeTransition( private fun SwipeTransition(old: SwipeTransition): SwipeTransition { return SwipeTransition( layoutState = old.layoutState, coroutineScope = old.coroutineScope, key = old.key, _fromScene = old._fromScene, _toScene = old._toScene, Loading @@ -556,6 +575,8 @@ private fun SwipeTransition(old: SwipeTransition): SwipeTransition { } private class SwipeTransition( val layoutState: BaseSceneTransitionLayoutState, val coroutineScope: CoroutineScope, val key: TransitionKey?, val _fromScene: Scene, val _toScene: Scene, Loading Loading @@ -609,6 +630,10 @@ private class SwipeTransition( private var lastDistance = DistanceUnspecified /** Whether [TransitionState.Transition.finish] was called on this transition. */ var isFinishing = false private set /** * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above * or to the left of [toScene]. Loading Loading @@ -640,9 +665,9 @@ private class SwipeTransition( } /** Ends any previous [offsetAnimation] and runs the new [animation]. */ private fun startOffsetAnimation(animation: () -> OffsetAnimation) { private fun startOffsetAnimation(animation: () -> OffsetAnimation): OffsetAnimation { cancelOffsetAnimation() offsetAnimation = animation() return animation().also { offsetAnimation = it } } /** Cancel any ongoing offset animation. */ Loading @@ -661,26 +686,67 @@ private class SwipeTransition( coroutineScope: CoroutineScope, initialVelocity: Float, targetOffset: Float, onAnimationCompleted: () -> Unit, ) { startOffsetAnimation { targetScene: SceneKey, ): OffsetAnimation { return startOffsetAnimation { val animatable = Animatable(dragOffset, OffsetVisibilityThreshold) val job = coroutineScope.launch { coroutineScope .launch { animatable.animateTo( targetValue = targetOffset, animationSpec = swipeSpec, initialVelocity = initialVelocity, ) onAnimationCompleted() } // Make sure that we settle to target scene at the end of the animation or if // the animation is cancelled. .apply { invokeOnCompletion { snapToScene(targetScene) } } OffsetAnimation(animatable, job) } } private class OffsetAnimation( fun snapToScene(scene: SceneKey) { if (layoutState.transitionState != this) return cancelOffsetAnimation() layoutState.finishTransition(this, idleScene = scene) } override fun finish(): Job { if (isFinishing) return requireNotNull(offsetAnimation).job isFinishing = true // If we were already animating the offset, simply return the job. offsetAnimation?.let { return it.job } // Animate to the current scene. val targetScene = currentScene val targetOffset = if (targetScene == fromScene) { 0f } else { val distance = distance() check(distance != DistanceUnspecified) { "targetScene != fromScene but distance is unspecified" } distance } val animation = animateOffset( coroutineScope = coroutineScope, initialVelocity = 0f, targetOffset = targetOffset, targetScene = currentScene, ) check(offsetAnimation == animation) return animation.job } internal class OffsetAnimation( /** The animatable used to animate the offset. */ val animatable: Animatable<Float, AnimationVector1D>, Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt +17 −6 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ import com.android.compose.animation.scene.transition.link.LinkedTransition import com.android.compose.animation.scene.transition.link.StateLink import kotlin.math.absoluteValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel /** Loading Loading @@ -188,13 +189,8 @@ sealed interface TransitionState { val fromScene: SceneKey, /** The scene this transition is going to. Can't be the same as fromScene */ val toScene: SceneKey val toScene: SceneKey, ) : TransitionState { init { check(fromScene != toScene) } /** * The progress of the transition. This is usually in the `[0; 1]` range, but it can also be * less than `0` or greater than `1` when using transitions with a spring AnimationSpec or Loading @@ -208,6 +204,21 @@ sealed interface TransitionState { /** Whether user input is currently driving the transition. */ abstract val isUserInputOngoing: Boolean init { check(fromScene != toScene) } /** * Force this transition to finish and animate to [currentScene], so that this transition * progress will settle to either 0% (if [currentScene] == [fromScene]) or 100% (if * [currentScene] == [toScene]) in a finite amount of time. * * @return the [Job] that animates the progress to [currentScene]. It can be used to wait * until the animation is complete or cancel it to snap to [currentScene]. Calling * [finish] multiple times will return the same [Job]. */ abstract fun finish(): Job /** * Whether we are transitioning. If [from] or [to] is empty, we will also check that they * match the scenes we are animating from and/or to. Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt +3 −0 Original line number Diff line number Diff line Loading @@ -18,6 +18,7 @@ package com.android.compose.animation.scene.transition.link import com.android.compose.animation.scene.SceneKey import com.android.compose.animation.scene.TransitionState import kotlinx.coroutines.Job /** A linked transition which is driven by a [originalTransition]. */ internal class LinkedTransition( Loading @@ -43,4 +44,6 @@ internal class LinkedTransition( override val progress: Float get() = originalTransition.progress override fun finish(): Job = originalTransition.finish() } packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +45 −0 Original line number Diff line number Diff line Loading @@ -39,6 +39,7 @@ import com.android.compose.test.runMonotonicClockTest import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import org.junit.Test import org.junit.runner.RunWith Loading Loading @@ -891,6 +892,50 @@ class DraggableHandlerTest { assertThat(transitionState).isNotSameInstanceAs(firstTransition) } @Test fun finish() = runGestureTest { // Start at scene C. navigateToSceneC() // Swipe up from the middle to transition to scene B. val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f) onDragStarted(startedPosition = middle, overSlop = up(0.1f)) assertTransition(fromScene = SceneC, toScene = SceneB, isUserInputOngoing = true) // The current transition can be intercepted. assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isTrue() // Finish the transition. val transition = transitionState as Transition val job = transition.finish() assertTransition(isUserInputOngoing = false) // The current transition can not be intercepted anymore. assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isFalse() // Calling finish() multiple times returns the same Job. assertThat(transition.finish()).isSameInstanceAs(job) assertThat(transition.finish()).isSameInstanceAs(job) assertThat(transition.finish()).isSameInstanceAs(job) // We can join the job to wait for the animation to end. assertTransition() job.join() assertIdle(SceneC) } @Test fun finish_cancelled() = runGestureTest { // Swipe up from the middle to transition to scene B. val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f) onDragStarted(startedPosition = middle, overSlop = up(0.1f)) assertTransition(fromScene = SceneA, toScene = SceneB) // Finish the transition and cancel the returned job. (transitionState as Transition).finish().cancelAndJoin() assertIdle(SceneA) } @Test fun blockTransition() = runGestureTest { assertIdle(SceneA) Loading Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt +16 −7 Original line number Diff line number Diff line Loading @@ -21,6 +21,7 @@ import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.SpringSpec import kotlin.math.absoluteValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch /** Loading Loading @@ -147,13 +148,16 @@ private fun CoroutineScope.animate( } // Animate the progress to its target value. transition.job = launch { animatable.animateTo(targetProgress, animationSpec) } .invokeOnCompletion { // Settle the state to Idle(target). Note that this will do nothing if this transition // was replaced/interrupted by another one, and this also runs if this coroutine is // cancelled, i.e. if [this] coroutine scope is cancelled. .apply { invokeOnCompletion { // Settle the state to Idle(target). Note that this will do nothing if this // transition was replaced/interrupted by another one, and this also runs if // this coroutine is cancelled, i.e. if [this] coroutine scope is cancelled. layoutState.finishTransition(transition, target) } } return transition } Loading @@ -174,8 +178,13 @@ private class OneOffTransition( */ lateinit var animatable: Animatable<Float, AnimationVector1D> /** The job that is animating [animatable]. */ lateinit var job: Job override val progress: Float get() = animatable.value override fun finish(): Job = job } // TODO(b/290184746): Compute a good default visibility threshold that depends on the layout size Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +98 −32 Original line number Diff line number Diff line Loading @@ -97,9 +97,15 @@ internal class DraggableHandlerImpl( return false } val swipeTransition = dragController.swipeTransition // Don't intercept a transition that is finishing. if (swipeTransition.isFinishing) { return false } // Only intercept the current transition if one of the 2 swipes results is also a transition // between the same pair of scenes. val swipeTransition = dragController.swipeTransition val fromScene = swipeTransition._currentScene val swipes = computeSwipes(fromScene, startedPosition, pointersDown = 1) val (upOrLeft, downOrRight) = swipes.computeSwipesResults(fromScene) Loading Loading @@ -150,15 +156,24 @@ internal class DraggableHandlerImpl( val fromScene = layoutImpl.scene(transitionState.currentScene) val swipes = computeSwipes(fromScene, startedPosition, pointersDown) val result = swipes.findUserActionResult(fromScene, overSlop, true) // As we were unable to locate a valid target scene, the initial SwipeTransition cannot be // defined. Consequently, a simple NoOp Controller will be returned. if (result == null) return NoOpDragController val result = swipes.findUserActionResult(fromScene, overSlop, true) // As we were unable to locate a valid target scene, the initial SwipeTransition // cannot be defined. Consequently, a simple NoOp Controller will be returned. ?: return NoOpDragController return updateDragController( swipes = swipes, swipeTransition = SwipeTransition(fromScene, result, swipes, layoutImpl, orientation) swipeTransition = SwipeTransition( layoutImpl.state, coroutineScope, fromScene, result, swipes, layoutImpl, orientation, ) ) } Loading Loading @@ -278,7 +293,7 @@ private class DragControllerImpl( * @return the consumed delta */ override fun onDrag(delta: Float) { if (delta == 0f || !isDrivingTransition) return if (delta == 0f || !isDrivingTransition || swipeTransition.isFinishing) return swipeTransition.dragOffset += delta val (fromScene, acceleratedOffset) = Loading Loading @@ -306,6 +321,8 @@ private class DragControllerImpl( ) { val swipeTransition = SwipeTransition( layoutState = layoutState, coroutineScope = draggableHandler.coroutineScope, fromScene = fromScene, result = result, swipes = swipes, Loading Loading @@ -356,15 +373,9 @@ private class DragControllerImpl( } } private fun snapToScene(scene: SceneKey) { if (!isDrivingTransition) return swipeTransition.cancelOffsetAnimation() layoutState.finishTransition(swipeTransition, idleScene = scene) } override fun onStop(velocity: Float, canChangeScene: Boolean) { // The state was changed since the drag started; don't do anything. if (!isDrivingTransition) { if (!isDrivingTransition || swipeTransition.isFinishing) { return } Loading @@ -390,7 +401,7 @@ private class DragControllerImpl( coroutineScope = draggableHandler.coroutineScope, initialVelocity = velocity, targetOffset = targetOffset, onAnimationCompleted = { snapToScene(targetScene.key) } targetScene = targetScene.key, ) } Loading Loading @@ -452,12 +463,14 @@ private class DragControllerImpl( val result = swipes.findUserActionResultStrict(velocity) if (result == null) { // We will not animate snapToScene(fromScene.key) swipeTransition.snapToScene(fromScene.key) return } val newSwipeTransition = SwipeTransition( layoutState = layoutState, coroutineScope = draggableHandler.coroutineScope, fromScene = fromScene, result = result, swipes = swipes, Loading Loading @@ -515,6 +528,8 @@ private class DragControllerImpl( } private fun SwipeTransition( layoutState: BaseSceneTransitionLayoutState, coroutineScope: CoroutineScope, fromScene: Scene, result: UserActionResult, swipes: Swipes, Loading @@ -531,6 +546,8 @@ private fun SwipeTransition( } return SwipeTransition( layoutState = layoutState, coroutineScope = coroutineScope, key = result.transitionKey, _fromScene = fromScene, _toScene = layoutImpl.scene(result.toScene), Loading @@ -542,6 +559,8 @@ private fun SwipeTransition( private fun SwipeTransition(old: SwipeTransition): SwipeTransition { return SwipeTransition( layoutState = old.layoutState, coroutineScope = old.coroutineScope, key = old.key, _fromScene = old._fromScene, _toScene = old._toScene, Loading @@ -556,6 +575,8 @@ private fun SwipeTransition(old: SwipeTransition): SwipeTransition { } private class SwipeTransition( val layoutState: BaseSceneTransitionLayoutState, val coroutineScope: CoroutineScope, val key: TransitionKey?, val _fromScene: Scene, val _toScene: Scene, Loading Loading @@ -609,6 +630,10 @@ private class SwipeTransition( private var lastDistance = DistanceUnspecified /** Whether [TransitionState.Transition.finish] was called on this transition. */ var isFinishing = false private set /** * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above * or to the left of [toScene]. Loading Loading @@ -640,9 +665,9 @@ private class SwipeTransition( } /** Ends any previous [offsetAnimation] and runs the new [animation]. */ private fun startOffsetAnimation(animation: () -> OffsetAnimation) { private fun startOffsetAnimation(animation: () -> OffsetAnimation): OffsetAnimation { cancelOffsetAnimation() offsetAnimation = animation() return animation().also { offsetAnimation = it } } /** Cancel any ongoing offset animation. */ Loading @@ -661,26 +686,67 @@ private class SwipeTransition( coroutineScope: CoroutineScope, initialVelocity: Float, targetOffset: Float, onAnimationCompleted: () -> Unit, ) { startOffsetAnimation { targetScene: SceneKey, ): OffsetAnimation { return startOffsetAnimation { val animatable = Animatable(dragOffset, OffsetVisibilityThreshold) val job = coroutineScope.launch { coroutineScope .launch { animatable.animateTo( targetValue = targetOffset, animationSpec = swipeSpec, initialVelocity = initialVelocity, ) onAnimationCompleted() } // Make sure that we settle to target scene at the end of the animation or if // the animation is cancelled. .apply { invokeOnCompletion { snapToScene(targetScene) } } OffsetAnimation(animatable, job) } } private class OffsetAnimation( fun snapToScene(scene: SceneKey) { if (layoutState.transitionState != this) return cancelOffsetAnimation() layoutState.finishTransition(this, idleScene = scene) } override fun finish(): Job { if (isFinishing) return requireNotNull(offsetAnimation).job isFinishing = true // If we were already animating the offset, simply return the job. offsetAnimation?.let { return it.job } // Animate to the current scene. val targetScene = currentScene val targetOffset = if (targetScene == fromScene) { 0f } else { val distance = distance() check(distance != DistanceUnspecified) { "targetScene != fromScene but distance is unspecified" } distance } val animation = animateOffset( coroutineScope = coroutineScope, initialVelocity = 0f, targetOffset = targetOffset, targetScene = currentScene, ) check(offsetAnimation == animation) return animation.job } internal class OffsetAnimation( /** The animatable used to animate the offset. */ val animatable: Animatable<Float, AnimationVector1D>, Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt +17 −6 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ import com.android.compose.animation.scene.transition.link.LinkedTransition import com.android.compose.animation.scene.transition.link.StateLink import kotlin.math.absoluteValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel /** Loading Loading @@ -188,13 +189,8 @@ sealed interface TransitionState { val fromScene: SceneKey, /** The scene this transition is going to. Can't be the same as fromScene */ val toScene: SceneKey val toScene: SceneKey, ) : TransitionState { init { check(fromScene != toScene) } /** * The progress of the transition. This is usually in the `[0; 1]` range, but it can also be * less than `0` or greater than `1` when using transitions with a spring AnimationSpec or Loading @@ -208,6 +204,21 @@ sealed interface TransitionState { /** Whether user input is currently driving the transition. */ abstract val isUserInputOngoing: Boolean init { check(fromScene != toScene) } /** * Force this transition to finish and animate to [currentScene], so that this transition * progress will settle to either 0% (if [currentScene] == [fromScene]) or 100% (if * [currentScene] == [toScene]) in a finite amount of time. * * @return the [Job] that animates the progress to [currentScene]. It can be used to wait * until the animation is complete or cancel it to snap to [currentScene]. Calling * [finish] multiple times will return the same [Job]. */ abstract fun finish(): Job /** * Whether we are transitioning. If [from] or [to] is empty, we will also check that they * match the scenes we are animating from and/or to. Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transition/link/LinkedTransition.kt +3 −0 Original line number Diff line number Diff line Loading @@ -18,6 +18,7 @@ package com.android.compose.animation.scene.transition.link import com.android.compose.animation.scene.SceneKey import com.android.compose.animation.scene.TransitionState import kotlinx.coroutines.Job /** A linked transition which is driven by a [originalTransition]. */ internal class LinkedTransition( Loading @@ -43,4 +44,6 @@ internal class LinkedTransition( override val progress: Float get() = originalTransition.progress override fun finish(): Job = originalTransition.finish() }
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +45 −0 Original line number Diff line number Diff line Loading @@ -39,6 +39,7 @@ import com.android.compose.test.runMonotonicClockTest import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import org.junit.Test import org.junit.runner.RunWith Loading Loading @@ -891,6 +892,50 @@ class DraggableHandlerTest { assertThat(transitionState).isNotSameInstanceAs(firstTransition) } @Test fun finish() = runGestureTest { // Start at scene C. navigateToSceneC() // Swipe up from the middle to transition to scene B. val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f) onDragStarted(startedPosition = middle, overSlop = up(0.1f)) assertTransition(fromScene = SceneC, toScene = SceneB, isUserInputOngoing = true) // The current transition can be intercepted. assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isTrue() // Finish the transition. val transition = transitionState as Transition val job = transition.finish() assertTransition(isUserInputOngoing = false) // The current transition can not be intercepted anymore. assertThat(draggableHandler.shouldImmediatelyIntercept(middle)).isFalse() // Calling finish() multiple times returns the same Job. assertThat(transition.finish()).isSameInstanceAs(job) assertThat(transition.finish()).isSameInstanceAs(job) assertThat(transition.finish()).isSameInstanceAs(job) // We can join the job to wait for the animation to end. assertTransition() job.join() assertIdle(SceneC) } @Test fun finish_cancelled() = runGestureTest { // Swipe up from the middle to transition to scene B. val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f) onDragStarted(startedPosition = middle, overSlop = up(0.1f)) assertTransition(fromScene = SceneA, toScene = SceneB) // Finish the transition and cancel the returned job. (transitionState as Transition).finish().cancelAndJoin() assertIdle(SceneA) } @Test fun blockTransition() = runGestureTest { assertIdle(SceneA) Loading