Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +16 −3 Original line number Diff line number Diff line Loading @@ -29,6 +29,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.util.fastCoerceIn import com.android.compose.animation.scene.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified import com.android.compose.animation.scene.content.Scene import com.android.compose.nestedscroll.PriorityNestedScrollConnection Loading Loading @@ -288,7 +289,8 @@ private class DragControllerImpl( val toScene = swipeTransition._toScene val distance = swipeTransition.distance() val desiredOffset = swipeTransition.dragOffset + delta val previousOffset = swipeTransition.dragOffset val desiredOffset = previousOffset + delta fun hasReachedToSceneUpOrLeft() = distance < 0 && Loading @@ -312,6 +314,7 @@ private class DragControllerImpl( val fromScene: Scene val currentTransitionOffset: Float val newOffset: Float val consumedDelta: Float if (hasReachedToScene) { // The new transition will start from the current toScene fromScene = toScene Loading @@ -319,11 +322,21 @@ private class DragControllerImpl( currentTransitionOffset = distance // The next transition will start with the remaining offset newOffset = desiredOffset - distance consumedDelta = delta } else { fromScene = swipeTransition._fromScene currentTransitionOffset = desiredOffset val desiredProgress = swipeTransition.computeProgress(desiredOffset) // note: the distance could be negative if fromScene is aboveOrLeft of toScene. currentTransitionOffset = when { distance == DistanceUnspecified || swipeTransition.isWithinProgressRange(desiredProgress) -> desiredOffset distance > 0f -> desiredOffset.fastCoerceIn(0f, distance) else -> desiredOffset.fastCoerceIn(distance, 0f) } // If there is a new transition, we will use the same offset newOffset = currentTransitionOffset consumedDelta = newOffset - previousOffset } swipeTransition.dragOffset = currentTransitionOffset Loading Loading @@ -363,7 +376,7 @@ private class DragControllerImpl( updateTransition(newSwipeTransition) } return delta return consumedDelta } override fun onStop(velocity: Float, canChangeScene: Boolean): Float { Loading packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +28 −16 Original line number Diff line number Diff line Loading @@ -195,10 +195,17 @@ class DraggableHandlerTest { startedPosition: Offset = Offset.Zero, overSlop: Float, pointersDown: Int = 1, expectedConsumedOverSlop: Float = overSlop, ): DragController { // overSlop should be 0f only if the drag gesture starts with startDragImmediately if (overSlop == 0f) error("Consider using onDragStartedImmediately()") return onDragStarted(draggableHandler, startedPosition, overSlop, pointersDown) return onDragStarted( draggableHandler = draggableHandler, startedPosition = startedPosition, overSlop = overSlop, pointersDown = pointersDown, expectedConsumedOverSlop = expectedConsumedOverSlop, ) } fun onDragStartedImmediately( Loading @@ -213,7 +220,7 @@ class DraggableHandlerTest { startedPosition: Offset = Offset.Zero, overSlop: Float = 0f, pointersDown: Int = 1, expectedConsumed: Boolean = true, expectedConsumedOverSlop: Float = overSlop, ): DragController { val dragController = draggableHandler.onDragStarted( Loading @@ -223,14 +230,14 @@ class DraggableHandlerTest { ) // MultiPointerDraggable will always call onDelta with the initial overSlop right after dragController.onDragDelta(pixels = overSlop, expectedConsumed = expectedConsumed) dragController.onDragDelta(pixels = overSlop, expectedConsumedOverSlop) return dragController } fun DragController.onDragDelta(pixels: Float, expectedConsumed: Boolean = true) { fun DragController.onDragDelta(pixels: Float, expectedConsumed: Float = pixels) { val consumed = onDrag(delta = pixels) assertThat(consumed).isEqualTo(if (expectedConsumed) pixels else 0f) assertThat(consumed).isEqualTo(expectedConsumed) } fun DragController.onDragStopped( Loading Loading @@ -370,14 +377,14 @@ class DraggableHandlerTest { onDragStarted( horizontalDraggableHandler, overSlop = up(fractionOfScreen = 0.3f), expectedConsumed = false, expectedConsumedOverSlop = 0f, ) assertIdle(currentScene = SceneA) onDragStarted( horizontalDraggableHandler, overSlop = down(fractionOfScreen = 0.3f), expectedConsumed = false, expectedConsumedOverSlop = 0f, ) assertIdle(currentScene = SceneA) } Loading Loading @@ -504,19 +511,19 @@ class DraggableHandlerTest { // start accelaratedScroll and scroll over to B -> null val dragController2 = onDragStartedImmediately() dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = false) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = false) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = 0f) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = 0f) // here onDragStopped is already triggered, but subsequent onDelta/onDragStopped calls may // still be called. Make sure that they don't crash or change the scene dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = false) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = 0f) dragController2.onDragStopped(velocity = 0f) advanceUntilIdle() assertIdle(SceneB) // These events can still come in after the animation has settled dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = false) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = 0f) dragController2.onDragStopped(velocity = 0f) assertIdle(SceneB) } Loading Loading @@ -1051,8 +1058,16 @@ class DraggableHandlerTest { // Swipe up to scene B at progress = 200%. val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f) val dragController = onDragStarted(startedPosition = middle, overSlop = up(2f)) val transition = assertTransition(fromScene = SceneA, toScene = SceneB, progress = 2f) val dragController = onDragStarted( startedPosition = middle, overSlop = up(2f), // Overscroll is disabled, it will scroll up to 100% expectedConsumedOverSlop = up(1f), ) // The progress value is coerced in `[0..1]` assertTransition(fromScene = SceneA, toScene = SceneB, progress = 1f) // Release the finger. dragController.onDragStopped(velocity = -velocityThreshold) Loading @@ -1061,9 +1076,6 @@ class DraggableHandlerTest { // 100% and that the overscroll on scene B is doing nothing, we are already idle. runCurrent() assertIdle(SceneB) // Progress is snapped to 100%. assertThat(transition).hasProgress(1f) } @Test Loading packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt +60 −0 Original line number Diff line number Diff line Loading @@ -29,6 +29,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.testTag Loading Loading @@ -786,4 +789,61 @@ class SwipeToSceneTest { .onNode(isElement(SceneB.rootElementKey)) .assertPositionInRootIsEqualTo(-layoutSize, 0.dp) } @Test fun whenOverscrollIsDisabled_dragGestureShouldNotBeConsumed() { val swipeDistance = 100.dp var availableOnPostScroll = Float.MIN_VALUE val connection = object : NestedScrollConnection { override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { availableOnPostScroll = available.y return super.onPostScroll(consumed, available, source) } } val state = rule.runOnUiThread { MutableSceneTransitionLayoutState( SceneA, transitions { from(SceneA, to = SceneB) { distance = FixedDistance(swipeDistance) } overscroll(SceneB, Orientation.Vertical) } ) } val layoutSize = 200.dp var touchSlop = 0f rule.setContent { touchSlop = LocalViewConfiguration.current.touchSlop SceneTransitionLayout(state, Modifier.size(layoutSize).nestedScroll(connection)) { scene(SceneA, userActions = mapOf(Swipe.Down to SceneB)) { Box(Modifier.fillMaxSize()) } scene(SceneB) { Box(Modifier.element(TestElements.Foo).fillMaxSize()) } } } // Swipe down by the swipe distance so that we are on scene B. rule.onRoot().performTouchInput { val middle = (layoutSize / 2).toPx() down(Offset(middle, middle)) moveBy(Offset(0f, touchSlop + (swipeDistance).toPx()), delayMillis = 1_000) } val transition = state.currentTransition assertThat(transition).isNotNull() assertThat(transition!!.progress).isEqualTo(1f) assertThat(availableOnPostScroll).isEqualTo(0f) // Overscrolling on Scene B val ovescrollPx = 100f rule.onRoot().performTouchInput { moveBy(Offset(0f, ovescrollPx), delayMillis = 1_000) } // Overscroll is disabled on Scene B assertThat(transition.progress).isEqualTo(1f) assertThat(availableOnPostScroll).isEqualTo(ovescrollPx) } } Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +16 −3 Original line number Diff line number Diff line Loading @@ -29,6 +29,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.util.fastCoerceIn import com.android.compose.animation.scene.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified import com.android.compose.animation.scene.content.Scene import com.android.compose.nestedscroll.PriorityNestedScrollConnection Loading Loading @@ -288,7 +289,8 @@ private class DragControllerImpl( val toScene = swipeTransition._toScene val distance = swipeTransition.distance() val desiredOffset = swipeTransition.dragOffset + delta val previousOffset = swipeTransition.dragOffset val desiredOffset = previousOffset + delta fun hasReachedToSceneUpOrLeft() = distance < 0 && Loading @@ -312,6 +314,7 @@ private class DragControllerImpl( val fromScene: Scene val currentTransitionOffset: Float val newOffset: Float val consumedDelta: Float if (hasReachedToScene) { // The new transition will start from the current toScene fromScene = toScene Loading @@ -319,11 +322,21 @@ private class DragControllerImpl( currentTransitionOffset = distance // The next transition will start with the remaining offset newOffset = desiredOffset - distance consumedDelta = delta } else { fromScene = swipeTransition._fromScene currentTransitionOffset = desiredOffset val desiredProgress = swipeTransition.computeProgress(desiredOffset) // note: the distance could be negative if fromScene is aboveOrLeft of toScene. currentTransitionOffset = when { distance == DistanceUnspecified || swipeTransition.isWithinProgressRange(desiredProgress) -> desiredOffset distance > 0f -> desiredOffset.fastCoerceIn(0f, distance) else -> desiredOffset.fastCoerceIn(distance, 0f) } // If there is a new transition, we will use the same offset newOffset = currentTransitionOffset consumedDelta = newOffset - previousOffset } swipeTransition.dragOffset = currentTransitionOffset Loading Loading @@ -363,7 +376,7 @@ private class DragControllerImpl( updateTransition(newSwipeTransition) } return delta return consumedDelta } override fun onStop(velocity: Float, canChangeScene: Boolean): Float { Loading
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +28 −16 Original line number Diff line number Diff line Loading @@ -195,10 +195,17 @@ class DraggableHandlerTest { startedPosition: Offset = Offset.Zero, overSlop: Float, pointersDown: Int = 1, expectedConsumedOverSlop: Float = overSlop, ): DragController { // overSlop should be 0f only if the drag gesture starts with startDragImmediately if (overSlop == 0f) error("Consider using onDragStartedImmediately()") return onDragStarted(draggableHandler, startedPosition, overSlop, pointersDown) return onDragStarted( draggableHandler = draggableHandler, startedPosition = startedPosition, overSlop = overSlop, pointersDown = pointersDown, expectedConsumedOverSlop = expectedConsumedOverSlop, ) } fun onDragStartedImmediately( Loading @@ -213,7 +220,7 @@ class DraggableHandlerTest { startedPosition: Offset = Offset.Zero, overSlop: Float = 0f, pointersDown: Int = 1, expectedConsumed: Boolean = true, expectedConsumedOverSlop: Float = overSlop, ): DragController { val dragController = draggableHandler.onDragStarted( Loading @@ -223,14 +230,14 @@ class DraggableHandlerTest { ) // MultiPointerDraggable will always call onDelta with the initial overSlop right after dragController.onDragDelta(pixels = overSlop, expectedConsumed = expectedConsumed) dragController.onDragDelta(pixels = overSlop, expectedConsumedOverSlop) return dragController } fun DragController.onDragDelta(pixels: Float, expectedConsumed: Boolean = true) { fun DragController.onDragDelta(pixels: Float, expectedConsumed: Float = pixels) { val consumed = onDrag(delta = pixels) assertThat(consumed).isEqualTo(if (expectedConsumed) pixels else 0f) assertThat(consumed).isEqualTo(expectedConsumed) } fun DragController.onDragStopped( Loading Loading @@ -370,14 +377,14 @@ class DraggableHandlerTest { onDragStarted( horizontalDraggableHandler, overSlop = up(fractionOfScreen = 0.3f), expectedConsumed = false, expectedConsumedOverSlop = 0f, ) assertIdle(currentScene = SceneA) onDragStarted( horizontalDraggableHandler, overSlop = down(fractionOfScreen = 0.3f), expectedConsumed = false, expectedConsumedOverSlop = 0f, ) assertIdle(currentScene = SceneA) } Loading Loading @@ -504,19 +511,19 @@ class DraggableHandlerTest { // start accelaratedScroll and scroll over to B -> null val dragController2 = onDragStartedImmediately() dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = false) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = false) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = 0f) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = 0f) // here onDragStopped is already triggered, but subsequent onDelta/onDragStopped calls may // still be called. Make sure that they don't crash or change the scene dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = false) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = 0f) dragController2.onDragStopped(velocity = 0f) advanceUntilIdle() assertIdle(SceneB) // These events can still come in after the animation has settled dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = false) dragController2.onDragDelta(pixels = up(fractionOfScreen = 0.5f), expectedConsumed = 0f) dragController2.onDragStopped(velocity = 0f) assertIdle(SceneB) } Loading Loading @@ -1051,8 +1058,16 @@ class DraggableHandlerTest { // Swipe up to scene B at progress = 200%. val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f) val dragController = onDragStarted(startedPosition = middle, overSlop = up(2f)) val transition = assertTransition(fromScene = SceneA, toScene = SceneB, progress = 2f) val dragController = onDragStarted( startedPosition = middle, overSlop = up(2f), // Overscroll is disabled, it will scroll up to 100% expectedConsumedOverSlop = up(1f), ) // The progress value is coerced in `[0..1]` assertTransition(fromScene = SceneA, toScene = SceneB, progress = 1f) // Release the finger. dragController.onDragStopped(velocity = -velocityThreshold) Loading @@ -1061,9 +1076,6 @@ class DraggableHandlerTest { // 100% and that the overscroll on scene B is doing nothing, we are already idle. runCurrent() assertIdle(SceneB) // Progress is snapped to 100%. assertThat(transition).hasProgress(1f) } @Test Loading
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt +60 −0 Original line number Diff line number Diff line Loading @@ -29,6 +29,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.testTag Loading Loading @@ -786,4 +789,61 @@ class SwipeToSceneTest { .onNode(isElement(SceneB.rootElementKey)) .assertPositionInRootIsEqualTo(-layoutSize, 0.dp) } @Test fun whenOverscrollIsDisabled_dragGestureShouldNotBeConsumed() { val swipeDistance = 100.dp var availableOnPostScroll = Float.MIN_VALUE val connection = object : NestedScrollConnection { override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { availableOnPostScroll = available.y return super.onPostScroll(consumed, available, source) } } val state = rule.runOnUiThread { MutableSceneTransitionLayoutState( SceneA, transitions { from(SceneA, to = SceneB) { distance = FixedDistance(swipeDistance) } overscroll(SceneB, Orientation.Vertical) } ) } val layoutSize = 200.dp var touchSlop = 0f rule.setContent { touchSlop = LocalViewConfiguration.current.touchSlop SceneTransitionLayout(state, Modifier.size(layoutSize).nestedScroll(connection)) { scene(SceneA, userActions = mapOf(Swipe.Down to SceneB)) { Box(Modifier.fillMaxSize()) } scene(SceneB) { Box(Modifier.element(TestElements.Foo).fillMaxSize()) } } } // Swipe down by the swipe distance so that we are on scene B. rule.onRoot().performTouchInput { val middle = (layoutSize / 2).toPx() down(Offset(middle, middle)) moveBy(Offset(0f, touchSlop + (swipeDistance).toPx()), delayMillis = 1_000) } val transition = state.currentTransition assertThat(transition).isNotNull() assertThat(transition!!.progress).isEqualTo(1f) assertThat(availableOnPostScroll).isEqualTo(0f) // Overscrolling on Scene B val ovescrollPx = 100f rule.onRoot().performTouchInput { moveBy(Offset(0f, ovescrollPx), delayMillis = 1_000) } // Overscroll is disabled on Scene B assertThat(transition.progress).isEqualTo(1f) assertThat(availableOnPostScroll).isEqualTo(ovescrollPx) } }