diff options
author | 2023-10-31 08:40:33 +0000 | |
---|---|---|
committer | 2023-10-31 08:40:33 +0000 | |
commit | 612e70e81a2e7170e8da3be49cade031d76a18ca (patch) | |
tree | 32ea1705f52b1bd9cb2e70a632cd837408210f06 | |
parent | 7f2312f6c1d825bcb00acf564cd287723ddf8e40 (diff) | |
parent | 13489cae926ae81fd1d3044ef3665206358e6793 (diff) |
Merge changes from topics "stl-edge-swipe", "stl-multiple-pointers" into main
* changes:
Add support for swipes started from an edge
Add support for swipe with multiple fingers
10 files changed, 568 insertions, 58 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/GestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt index d005413fcbcf..ae7d8f599b91 100644 --- 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 @@ -2,7 +2,6 @@ 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 @@ -10,9 +9,9 @@ interface GestureHandler { } interface DraggableHandler { - suspend fun onDragStarted(coroutineScope: CoroutineScope, startedPosition: Offset) + fun onDragStarted(startedPosition: Offset, pointersDown: Int = 1) fun onDelta(pixels: Float) - suspend fun onDragStopped(coroutineScope: CoroutineScope, velocity: Float) + fun onDragStopped(velocity: Float) } interface NestedScrollHandler { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt new file mode 100644 index 000000000000..97d3fff48b23 --- /dev/null +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt @@ -0,0 +1,191 @@ +/* + * 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.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.horizontalDrag +import androidx.compose.foundation.gestures.verticalDrag +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.util.fastForEach + +/** + * Make an element draggable in the given [orientation]. + * + * The main difference with [multiPointerDraggable] and + * [androidx.compose.foundation.gestures.draggable] is that [onDragStarted] also receives the number + * of pointers that are down when the drag is started. If you don't need this information, you + * should use `draggable` instead. + * + * Note that the current implementation is trivial: we wait for the touch slope on the *first* down + * pointer, then we count the number of distinct pointers that are down right before calling + * [onDragStarted]. This means that the drag won't start when a first pointer is down (but not + * dragged) and a second pointer is down and dragged. This is an implementation detail that might + * change in the future. + */ +// TODO(b/291055080): Migrate to the Modifier.Node API. +@Composable +internal fun Modifier.multiPointerDraggable( + orientation: Orientation, + enabled: Boolean, + startDragImmediately: Boolean, + onDragStarted: (startedPosition: Offset, pointersDown: Int) -> Unit, + onDragDelta: (Float) -> Unit, + onDragStopped: (velocity: Float) -> Unit, +): Modifier { + val onDragStarted by rememberUpdatedState(onDragStarted) + val onDragStopped by rememberUpdatedState(onDragStopped) + val onDragDelta by rememberUpdatedState(onDragDelta) + val startDragImmediately by rememberUpdatedState(startDragImmediately) + + val velocityTracker = remember { VelocityTracker() } + val maxFlingVelocity = + LocalViewConfiguration.current.maximumFlingVelocity.let { max -> + val maxF = max.toFloat() + Velocity(maxF, maxF) + } + + return this.pointerInput(enabled, orientation, maxFlingVelocity) { + if (!enabled) { + return@pointerInput + } + + val onDragStart: (Offset, Int) -> Unit = { startedPosition, pointersDown -> + velocityTracker.resetTracking() + onDragStarted(startedPosition, pointersDown) + } + + val onDragCancel: () -> Unit = { onDragStopped(/* velocity= */ 0f) } + + val onDragEnd: () -> Unit = { + val velocity = velocityTracker.calculateVelocity(maxFlingVelocity) + onDragStopped( + when (orientation) { + Orientation.Horizontal -> velocity.x + Orientation.Vertical -> velocity.y + } + ) + } + + val onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit = { change, amount -> + velocityTracker.addPointerInputChange(change) + onDragDelta(amount) + } + + detectDragGestures( + orientation = orientation, + startDragImmediately = { startDragImmediately }, + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + onDrag = onDrag, + ) + } +} + +/** + * Detect drag gestures in the given [orientation]. + * + * This function is a mix of [androidx.compose.foundation.gestures.awaitDownAndSlop] and + * [androidx.compose.foundation.gestures.detectVerticalDragGestures] to add support for: + * 1) starting the gesture immediately without requiring a drag >= touch slope; + * 2) passing the number of pointers down to [onDragStart]. + */ +private suspend fun PointerInputScope.detectDragGestures( + orientation: Orientation, + startDragImmediately: () -> Boolean, + onDragStart: (startedPosition: Offset, pointersDown: Int) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit, +) { + awaitEachGesture { + val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + var overSlop = 0f + val drag = + if (startDragImmediately()) { + initialDown.consume() + initialDown + } else { + val down = awaitFirstDown(requireUnconsumed = false) + val onSlopReached = { change: PointerInputChange, over: Float -> + change.consume() + overSlop = over + } + + // TODO(b/291055080): Replace by await[Orientation]PointerSlopOrCancellation once + // it is public. + when (orientation) { + Orientation.Horizontal -> + awaitHorizontalTouchSlopOrCancellation(down.id, onSlopReached) + Orientation.Vertical -> + awaitVerticalTouchSlopOrCancellation(down.id, onSlopReached) + } + } + + if (drag != null) { + // Count the number of pressed pointers. + val pressed = mutableSetOf<PointerId>() + currentEvent.changes.fastForEach { change -> + if (change.pressed) { + pressed.add(change.id) + } + } + + onDragStart(drag.position, pressed.size) + onDrag(drag, overSlop) + + val successful = + when (orientation) { + Orientation.Horizontal -> + horizontalDrag(drag.id) { + onDrag(it, it.positionChange().x) + it.consume() + } + Orientation.Vertical -> + verticalDrag(drag.id) { + onDrag(it, it.positionChange().y) + it.consume() + } + } + + if (successful) { + onDragEnd() + } else { + onDragCancel() + } + } + } +} 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 9c799b282571..3fd6828fca6b 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,7 +16,6 @@ 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 @@ -101,19 +100,3 @@ 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/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt index 74e66d2a9949..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 @@ -16,6 +16,7 @@ package com.android.compose.animation.scene +import androidx.compose.foundation.gestures.Orientation import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.remember @@ -37,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 @@ -46,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 @@ -56,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) } @@ -191,9 +196,9 @@ data class Swipe( } } -enum class SwipeDirection { - Up, - Down, - Left, - Right, +enum class SwipeDirection(val orientation: Orientation) { + Up(Orientation.Vertical), + Down(Orientation.Vertical), + Left(Orientation.Horizontal), + Right(Orientation.Horizontal), } 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 2dc53ab8bf76..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 @@ -22,8 +22,6 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -37,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 @@ -55,7 +54,7 @@ internal fun Modifier.swipeToScene( /** Whether swipe should be enabled in the given [orientation]. */ fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean = - upOrLeft(orientation) != null || downOrRight(orientation) != null + userActions.keys.any { it is Swipe && it.direction.orientation == orientation } val currentScene = gestureHandler.currentScene val canSwipe = currentScene.shouldEnableSwipes(orientation) @@ -68,8 +67,7 @@ internal fun Modifier.swipeToScene( ) return nestedScroll(connection = gestureHandler.nestedScroll.connection) - .draggable( - state = rememberDraggableState(onDelta = gestureHandler.draggable::onDelta), + .multiPointerDraggable( orientation = orientation, enabled = gestureHandler.isDrivingTransition || canSwipe, // Immediately start the drag if this our [transition] is currently animating to a scene @@ -80,6 +78,7 @@ internal fun Modifier.swipeToScene( gestureHandler.isAnimatingOffset && !canOppositeSwipe, onDragStarted = gestureHandler.draggable::onDragStarted, + onDragDelta = gestureHandler.draggable::onDelta, onDragStopped = gestureHandler.draggable::onDragStopped, ) } @@ -159,7 +158,7 @@ class SceneGestureHandler( internal var gestureWithPriority: Any? = null - internal fun onDragStarted() { + 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. @@ -199,6 +198,48 @@ 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 = + when (orientation) { + Orientation.Horizontal -> SwipeDirection.Left + Orientation.Vertical -> SwipeDirection.Up + }, + pointerCount = pointersDown, + fromEdge = fromEdge, + ) + + swipeTransition.actionDownOrRight = + Swipe( + direction = + when (orientation) { + Orientation.Horizontal -> SwipeDirection.Right + 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 } @@ -246,11 +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.upOrLeft(orientation) == toScene.key) { + if (offset <= -absoluteDistance && swipeTransition.upOrLeft(fromScene) == toScene.key) { swipeTransition.dragOffset += absoluteDistance swipeTransition._fromScene = toScene } else if ( - offset >= absoluteDistance && fromScene.downOrRight(orientation) == toScene.key + offset >= absoluteDistance && swipeTransition.downOrRight(fromScene) == toScene.key ) { swipeTransition.dragOffset -= absoluteDistance swipeTransition._fromScene = toScene @@ -272,8 +313,8 @@ class SceneGestureHandler( Orientation.Vertical -> layoutImpl.size.height }.toFloat() - val upOrLeft = upOrLeft(orientation) - val downOrRight = downOrRight(orientation) + val upOrLeft = swipeTransition.upOrLeft(this) + val downOrRight = swipeTransition.downOrRight(this) // Compute the target scene depending on the current offset. return when { @@ -516,6 +557,22 @@ class SceneGestureHandler( var _distance by mutableFloatStateOf(0f) val distance: Float get() = _distance + + /** 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 { @@ -526,9 +583,9 @@ class SceneGestureHandler( private class SceneDraggableHandler( private val gestureHandler: SceneGestureHandler, ) : DraggableHandler { - override suspend fun onDragStarted(coroutineScope: CoroutineScope, startedPosition: Offset) { + override fun onDragStarted(startedPosition: Offset, pointersDown: Int) { gestureHandler.gestureWithPriority = this - gestureHandler.onDragStarted() + gestureHandler.onDragStarted(pointersDown, startedPosition) } override fun onDelta(pixels: Float) { @@ -537,7 +594,7 @@ private class SceneDraggableHandler( } } - override suspend fun onDragStopped(coroutineScope: CoroutineScope, velocity: Float) { + override fun onDragStopped(velocity: Float) { if (gestureHandler.gestureWithPriority == this) { gestureHandler.gestureWithPriority = null gestureHandler.onDragStopped(velocity = velocity, canChangeScene = true) @@ -580,11 +637,31 @@ class SceneNestedScrollHandler( // moving on to the next scene. var gestureStartedOnNestedChild = false + val actionUpOrLeft = + Swipe( + direction = + when (gestureHandler.orientation) { + Orientation.Horizontal -> SwipeDirection.Left + Orientation.Vertical -> SwipeDirection.Up + }, + pointerCount = 1, + ) + + val actionDownOrRight = + Swipe( + direction = + when (gestureHandler.orientation) { + Orientation.Horizontal -> SwipeDirection.Right + Orientation.Vertical -> SwipeDirection.Down + }, + pointerCount = 1, + ) + fun findNextScene(amount: Float): SceneKey? { val fromScene = gestureHandler.currentScene return when { - amount < 0f -> fromScene.upOrLeft(gestureHandler.orientation) - amount > 0f -> fromScene.downOrRight(gestureHandler.orientation) + amount < 0f -> fromScene.userActions[actionUpOrLeft] + amount > 0f -> fromScene.userActions[actionDownOrRight] else -> null } } @@ -625,7 +702,7 @@ class SceneNestedScrollHandler( onStart = { gestureHandler.gestureWithPriority = this priorityScene = nextScene - gestureHandler.onDragStarted() + 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 6791a85ff21c..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, @@ -104,13 +105,13 @@ class SceneGestureHandlerTest { @Test fun onDragStarted_shouldStartATransition() = runGestureTest { - draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero) + draggable.onDragStarted(startedPosition = Offset.Zero) assertScene(currentScene = SceneA, isIdle = false) } @Test fun afterSceneTransitionIsStarted_interceptDragEvents() = runGestureTest { - draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero) + draggable.onDragStarted(startedPosition = Offset.Zero) assertScene(currentScene = SceneA, isIdle = false) val transition = transitionState as Transition @@ -123,14 +124,13 @@ class SceneGestureHandlerTest { @Test fun onDragStoppedAfterDrag_velocityLowerThanThreshold_remainSameScene() = runGestureTest { - draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero) + draggable.onDragStarted(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) @@ -142,14 +142,13 @@ class SceneGestureHandlerTest { @Test fun onDragStoppedAfterDrag_velocityAtLeastThreshold_goToNextScene() = runGestureTest { - draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero) + draggable.onDragStarted(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) @@ -161,23 +160,22 @@ class SceneGestureHandlerTest { @Test fun onDragStoppedAfterStarted_returnImmediatelyToIdle() = runGestureTest { - draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero) + draggable.onDragStarted(startedPosition = Offset.Zero) assertScene(currentScene = SceneA, isIdle = false) - draggable.onDragStopped(coroutineScope = coroutineScope, velocity = 0f) + draggable.onDragStopped(velocity = 0f) assertScene(currentScene = SceneA, isIdle = true) } @Test fun startGestureDuringAnimatingOffset_shouldImmediatelyStopTheAnimation() = runGestureTest { - draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero) + draggable.onDragStarted(startedPosition = Offset.Zero) assertScene(currentScene = SceneA, isIdle = false) draggable.onDelta(pixels = deltaInPixels10) assertScene(currentScene = SceneA, isIdle = false) draggable.onDragStopped( - coroutineScope = coroutineScope, velocity = velocityThreshold, ) @@ -191,7 +189,7 @@ class SceneGestureHandlerTest { assertScene(currentScene = SceneC, isIdle = false) // Start a new gesture while the offset is animating - draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero) + draggable.onDragStarted(startedPosition = Offset.Zero) assertThat(sceneGestureHandler.isAnimatingOffset).isFalse() } @@ -320,7 +318,7 @@ class SceneGestureHandlerTest { } @Test fun beforeDraggableStart_stop_shouldBeIgnored() = runGestureTest { - draggable.onDragStopped(coroutineScope, velocityThreshold) + draggable.onDragStopped(velocityThreshold) assertScene(currentScene = SceneA, isIdle = true) } @@ -332,7 +330,7 @@ class SceneGestureHandlerTest { @Test fun startNestedScrollWhileDragging() = runGestureTest { - draggable.onDragStarted(coroutineScope, Offset.Zero) + draggable.onDragStarted(Offset.Zero) assertScene(currentScene = SceneA, isIdle = false) val transition = transitionState as Transition @@ -344,7 +342,7 @@ class SceneGestureHandlerTest { assertThat(transition.progress).isEqualTo(0.2f) // this should be ignored, we are scrolling now! - draggable.onDragStopped(coroutineScope, velocityThreshold) + draggable.onDragStopped(velocityThreshold) assertScene(currentScene = SceneA, isIdle = false) nestedScrollEvents(available = offsetY10) 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 df3b72aa5533..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) @@ -83,7 +91,13 @@ class SwipeToSceneTest { } scene( TestScenes.SceneC, - userActions = mapOf(Swipe.Down to TestScenes.SceneA), + userActions = + 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()) } @@ -242,4 +256,100 @@ class SwipeToSceneTest { assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java) assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC) } + + @Test + fun multiPointerSwipe() { + // 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 with two fingers. + rule.onRoot().performTouchInput { + repeat(2) { i -> down(pointerId = i, middle) } + repeat(2) { i -> + moveBy(pointerId = i, Offset(0f, touchSlop + 10.dp.toPx()), delayMillis = 1_000) + } + } + + // We are transitioning to B because we used 2 fingers. + val 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 { repeat(2) { i -> up(pointerId = i) } } + rule.waitForIdle() + 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) + } } |