summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Jordan Demeulenaere <jdemeulenaere@google.com> 2023-10-30 13:57:46 +0100
committer Jordan Demeulenaere <jdemeulenaere@google.com> 2023-10-30 15:11:08 +0100
commit13489cae926ae81fd1d3044ef3665206358e6793 (patch)
tree4df5cbf64b9b58fb6d1f6397b378e470f5802520
parentde94ac9d388bf82656cf0c5ecd6e0b1cec893c11 (diff)
Add support for swipes started from an edge
This CL adds support for swipes started from a specific edge. The definition of edge is abstracted away into an EdgeDetector class, which defaults to a fixed edge size of 40dp. Bug: 291055080 Test: SwipeToSceneTest Test: FixedSizeEdgeDetectorTest Change-Id: I608ccefc26804c588e7568057e58606683cca8d7
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/EdgeDetector.kt75
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt6
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt2
-rw-r--r--packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt53
-rw-r--r--packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/FixedSizeEdgeDetectorTest.kt70
-rw-r--r--packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt3
-rw-r--r--packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt67
7 files changed, 263 insertions, 13 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/EdgeDetector.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/EdgeDetector.kt
new file mode 100644
index 000000000000..82d4239d7eb5
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/EdgeDetector.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.compose.animation.scene
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+
+interface EdgeDetector {
+ /**
+ * Return the [Edge] associated to [position] inside a layout of size [layoutSize], given
+ * [density] and [orientation].
+ */
+ fun edge(
+ layoutSize: IntSize,
+ position: IntOffset,
+ density: Density,
+ orientation: Orientation,
+ ): Edge?
+}
+
+val DefaultEdgeDetector = FixedSizeEdgeDetector(40.dp)
+
+/** An [EdgeDetector] that detects edges assuming a fixed edge size of [size]. */
+class FixedSizeEdgeDetector(val size: Dp) : EdgeDetector {
+ override fun edge(
+ layoutSize: IntSize,
+ position: IntOffset,
+ density: Density,
+ orientation: Orientation,
+ ): Edge? {
+ val axisSize: Int
+ val axisPosition: Int
+ val topOrLeft: Edge
+ val bottomOrRight: Edge
+ when (orientation) {
+ Orientation.Horizontal -> {
+ axisSize = layoutSize.width
+ axisPosition = position.x
+ topOrLeft = Edge.Left
+ bottomOrRight = Edge.Right
+ }
+ Orientation.Vertical -> {
+ axisSize = layoutSize.height
+ axisPosition = position.y
+ topOrLeft = Edge.Top
+ bottomOrRight = Edge.Bottom
+ }
+ }
+
+ val sizePx = with(density) { size.toPx() }
+ return when {
+ axisPosition <= sizePx -> topOrLeft
+ axisPosition >= axisSize - sizePx -> bottomOrRight
+ else -> null
+ }
+ }
+}
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 4a1d73dbfcb1..1f38e70799c3 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
@@ -38,6 +38,7 @@ import androidx.compose.ui.platform.LocalDensity
* 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 state the observable state of this layout.
+ * @param edgeDetector the edge detector used to detect which edge a swipe is started from, if any.
* @param scenes the configuration of the different scenes of this layout.
*/
@Composable
@@ -47,6 +48,7 @@ fun SceneTransitionLayout(
transitions: SceneTransitions,
modifier: Modifier = Modifier,
state: SceneTransitionLayoutState = remember { SceneTransitionLayoutState(currentScene) },
+ edgeDetector: EdgeDetector = DefaultEdgeDetector,
scenes: SceneTransitionLayoutScope.() -> Unit,
) {
val density = LocalDensity.current
@@ -57,15 +59,17 @@ fun SceneTransitionLayout(
transitions,
state,
density,
+ edgeDetector,
)
}
layoutImpl.onChangeScene = onChangeScene
layoutImpl.transitions = transitions
layoutImpl.density = density
+ layoutImpl.edgeDetector = edgeDetector
+
layoutImpl.setScenes(scenes)
layoutImpl.setCurrentScene(currentScene)
-
layoutImpl.Content(modifier)
}
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 a40b29991877..a803a4770517 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
@@ -47,6 +47,7 @@ class SceneTransitionLayoutImpl(
transitions: SceneTransitions,
internal val state: SceneTransitionLayoutState,
density: Density,
+ edgeDetector: EdgeDetector,
) {
internal val scenes = SnapshotStateMap<SceneKey, Scene>()
internal val elements = SnapshotStateMap<ElementKey, Element>()
@@ -57,6 +58,7 @@ class SceneTransitionLayoutImpl(
internal var onChangeScene by mutableStateOf(onChangeScene)
internal var transitions by mutableStateOf(transitions)
internal var density: Density by mutableStateOf(density)
+ internal var edgeDetector by mutableStateOf(edgeDetector)
/**
* The size of this layout. Note that this could be [IntSize.Zero] if this layour does not have
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 25b5db5c7506..ee1f13347840 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
@@ -35,6 +35,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.round
import com.android.compose.nestedscroll.PriorityNestedScrollConnection
import kotlin.math.absoluteValue
import kotlinx.coroutines.CoroutineScope
@@ -157,7 +158,7 @@ class SceneGestureHandler(
internal var gestureWithPriority: Any? = null
- internal fun onDragStarted(pointersDown: Int) {
+ internal fun onDragStarted(pointersDown: Int, startedPosition: Offset?) {
if (isDrivingTransition) {
// This [transition] was already driving the animation: simply take over it.
// Stop animating and start from where the current offset.
@@ -197,6 +198,16 @@ class SceneGestureHandler(
Orientation.Vertical -> layoutImpl.size.height
}.toFloat()
+ val fromEdge =
+ startedPosition?.let { position ->
+ layoutImpl.edgeDetector.edge(
+ layoutImpl.size,
+ position.round(),
+ layoutImpl.density,
+ orientation,
+ )
+ }
+
swipeTransition.actionUpOrLeft =
Swipe(
direction =
@@ -205,6 +216,7 @@ class SceneGestureHandler(
Orientation.Vertical -> SwipeDirection.Up
},
pointerCount = pointersDown,
+ fromEdge = fromEdge,
)
swipeTransition.actionDownOrRight =
@@ -215,8 +227,19 @@ class SceneGestureHandler(
Orientation.Vertical -> SwipeDirection.Down
},
pointerCount = pointersDown,
+ fromEdge = fromEdge,
)
+ if (fromEdge == null) {
+ swipeTransition.actionUpOrLeftNoEdge = null
+ swipeTransition.actionDownOrRightNoEdge = null
+ } else {
+ swipeTransition.actionUpOrLeftNoEdge =
+ (swipeTransition.actionUpOrLeft as Swipe).copy(fromEdge = null)
+ swipeTransition.actionDownOrRightNoEdge =
+ (swipeTransition.actionDownOrRight as Swipe).copy(fromEdge = null)
+ }
+
if (swipeTransition.absoluteDistance > 0f) {
transitionState = swipeTransition
}
@@ -264,15 +287,11 @@ class SceneGestureHandler(
// to the next screen or go back to the previous one.
val offset = swipeTransition.dragOffset
val absoluteDistance = swipeTransition.absoluteDistance
- if (
- offset <= -absoluteDistance &&
- fromScene.userActions[swipeTransition.actionUpOrLeft] == toScene.key
- ) {
+ if (offset <= -absoluteDistance && swipeTransition.upOrLeft(fromScene) == toScene.key) {
swipeTransition.dragOffset += absoluteDistance
swipeTransition._fromScene = toScene
} else if (
- offset >= absoluteDistance &&
- fromScene.userActions[swipeTransition.actionDownOrRight] == toScene.key
+ offset >= absoluteDistance && swipeTransition.downOrRight(fromScene) == toScene.key
) {
swipeTransition.dragOffset -= absoluteDistance
swipeTransition._fromScene = toScene
@@ -294,8 +313,8 @@ class SceneGestureHandler(
Orientation.Vertical -> layoutImpl.size.height
}.toFloat()
- val upOrLeft = userActions[swipeTransition.actionUpOrLeft]
- val downOrRight = userActions[swipeTransition.actionDownOrRight]
+ val upOrLeft = swipeTransition.upOrLeft(this)
+ val downOrRight = swipeTransition.downOrRight(this)
// Compute the target scene depending on the current offset.
return when {
@@ -542,6 +561,18 @@ class SceneGestureHandler(
/** The [UserAction]s associated to this swipe. */
var actionUpOrLeft: UserAction = Back
var actionDownOrRight: UserAction = Back
+ var actionUpOrLeftNoEdge: UserAction? = null
+ var actionDownOrRightNoEdge: UserAction? = null
+
+ fun upOrLeft(scene: Scene): SceneKey? {
+ return scene.userActions[actionUpOrLeft]
+ ?: actionUpOrLeftNoEdge?.let { scene.userActions[it] }
+ }
+
+ fun downOrRight(scene: Scene): SceneKey? {
+ return scene.userActions[actionDownOrRight]
+ ?: actionDownOrRightNoEdge?.let { scene.userActions[it] }
+ }
}
companion object {
@@ -554,7 +585,7 @@ private class SceneDraggableHandler(
) : DraggableHandler {
override fun onDragStarted(startedPosition: Offset, pointersDown: Int) {
gestureHandler.gestureWithPriority = this
- gestureHandler.onDragStarted(pointersDown)
+ gestureHandler.onDragStarted(pointersDown, startedPosition)
}
override fun onDelta(pixels: Float) {
@@ -671,7 +702,7 @@ class SceneNestedScrollHandler(
onStart = {
gestureHandler.gestureWithPriority = this
priorityScene = nextScene
- gestureHandler.onDragStarted(pointersDown = 1)
+ gestureHandler.onDragStarted(pointersDown = 1, startedPosition = null)
},
onScroll = { offsetAvailable ->
if (gestureHandler.gestureWithPriority != this) {
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/FixedSizeEdgeDetectorTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/FixedSizeEdgeDetectorTest.kt
new file mode 100644
index 000000000000..a68282ae78f4
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/FixedSizeEdgeDetectorTest.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.compose.animation.scene
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FixedSizeEdgeDetectorTest {
+ private val detector = FixedSizeEdgeDetector(30.dp)
+ private val layoutSize = IntSize(100, 100)
+ private val density = Density(1f)
+
+ @Test
+ fun horizontalEdges() {
+ fun horizontalEdge(position: Int): Edge? =
+ detector.edge(
+ layoutSize,
+ position = IntOffset(position, 0),
+ density,
+ Orientation.Horizontal,
+ )
+
+ assertThat(horizontalEdge(0)).isEqualTo(Edge.Left)
+ assertThat(horizontalEdge(30)).isEqualTo(Edge.Left)
+ assertThat(horizontalEdge(31)).isEqualTo(null)
+ assertThat(horizontalEdge(69)).isEqualTo(null)
+ assertThat(horizontalEdge(70)).isEqualTo(Edge.Right)
+ assertThat(horizontalEdge(100)).isEqualTo(Edge.Right)
+ }
+
+ @Test
+ fun verticalEdges() {
+ fun verticalEdge(position: Int): Edge? =
+ detector.edge(
+ layoutSize,
+ position = IntOffset(0, position),
+ density,
+ Orientation.Vertical,
+ )
+
+ assertThat(verticalEdge(0)).isEqualTo(Edge.Top)
+ assertThat(verticalEdge(30)).isEqualTo(Edge.Top)
+ assertThat(verticalEdge(31)).isEqualTo(null)
+ assertThat(verticalEdge(69)).isEqualTo(null)
+ assertThat(verticalEdge(70)).isEqualTo(Edge.Bottom)
+ assertThat(verticalEdge(100)).isEqualTo(Edge.Bottom)
+ }
+}
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 4b3fbdf77274..1eb3392f1374 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
@@ -55,7 +55,8 @@ class SceneGestureHandlerTest {
builder = scenesBuilder,
transitions = EmptyTestTransitions,
state = layoutState,
- density = Density(1f)
+ density = Density(1f),
+ edgeDetector = DefaultEdgeDetector,
)
.also { it.size = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt()) },
orientation = Orientation.Vertical,
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 6ad2108c05a5..4a6066f5c664 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
@@ -48,6 +48,14 @@ class SwipeToSceneTest {
/** The middle of the layout, in pixels. */
private val Density.middle: Offset
get() = Offset((LayoutWidth / 2).toPx(), (LayoutHeight / 2).toPx())
+
+ /** The middle-top of the layout, in pixels. */
+ private val Density.middleTop: Offset
+ get() = Offset((LayoutWidth / 2).toPx(), 0f)
+
+ /** The middle-left of the layout, in pixels. */
+ private val Density.middleLeft: Offset
+ get() = Offset(0f, (LayoutHeight / 2).toPx())
}
private var currentScene by mutableStateOf(TestScenes.SceneA)
@@ -87,6 +95,8 @@ class SwipeToSceneTest {
mapOf(
Swipe.Down to TestScenes.SceneA,
Swipe(SwipeDirection.Down, pointerCount = 2) to TestScenes.SceneB,
+ Swipe(SwipeDirection.Right, fromEdge = Edge.Left) to TestScenes.SceneB,
+ Swipe(SwipeDirection.Down, fromEdge = Edge.Top) to TestScenes.SceneB,
),
) {
Box(Modifier.fillMaxSize())
@@ -285,4 +295,61 @@ class SwipeToSceneTest {
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
}
+
+ @Test
+ fun defaultEdgeSwipe() {
+ // Start at scene C.
+ currentScene = TestScenes.SceneC
+
+ // 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
+ rule.setContent {
+ touchSlop = LocalViewConfiguration.current.touchSlop
+ TestContent()
+ }
+
+ assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+ assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
+
+ // Swipe down from the top edge.
+ rule.onRoot().performTouchInput {
+ down(middleTop)
+ moveBy(Offset(0f, touchSlop + 10.dp.toPx()), delayMillis = 1_000)
+ }
+
+ // We are transitioning to B (and not A) because we started from the top edge.
+ var transition = layoutState.transitionState
+ assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
+ assertThat((transition as TransitionState.Transition).fromScene)
+ .isEqualTo(TestScenes.SceneC)
+ assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
+
+ // Release the fingers and wait for the animation to end. We are back to C because we only
+ // swiped 10dp.
+ rule.onRoot().performTouchInput { up() }
+ rule.waitForIdle()
+ assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+ assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
+
+ // Swipe right from the left edge.
+ rule.onRoot().performTouchInput {
+ down(middleLeft)
+ moveBy(Offset(touchSlop + 10.dp.toPx(), 0f), delayMillis = 1_000)
+ }
+
+ // We are transitioning to B (and not A) because we started from the left edge.
+ transition = layoutState.transitionState
+ assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
+ assertThat((transition as TransitionState.Transition).fromScene)
+ .isEqualTo(TestScenes.SceneC)
+ assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
+
+ // Release the fingers and wait for the animation to end. We are back to C because we only
+ // swiped 10dp.
+ rule.onRoot().performTouchInput { up() }
+ rule.waitForIdle()
+ assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
+ assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
+ }
}