diff options
| author | 2023-10-30 13:57:46 +0100 | |
|---|---|---|
| committer | 2023-10-30 15:11:08 +0100 | |
| commit | 13489cae926ae81fd1d3044ef3665206358e6793 (patch) | |
| tree | 4df5cbf64b9b58fb6d1f6397b378e470f5802520 | |
| parent | de94ac9d388bf82656cf0c5ecd6e0b1cec893c11 (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
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) + } } |