Merge "Make it possible to cancel swipes (1/2)" into main
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
index c8fbad4..76e7c95 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
@@ -348,6 +348,8 @@
// Compute the destination scene (and therefore offset) to settle in.
val offset = swipeTransition.dragOffset
val distance = swipeTransition.distance
+ var targetScene: Scene
+ var targetOffset: Float
if (
shouldCommitSwipe(
offset,
@@ -356,12 +358,24 @@
wasCommitted = swipeTransition._currentScene == toScene,
)
) {
- // Animate to the next scene
- animateTo(targetScene = toScene, targetOffset = distance)
+ targetScene = toScene
+ targetOffset = distance
} else {
- // Animate to the initial scene
- animateTo(targetScene = fromScene, targetOffset = 0f)
+ targetScene = fromScene
+ targetOffset = 0f
}
+
+ if (
+ targetScene != swipeTransition._currentScene &&
+ !layoutState.canChangeScene(targetScene.key)
+ ) {
+ // We wanted to change to a new scene but we are not allowed to, so we animate back
+ // to the current scene.
+ targetScene = swipeTransition._currentScene
+ targetOffset = if (targetScene == fromScene) 0f else distance
+ }
+
+ animateTo(targetScene = targetScene, targetOffset = targetOffset)
} else {
// We are doing an overscroll animation between scenes. In this case, we can also start
// from the idle position.
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 8c5a472..08399ff 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
@@ -232,7 +232,12 @@
scene(state.transitionState.currentScene).userActions[Back]?.let { result ->
// TODO(b/290184746): Handle predictive back and use result.distance if
// specified.
- BackHandler { with(state) { coroutineScope.onChangeScene(result.toScene) } }
+ BackHandler {
+ val targetScene = result.toScene
+ if (state.canChangeScene(targetScene)) {
+ with(state) { coroutineScope.onChangeScene(targetScene) }
+ }
+ }
}
Box {
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt
index a8da551..1cdba2d 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt
@@ -101,13 +101,30 @@
): TransitionState.Transition?
}
-/** Return a [MutableSceneTransitionLayoutState] initially idle at [initialScene]. */
+/**
+ * Return a [MutableSceneTransitionLayoutState] initially idle at [initialScene].
+ *
+ * @param initialScene the initial scene to which this state is initialized.
+ * @param transitions the [SceneTransitions] used when this state is transitioning between scenes.
+ * @param canChangeScene whether we can transition to the given scene. This is called when the user
+ * commits a transition to a new scene because of a [UserAction]. If [canChangeScene] returns
+ * `true`, then the gesture will be committed and we will animate to the other scene. Otherwise,
+ * the gesture will be cancelled and we will animate back to the current scene.
+ * @param stateLinks the [StateLink] connecting this [SceneTransitionLayoutState] to other
+ * [SceneTransitionLayoutState]s.
+ */
fun MutableSceneTransitionLayoutState(
initialScene: SceneKey,
transitions: SceneTransitions = SceneTransitions.Empty,
+ canChangeScene: (SceneKey) -> Boolean = { true },
stateLinks: List<StateLink> = emptyList(),
): MutableSceneTransitionLayoutState {
- return MutableSceneTransitionLayoutStateImpl(initialScene, transitions, stateLinks)
+ return MutableSceneTransitionLayoutStateImpl(
+ initialScene,
+ transitions,
+ canChangeScene,
+ stateLinks,
+ )
}
/**
@@ -120,18 +137,32 @@
* This is called when the user commits a transition to a new scene because of a [UserAction], for
* instance by triggering back navigation or by swiping to a new scene.
* @param transitions the definition of the transitions used to animate a change of scene.
+ * @param canChangeScene whether we can transition to the given scene. This is called when the user
+ * commits a transition to a new scene because of a [UserAction]. If [canChangeScene] returns
+ * `true`, then [onChangeScene] will be called right afterwards with the same [SceneKey]. If it
+ * returns `false`, the user action will be cancelled and we will animate back to the current
+ * scene.
+ * @param stateLinks the [StateLink] connecting this [SceneTransitionLayoutState] to other
+ * [SceneTransitionLayoutState]s.
*/
@Composable
fun updateSceneTransitionLayoutState(
currentScene: SceneKey,
onChangeScene: (SceneKey) -> Unit,
transitions: SceneTransitions = SceneTransitions.Empty,
+ canChangeScene: (SceneKey) -> Boolean = { true },
stateLinks: List<StateLink> = emptyList(),
): SceneTransitionLayoutState {
return remember {
- HoistedSceneTransitionLayoutScene(currentScene, transitions, onChangeScene, stateLinks)
+ HoistedSceneTransitionLayoutScene(
+ currentScene,
+ transitions,
+ onChangeScene,
+ canChangeScene,
+ stateLinks,
+ )
}
- .apply { update(currentScene, onChangeScene, transitions, stateLinks) }
+ .apply { update(currentScene, onChangeScene, canChangeScene, transitions, stateLinks) }
}
@Stable
@@ -208,6 +239,9 @@
private val activeTransitionLinks = mutableMapOf<StateLink, LinkedTransition>()
+ /** Whether we can transition to the given [scene]. */
+ internal abstract fun canChangeScene(scene: SceneKey): Boolean
+
/**
* Called when the [current scene][TransitionState.currentScene] should be changed to [scene].
*
@@ -334,21 +368,26 @@
initialScene: SceneKey,
override var transitions: SceneTransitions,
private var changeScene: (SceneKey) -> Unit,
+ private var canChangeScene: (SceneKey) -> Boolean,
stateLinks: List<StateLink> = emptyList(),
) : BaseSceneTransitionLayoutState(initialScene, stateLinks) {
private val targetSceneChannel = Channel<SceneKey>(Channel.CONFLATED)
- override fun CoroutineScope.onChangeScene(scene: SceneKey) = changeScene(scene)
+ override fun canChangeScene(scene: SceneKey): Boolean = canChangeScene.invoke(scene)
+
+ override fun CoroutineScope.onChangeScene(scene: SceneKey) = changeScene.invoke(scene)
@Composable
fun update(
currentScene: SceneKey,
onChangeScene: (SceneKey) -> Unit,
+ canChangeScene: (SceneKey) -> Boolean,
transitions: SceneTransitions,
stateLinks: List<StateLink>,
) {
SideEffect {
this.changeScene = onChangeScene
+ this.canChangeScene = canChangeScene
this.transitions = transitions
this.stateLinks = stateLinks
@@ -374,6 +413,7 @@
internal class MutableSceneTransitionLayoutStateImpl(
initialScene: SceneKey,
override var transitions: SceneTransitions,
+ private val canChangeScene: (SceneKey) -> Boolean = { true },
stateLinks: List<StateLink> = emptyList(),
) : MutableSceneTransitionLayoutState, BaseSceneTransitionLayoutState(initialScene, stateLinks) {
override fun setTargetScene(
@@ -388,6 +428,8 @@
)
}
+ override fun canChangeScene(scene: SceneKey): Boolean = canChangeScene.invoke(scene)
+
override fun CoroutineScope.onChangeScene(scene: SceneKey) {
setTargetScene(scene, coroutineScope = this)
}
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
index c91d298..fe53d5b 100644
--- 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
@@ -51,8 +51,13 @@
private class TestGestureScope(
private val testScope: MonotonicClockTestScope,
) {
+ var canChangeScene: (SceneKey) -> Boolean = { true }
private val layoutState =
- MutableSceneTransitionLayoutStateImpl(SceneA, EmptyTestTransitions)
+ MutableSceneTransitionLayoutStateImpl(
+ SceneA,
+ EmptyTestTransitions,
+ canChangeScene = { canChangeScene(it) },
+ )
val mutableUserActionsA = mutableMapOf(Swipe.Up to SceneB, Swipe.Down to SceneC)
val mutableUserActionsB = mutableMapOf(Swipe.Up to SceneC, Swipe.Down to SceneA)
@@ -890,4 +895,41 @@
)
assertThat(transitionState).isNotSameInstanceAs(firstTransition)
}
+
+ @Test
+ fun blockTransition() = runGestureTest {
+ assertIdle(SceneA)
+
+ // Swipe up to scene B.
+ onDragStarted(overSlop = up(0.1f))
+ assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneB)
+
+ // Block the transition when the user release their finger.
+ canChangeScene = { false }
+ onDragStopped(velocity = -velocityThreshold)
+ advanceUntilIdle()
+ assertIdle(SceneA)
+ }
+
+ @Test
+ fun blockInterceptedTransition() = runGestureTest {
+ assertIdle(SceneA)
+
+ // Swipe up to B.
+ onDragStarted(overSlop = up(0.1f))
+ assertTransition(currentScene = SceneA, fromScene = SceneA, toScene = SceneB)
+ onDragStopped(velocity = -velocityThreshold)
+ assertTransition(currentScene = SceneB, fromScene = SceneA, toScene = SceneB)
+
+ // Intercept the transition and swipe down back to scene A.
+ assertThat(sceneGestureHandler.shouldImmediatelyIntercept(startedPosition = null)).isTrue()
+ onDragStartedImmediately()
+
+ // Block the transition when the user release their finger.
+ canChangeScene = { false }
+ onDragStopped(velocity = velocityThreshold)
+
+ advanceUntilIdle()
+ assertIdle(SceneB)
+ }
}