Make STL swipe distances configurable
This CL changes the userActions map from a Map<UserAction, SceneKey> to
Map<UserAction, UserActionResult>. In addition to the target SceneKey,
the UserActionResult also exposes a UserActionDistance that can be used
to specify the distance of the user action.
This CL makes sure that usages of the previous API still work by making
SceneKey implement UserActionResult.
Bug: 321932826
Test: atest SwipeToSceneTest
Flag: N/A
Change-Id: I6b3832f82a72d3d1dca8cef1c58ccb87b8ec2220
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt
index 90f46bd..9d4b69c 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt
@@ -44,7 +44,7 @@
class SceneKey(
name: String,
identity: Any = Object(),
-) : Key(name, identity) {
+) : Key(name, identity), UserActionResult {
@VisibleForTesting
// TODO(b/240432457): Make internal once PlatformComposeSceneTransitionLayoutTestsUtils can
// access internal members.
@@ -53,6 +53,10 @@
/** The unique [ElementKey] identifying this scene's root element. */
val rootElementKey = ElementKey(name, identity)
+ // Implementation of [UserActionResult].
+ override val toScene: SceneKey = this
+ override val distance: UserActionDistance? = null
+
override fun toString(): String {
return "SceneKey(debugName=$debugName)"
}
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 f67df54..af51cee 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
@@ -38,7 +38,7 @@
val key: SceneKey,
layoutImpl: SceneTransitionLayoutImpl,
content: @Composable SceneScope.() -> Unit,
- actions: Map<UserAction, SceneKey>,
+ actions: Map<UserAction, UserActionResult>,
zIndex: Float,
) {
internal val scope = SceneScopeImpl(layoutImpl, this)
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 ff05478..0a2370a 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
@@ -28,6 +28,8 @@
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import com.android.compose.nestedscroll.PriorityNestedScrollConnection
@@ -75,20 +77,22 @@
internal var currentSource: Any? = null
- /** The [UserAction]s associated to the current swipe. */
- private var actionUpOrLeft: UserAction? = null
- private var actionDownOrRight: UserAction? = null
- private var actionUpOrLeftNoEdge: UserAction? = null
- private var actionDownOrRightNoEdge: UserAction? = null
- private var upOrLeftScene: SceneKey? = null
- private var downOrRightScene: SceneKey? = null
+ /** The [Swipe]s associated to the current gesture. */
+ private var upOrLeftSwipe: Swipe? = null
+ private var downOrRightSwipe: Swipe? = null
+ private var upOrLeftNoEdgeSwipe: Swipe? = null
+ private var downOrRightNoEdgeSwipe: Swipe? = null
+
+ /** The [UserActionResult] associated to up and down swipes. */
+ private var upOrLeftResult: UserActionResult? = null
+ private var downOrRightResult: UserActionResult? = null
internal fun onDragStarted(pointersDown: Int, startedPosition: Offset?, overSlop: Float) {
if (isDrivingTransition) {
// This [transition] was already driving the animation: simply take over it.
// Stop animating and start from where the current offset.
swipeTransition.cancelOffsetAnimation()
- updateTargetScenes(swipeTransition._fromScene)
+ updateTargetResults(swipeTransition._fromScene)
return
}
@@ -146,29 +150,23 @@
)
if (fromEdge == null) {
- actionUpOrLeft = null
- actionDownOrRight = null
- actionUpOrLeftNoEdge = upOrLeft
- actionDownOrRightNoEdge = downOrRight
+ upOrLeftSwipe = null
+ downOrRightSwipe = null
+ upOrLeftNoEdgeSwipe = upOrLeft
+ downOrRightNoEdgeSwipe = downOrRight
} else {
- actionUpOrLeft = upOrLeft
- actionDownOrRight = downOrRight
- actionUpOrLeftNoEdge = upOrLeft.copy(fromEdge = null)
- actionDownOrRightNoEdge = downOrRight.copy(fromEdge = null)
+ upOrLeftSwipe = upOrLeft
+ downOrRightSwipe = downOrRight
+ upOrLeftNoEdgeSwipe = upOrLeft.copy(fromEdge = null)
+ downOrRightNoEdgeSwipe = downOrRight.copy(fromEdge = null)
}
}
- /**
- * 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.
- */
- private fun Scene.getAbsoluteDistance(): Float {
- return when (orientation) {
- Orientation.Horizontal -> targetSize.width
- Orientation.Vertical -> targetSize.height
- }.toFloat()
+ private fun Scene.getAbsoluteDistance(distance: UserActionDistance?): Float {
+ val targetSize = this.targetSize
+ return with(distance ?: DefaultSwipeDistance) {
+ layoutImpl.density.absoluteDistance(targetSize, orientation)
+ }
}
internal fun onDrag(delta: Float) {
@@ -200,9 +198,15 @@
}
}
- private fun updateTargetScenes(fromScene: Scene) {
- upOrLeftScene = fromScene.upOrLeft()
- downOrRightScene = fromScene.downOrRight()
+ private fun updateTargetResults(fromScene: Scene) {
+ val userActions = fromScene.userActions
+ fun sceneToSwipePair(swipe: Swipe?): UserActionResult? {
+ return userActions[swipe ?: return null]
+ }
+
+ upOrLeftResult = sceneToSwipePair(upOrLeftSwipe) ?: sceneToSwipePair(upOrLeftNoEdgeSwipe)
+ downOrRightResult =
+ sceneToSwipePair(downOrRightSwipe) ?: sceneToSwipePair(downOrRightNoEdgeSwipe)
}
/**
@@ -229,9 +233,9 @@
// 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
- return if (offset <= -absoluteDistance && upOrLeftScene == toScene.key) {
+ return if (offset <= -absoluteDistance && upOrLeftResult?.toScene == toScene.key) {
Pair(toScene, absoluteDistance)
- } else if (offset >= absoluteDistance && downOrRightScene == toScene.key) {
+ } else if (offset >= absoluteDistance && downOrRightResult?.toScene == toScene.key) {
Pair(toScene, -absoluteDistance)
} else {
Pair(fromScene, 0f)
@@ -253,22 +257,32 @@
* drag into the null direction this function will return the opposite direction, assuming
* that the users intention is to start the drag into the other direction eventually. If
* [directionOffset] is 0f and both direction are available, it will default to
- * [upOrLeftScene].
+ * [upOrLeftResult].
*/
private inline fun findTargetSceneAndDistance(
fromScene: Scene,
directionOffset: Float,
updateScenes: Boolean,
): Pair<Scene, Float>? {
- if (updateScenes) updateTargetScenes(fromScene)
- val absoluteDistance = fromScene.getAbsoluteDistance()
+ if (updateScenes) updateTargetResults(fromScene)
// Compute the target scene depending on the current offset.
return when {
- upOrLeftScene == null && downOrRightScene == null -> null
- (directionOffset < 0f && upOrLeftScene != null) || downOrRightScene == null ->
- Pair(layoutImpl.scene(upOrLeftScene!!), -absoluteDistance)
- else -> Pair(layoutImpl.scene(downOrRightScene!!), absoluteDistance)
+ upOrLeftResult == null && downOrRightResult == null -> null
+ (directionOffset < 0f && upOrLeftResult != null) || downOrRightResult == null ->
+ upOrLeftResult?.let { result ->
+ Pair(
+ layoutImpl.scene(result.toScene),
+ -fromScene.getAbsoluteDistance(result.distance)
+ )
+ }
+ else ->
+ downOrRightResult?.let { result ->
+ Pair(
+ layoutImpl.scene(result.toScene),
+ fromScene.getAbsoluteDistance(result.distance)
+ )
+ }
}
}
@@ -280,24 +294,25 @@
fromScene: Scene,
directionOffset: Float,
): Pair<Scene, Float>? {
- val absoluteDistance = fromScene.getAbsoluteDistance()
return when {
directionOffset > 0f ->
- upOrLeftScene?.let { Pair(layoutImpl.scene(it), -absoluteDistance) }
+ upOrLeftResult?.let { result ->
+ Pair(
+ layoutImpl.scene(result.toScene),
+ -fromScene.getAbsoluteDistance(result.distance),
+ )
+ }
directionOffset < 0f ->
- downOrRightScene?.let { Pair(layoutImpl.scene(it), absoluteDistance) }
+ downOrRightResult?.let { result ->
+ Pair(
+ layoutImpl.scene(result.toScene),
+ fromScene.getAbsoluteDistance(result.distance),
+ )
+ }
else -> null
}
}
- private fun Scene.upOrLeft(): SceneKey? {
- return userActions[actionUpOrLeft] ?: userActions[actionUpOrLeftNoEdge]
- }
-
- private fun Scene.downOrRight(): SceneKey? {
- return userActions[actionDownOrRight] ?: userActions[actionDownOrRightNoEdge]
- }
-
internal fun onDragStopped(velocity: Float, canChangeScene: Boolean) {
// The state was changed since the drag started; don't do anything.
if (!isDrivingTransition) {
@@ -515,6 +530,18 @@
companion object {
private const val TAG = "SceneGestureHandler"
}
+
+ private object DefaultSwipeDistance : UserActionDistance {
+ override fun Density.absoluteDistance(
+ fromSceneSize: IntSize,
+ orientation: Orientation,
+ ): Float {
+ return when (orientation) {
+ Orientation.Horizontal -> fromSceneSize.width
+ Orientation.Vertical -> fromSceneSize.height
+ }.toFloat()
+ }
+ }
}
private class SceneDraggableHandler(
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
index 80f8c1c..aa232df 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
@@ -27,6 +27,9 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
/**
* [SceneTransitionLayout] is a container that automatically animates its content whenever its state
@@ -113,7 +116,7 @@
*/
fun scene(
key: SceneKey,
- userActions: Map<UserAction, SceneKey> = emptyMap(),
+ userActions: Map<UserAction, UserActionResult> = emptyMap(),
content: @Composable SceneScope.() -> Unit,
)
}
@@ -353,6 +356,68 @@
}
/**
+ * The result of performing a [UserAction].
+ *
+ * Note: [UserActionResult] is implemented by [SceneKey], and you can also use [withDistance] to
+ * easily create a [UserActionResult] with a fixed distance:
+ * ```
+ * SceneTransitionLayout(...) {
+ * scene(
+ * Scenes.Foo,
+ * userActions =
+ * mapOf(
+ * Swipe.Right to Scene.Bar,
+ * Swipe.Down to Scene.Doe withDistance 100.dp,
+ * )
+ * )
+ * ) { ... }
+ * }
+ * ```
+ */
+interface UserActionResult {
+ /** The scene we should be transitioning to during the [UserAction]. */
+ val toScene: SceneKey
+
+ /**
+ * The distance the action takes to animate from 0% to 100%.
+ *
+ * If `null`, a default distance will be used that depends on the [UserAction] performed.
+ */
+ val distance: UserActionDistance?
+}
+
+interface UserActionDistance {
+ /**
+ * Return the **absolute** distance of the user action given the size of the scene we are
+ * animating from and the [orientation].
+ */
+ fun Density.absoluteDistance(fromSceneSize: IntSize, orientation: Orientation): Float
+}
+
+/**
+ * A utility function to make it possible to define user actions with a distance using the syntax
+ * `Swipe.Up to Scene.foo withDistance 100.dp`
+ */
+infix fun Pair<UserAction, SceneKey>.withDistance(
+ distance: Dp
+): Pair<UserAction, UserActionResult> {
+ val scene = second
+ val distance = FixedDistance(distance)
+ return first to
+ object : UserActionResult {
+ override val toScene: SceneKey = scene
+ override val distance: UserActionDistance = distance
+ }
+}
+
+/** The user action has a fixed [absoluteDistance]. */
+private class FixedDistance(private val distance: Dp) : UserActionDistance {
+ override fun Density.absoluteDistance(fromSceneSize: IntSize, orientation: Orientation): Float {
+ return distance.toPx()
+ }
+}
+
+/**
* An internal version of [SceneTransitionLayout] to be used for tests.
*
* Important: You should use this only in tests and if you need to access the underlying
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 7cc9d26..982b0e0 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
@@ -140,7 +140,7 @@
object : SceneTransitionLayoutScope {
override fun scene(
key: SceneKey,
- userActions: Map<UserAction, SceneKey>,
+ userActions: Map<UserAction, UserActionResult>,
content: @Composable SceneScope.() -> Unit,
) {
scenesToRemove.remove(key)
@@ -229,8 +229,10 @@
// Handle back events.
// TODO(b/290184746): Make sure that this works with SystemUI once we use
// SceneTransitionLayout in Flexiglass.
- scene(state.transitionState.currentScene).userActions[Back]?.let { backScene ->
- BackHandler { with(state) { coroutineScope.onChangeScene(backScene) } }
+ 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) } }
}
Box {
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt
index 1ec3c8b..4571d8e 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt
@@ -17,6 +17,7 @@
package com.android.compose.animation.scene
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@@ -349,4 +350,46 @@
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
}
+
+ @Test
+ fun swipeDistance() {
+ // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
+ // detected as a drag event.
+ var touchSlop = 0f
+
+ val layoutState = MutableSceneTransitionLayoutState(TestScenes.SceneA)
+ val verticalSwipeDistance = 50.dp
+ assertThat(verticalSwipeDistance).isNotEqualTo(LayoutHeight)
+
+ rule.setContent {
+ touchSlop = LocalViewConfiguration.current.touchSlop
+
+ SceneTransitionLayout(
+ state = layoutState,
+ modifier = Modifier.size(LayoutWidth, LayoutHeight)
+ ) {
+ scene(
+ TestScenes.SceneA,
+ userActions =
+ mapOf(Swipe.Down to TestScenes.SceneB withDistance verticalSwipeDistance),
+ ) {
+ Spacer(Modifier.fillMaxSize())
+ }
+ scene(TestScenes.SceneB) { Spacer(Modifier.fillMaxSize()) }
+ }
+ }
+
+ assertThat(layoutState.currentTransition).isNull()
+
+ // Swipe by half of verticalSwipeDistance.
+ rule.onRoot().performTouchInput {
+ down(middleTop)
+ moveBy(Offset(0f, touchSlop + (verticalSwipeDistance / 2).toPx()), delayMillis = 1_000)
+ }
+
+ // We should be at 50%
+ val transition = layoutState.currentTransition
+ assertThat(transition).isNotNull()
+ assertThat(transition!!.progress).isEqualTo(0.5f)
+ }
}