Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt +136 −31 Original line number Original line Diff line number Diff line Loading @@ -16,12 +16,16 @@ package com.android.compose.animation.scene package com.android.compose.animation.scene import androidx.annotation.VisibleForTesting import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass Loading @@ -36,13 +40,11 @@ import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.node.findNearestAncestor import androidx.compose.ui.node.observeReads import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize Loading @@ -51,6 +53,7 @@ import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastSumBy import androidx.compose.ui.util.fastSumBy import com.android.compose.ui.util.SpaceVectorConverter import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException import kotlin.math.sign import kotlin.math.sign import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope Loading @@ -71,6 +74,7 @@ import kotlinx.coroutines.launch * dragged) and a second pointer is down and dragged. This is an implementation detail that might * dragged) and a second pointer is down and dragged. This is an implementation detail that might * change in the future. * change in the future. */ */ @VisibleForTesting @Stable @Stable internal fun Modifier.multiPointerDraggable( internal fun Modifier.multiPointerDraggable( orientation: Orientation, orientation: Orientation, Loading @@ -78,6 +82,7 @@ internal fun Modifier.multiPointerDraggable( startDragImmediately: (startedPosition: Offset) -> Boolean, startDragImmediately: (startedPosition: Offset) -> Boolean, onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, swipeDetector: SwipeDetector = DefaultSwipeDetector, swipeDetector: SwipeDetector = DefaultSwipeDetector, dispatcher: NestedScrollDispatcher, ): Modifier = ): Modifier = this.then( this.then( MultiPointerDraggableElement( MultiPointerDraggableElement( Loading @@ -86,6 +91,7 @@ internal fun Modifier.multiPointerDraggable( startDragImmediately, startDragImmediately, onDragStarted, onDragStarted, swipeDetector, swipeDetector, dispatcher, ) ) ) ) Loading @@ -96,6 +102,7 @@ private data class MultiPointerDraggableElement( private val onDragStarted: private val onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, private val swipeDetector: SwipeDetector, private val swipeDetector: SwipeDetector, private val dispatcher: NestedScrollDispatcher, ) : ModifierNodeElement<MultiPointerDraggableNode>() { ) : ModifierNodeElement<MultiPointerDraggableNode>() { override fun create(): MultiPointerDraggableNode = override fun create(): MultiPointerDraggableNode = MultiPointerDraggableNode( MultiPointerDraggableNode( Loading @@ -104,6 +111,7 @@ private data class MultiPointerDraggableElement( startDragImmediately = startDragImmediately, startDragImmediately = startDragImmediately, onDragStarted = onDragStarted, onDragStarted = onDragStarted, swipeDetector = swipeDetector, swipeDetector = swipeDetector, dispatcher = dispatcher, ) ) override fun update(node: MultiPointerDraggableNode) { override fun update(node: MultiPointerDraggableNode) { Loading @@ -122,11 +130,13 @@ internal class MultiPointerDraggableNode( var onDragStarted: var onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, var swipeDetector: SwipeDetector = DefaultSwipeDetector, var swipeDetector: SwipeDetector = DefaultSwipeDetector, private val dispatcher: NestedScrollDispatcher, ) : ) : DelegatingNode(), DelegatingNode(), PointerInputModifierNode, PointerInputModifierNode, CompositionLocalConsumerModifierNode, CompositionLocalConsumerModifierNode, ObserverModifierNode { ObserverModifierNode, SpaceVectorConverter { private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { pointerInput() } private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { pointerInput() } private val delegate = delegate(SuspendingPointerInputModifierNode(pointerInputHandler)) private val delegate = delegate(SuspendingPointerInputModifierNode(pointerInputHandler)) private val velocityTracker = VelocityTracker() private val velocityTracker = VelocityTracker() Loading @@ -141,26 +151,22 @@ internal class MultiPointerDraggableNode( } } } } private var _toFloat = orientation.toFunctionOffsetToFloat() private var converter = SpaceVectorConverter(orientation) private fun Offset.toFloat(): Float = _toFloat(this) override fun Offset.toFloat(): Float = with(converter) { this@toFloat.toFloat() } private fun Orientation.toFunctionOffsetToFloat(): (Offset) -> Float = override fun Velocity.toFloat(): Float = with(converter) { this@toFloat.toFloat() } when (this) { Orientation.Vertical -> { override fun Float.toOffset(): Offset = with(converter) { this@toOffset.toOffset() } { it.y } } override fun Float.toVelocity(): Velocity = with(converter) { this@toVelocity.toVelocity() } Orientation.Horizontal -> { { it.x } } } var orientation: Orientation = orientation var orientation: Orientation = orientation set(value) { set(value) { // Reset the pointer input whenever orientation changed. // Reset the pointer input whenever orientation changed. if (value != field) { if (value != field) { field = value field = value _toFloat = field.toFunctionOffsetToFloat() converter = SpaceVectorConverter(value) delegate.resetPointerInputHandler() delegate.resetPointerInputHandler() } } } } Loading Loading @@ -240,28 +246,32 @@ internal class MultiPointerDraggableNode( }, }, onDrag = { controller, change, amount -> onDrag = { controller, change, amount -> velocityTracker.addPointerInputChange(change) velocityTracker.addPointerInputChange(change) controller.onDrag(amount) dispatchScrollEvents( availableOnPreScroll = amount, onScroll = { controller.onDrag(it) }, source = NestedScrollSource.UserInput, ) }, }, onDragEnd = { controller -> onDragEnd = { controller -> val viewConfiguration = currentValueOf(LocalViewConfiguration) startFlingGesture( val maxVelocity = initialVelocity = viewConfiguration.maximumFlingVelocity.let { currentValueOf(LocalViewConfiguration) Velocity(it, it) .maximumFlingVelocity } .let { val velocity = velocityTracker.calculateVelocity(maxVelocity) val maxVelocity = Velocity(it, it) controller.onStop( velocityTracker.calculateVelocity(maxVelocity) velocity = } when (orientation) { .toFloat(), Orientation.Horizontal -> velocity.x onFling = { controller.onStop(it, canChangeScene = true) } Orientation.Vertical -> velocity.y }, canChangeScene = true, ) ) }, }, onDragCancel = { controller -> onDragCancel = { controller -> controller.onStop(velocity = 0f, canChangeScene = true) startFlingGesture( initialVelocity = 0f, onFling = { controller.onStop(it, canChangeScene = true) } ) }, }, swipeDetector = swipeDetector swipeDetector = swipeDetector, ) ) } catch (exception: CancellationException) { } catch (exception: CancellationException) { // If the coroutine scope is active, we can just restart the drag cycle. // If the coroutine scope is active, we can just restart the drag cycle. Loading @@ -275,6 +285,101 @@ internal class MultiPointerDraggableNode( } } } } /** * Start a fling gesture in another CoroutineScope, this is to ensure that even when the pointer * input scope is reset we will continue any coroutine scope that we started from these methods * while the pointer input scope was active. * * Note: Inspired by [androidx.compose.foundation.gestures.ScrollableNode.onDragStopped] */ private fun startFlingGesture(initialVelocity: Float, onFling: (velocity: Float) -> Float) { // Note: [AwaitPointerEventScope] is annotated as @RestrictsSuspension, we need another // CoroutineScope to run the fling gestures. // We do not need to cancel this [Job], the source will take care of emitting an // [onPostFling] before starting a new gesture. dispatcher.coroutineScope.launch { dispatchFlingEvents(availableOnPreFling = initialVelocity, onFling = onFling) } } /** * Use the nested scroll system to fire scroll events. This allows us to consume events from our * ancestors during the pre-scroll and post-scroll phases. * * @param availableOnPreScroll amount available before the scroll, this can be partially * consumed by our ancestors. * @param onScroll function that returns the amount consumed during a scroll given the amount * available after the [NestedScrollConnection.onPreScroll]. * @param source the source of the scroll event * @return Total offset consumed. */ private inline fun dispatchScrollEvents( availableOnPreScroll: Float, onScroll: (delta: Float) -> Float, source: NestedScrollSource, ): Float { // PreScroll phase val consumedByPreScroll = dispatcher .dispatchPreScroll( available = availableOnPreScroll.toOffset(), source = source, ) .toFloat() // Scroll phase val availableOnScroll = availableOnPreScroll - consumedByPreScroll val consumedBySelfScroll = onScroll(availableOnScroll) // PostScroll phase val availableOnPostScroll = availableOnScroll - consumedBySelfScroll val consumedByPostScroll = dispatcher .dispatchPostScroll( consumed = consumedBySelfScroll.toOffset(), available = availableOnPostScroll.toOffset(), source = source, ) .toFloat() return consumedByPreScroll + consumedBySelfScroll + consumedByPostScroll } /** * Use the nested scroll system to fire fling events. This allows us to consume events from our * ancestors during the pre-fling and post-fling phases. * * @param availableOnPreFling velocity available before the fling, this can be partially * consumed by our ancestors. * @param onFling function that returns the velocity consumed during the fling given the * velocity available after the [NestedScrollConnection.onPreFling]. * @return Total velocity consumed. */ private suspend inline fun dispatchFlingEvents( availableOnPreFling: Float, onFling: (velocity: Float) -> Float, ): Float { // PreFling phase val consumedByPreFling = dispatcher.dispatchPreFling(available = availableOnPreFling.toVelocity()).toFloat() // Fling phase val availableOnFling = availableOnPreFling - consumedByPreFling val consumedBySelfFling = onFling(availableOnFling) // PostFling phase val availableOnPostFling = availableOnFling - consumedBySelfFling val consumedByPostFling = dispatcher .dispatchPostFling( consumed = consumedBySelfFling.toVelocity(), available = availableOnPostFling.toVelocity(), ) .toFloat() return consumedByPreFling + consumedBySelfFling + consumedByPostFling } /** /** * Detect drag gestures in the given [orientation]. * Detect drag gestures in the given [orientation]. * * Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt +4 −1 Original line number Original line Diff line number Diff line Loading @@ -20,6 +20,7 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass Loading Loading @@ -57,6 +58,7 @@ private class SwipeToSceneNode( draggableHandler: DraggableHandlerImpl, draggableHandler: DraggableHandlerImpl, swipeDetector: SwipeDetector, swipeDetector: SwipeDetector, ) : DelegatingNode(), PointerInputModifierNode { ) : DelegatingNode(), PointerInputModifierNode { private val dispatcher = NestedScrollDispatcher() private val multiPointerDraggableNode = private val multiPointerDraggableNode = delegate( delegate( MultiPointerDraggableNode( MultiPointerDraggableNode( Loading @@ -65,6 +67,7 @@ private class SwipeToSceneNode( startDragImmediately = ::startDragImmediately, startDragImmediately = ::startDragImmediately, onDragStarted = draggableHandler::onDragStarted, onDragStarted = draggableHandler::onDragStarted, swipeDetector = swipeDetector, swipeDetector = swipeDetector, dispatcher = dispatcher, ) ) ) ) Loading Loading @@ -93,7 +96,7 @@ private class SwipeToSceneNode( ) ) init { init { delegate(nestedScrollModifierNode(nestedScrollHandlerImpl.connection, dispatcher = null)) delegate(nestedScrollModifierNode(nestedScrollHandlerImpl.connection, dispatcher)) delegate(ScrollBehaviorOwnerNode(draggableHandler.nestedScrollKey, nestedScrollHandlerImpl)) delegate(ScrollBehaviorOwnerNode(draggableHandler.nestedScrollKey, nestedScrollHandlerImpl)) } } Loading packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt +136 −4 Original line number Original line Diff line number Diff line Loading @@ -28,6 +28,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputChange Loading @@ -37,6 +41,7 @@ import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.Velocity import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope Loading @@ -49,17 +54,22 @@ import org.junit.runner.RunWith class MultiPointerDraggableTest { class MultiPointerDraggableTest { @get:Rule val rule = createComposeRule() @get:Rule val rule = createComposeRule() private val emptyConnection = object : NestedScrollConnection {} private val defaultDispatcher = NestedScrollDispatcher() private fun Modifier.nestedScrollDispatcher() = nestedScroll(emptyConnection, defaultDispatcher) private class SimpleDragController( private class SimpleDragController( val onDrag: () -> Unit, val onDrag: (delta: Float) -> Unit, val onStop: () -> Unit, val onStop: (velocity: Float) -> Unit, ) : DragController { ) : DragController { override fun onDrag(delta: Float): Float { override fun onDrag(delta: Float): Float { onDrag() onDrag.invoke(delta) return delta return delta } } override fun onStop(velocity: Float, canChangeScene: Boolean): Float { override fun onStop(velocity: Float, canChangeScene: Boolean): Float { onStop() onStop.invoke(velocity) return velocity return velocity } } } } Loading @@ -79,6 +89,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { enabled }, enabled = { enabled }, Loading @@ -90,6 +101,7 @@ class MultiPointerDraggableTest { onStop = { stopped = true }, onStop = { stopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) ) } } Loading Loading @@ -145,6 +157,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -157,6 +170,7 @@ class MultiPointerDraggableTest { onStop = { stopped = true }, onStop = { stopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) .pointerInput(Unit) { .pointerInput(Unit) { coroutineScope { coroutineScope { Loading Loading @@ -217,6 +231,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -228,6 +243,7 @@ class MultiPointerDraggableTest { onStop = { stopped = true }, onStop = { stopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) { ) { if (hasScrollable) { if (hasScrollable) { Loading Loading @@ -335,6 +351,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -346,6 +363,7 @@ class MultiPointerDraggableTest { onStop = { stopped = true }, onStop = { stopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) { ) { Box( Box( Loading Loading @@ -436,6 +454,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -447,6 +466,7 @@ class MultiPointerDraggableTest { onStop = { verticalStopped = true }, onStop = { verticalStopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Horizontal, orientation = Orientation.Horizontal, Loading @@ -459,6 +479,7 @@ class MultiPointerDraggableTest { onStop = { horizontalStopped = true }, onStop = { horizontalStopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) ) } } Loading Loading @@ -539,6 +560,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -557,6 +579,7 @@ class MultiPointerDraggableTest { onStop = { /* do nothing */ }, onStop = { /* do nothing */ }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) {} ) {} } } Loading Loading @@ -587,4 +610,113 @@ class MultiPointerDraggableTest { assertThat(started).isTrue() assertThat(started).isTrue() } } @Test fun multiPointerNestedScrollDispatcher() { val size = 200f val middle = Offset(size / 2f, size / 2f) var touchSlop = 0f var consumedOnPreScroll = 0f var availableOnPreScroll = Float.MIN_VALUE var availableOnPostScroll = Float.MIN_VALUE var availableOnPreFling = Float.MIN_VALUE var availableOnPostFling = Float.MIN_VALUE var consumedOnDrag = 0f var consumedOnDragStop = 0f val connection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { availableOnPreScroll = available.y return Offset(0f, consumedOnPreScroll) } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { availableOnPostScroll = available.y return Offset.Zero } override suspend fun onPreFling(available: Velocity): Velocity { availableOnPreFling = available.y return Velocity.Zero } override suspend fun onPostFling( consumed: Velocity, available: Velocity ): Velocity { availableOnPostFling = available.y return Velocity.Zero } } rule.setContent { touchSlop = LocalViewConfiguration.current.touchSlop Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScroll(connection) .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> SimpleDragController( onDrag = { consumedOnDrag = it }, onStop = { consumedOnDragStop = it }, ) }, dispatcher = defaultDispatcher, ) ) } fun startDrag() { rule.onRoot().performTouchInput { down(middle) moveBy(Offset(0f, touchSlop)) } } fun continueDrag() { rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } } fun stopDrag() { rule.onRoot().performTouchInput { up() } } startDrag() continueDrag() assertThat(availableOnPreScroll).isEqualTo(touchSlop) assertThat(consumedOnDrag).isEqualTo(touchSlop) assertThat(availableOnPostScroll).isEqualTo(0f) // Parent node consumes half of the gesture consumedOnPreScroll = touchSlop / 2f continueDrag() assertThat(availableOnPreScroll).isEqualTo(touchSlop) assertThat(consumedOnDrag).isEqualTo(touchSlop / 2f) assertThat(availableOnPostScroll).isEqualTo(0f) // Parent node consumes the gesture consumedOnPreScroll = touchSlop continueDrag() assertThat(availableOnPreScroll).isEqualTo(touchSlop) assertThat(consumedOnDrag).isEqualTo(0f) assertThat(availableOnPostScroll).isEqualTo(0f) // Parent node can intercept the velocity on stop stopDrag() assertThat(availableOnPreFling).isEqualTo(consumedOnDragStop) assertThat(availableOnPostFling).isEqualTo(0f) } } } Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt +136 −31 Original line number Original line Diff line number Diff line Loading @@ -16,12 +16,16 @@ package com.android.compose.animation.scene package com.android.compose.animation.scene import androidx.annotation.VisibleForTesting import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass Loading @@ -36,13 +40,11 @@ import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.node.findNearestAncestor import androidx.compose.ui.node.observeReads import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize Loading @@ -51,6 +53,7 @@ import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastSumBy import androidx.compose.ui.util.fastSumBy import com.android.compose.ui.util.SpaceVectorConverter import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException import kotlin.math.sign import kotlin.math.sign import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope Loading @@ -71,6 +74,7 @@ import kotlinx.coroutines.launch * dragged) and a second pointer is down and dragged. This is an implementation detail that might * dragged) and a second pointer is down and dragged. This is an implementation detail that might * change in the future. * change in the future. */ */ @VisibleForTesting @Stable @Stable internal fun Modifier.multiPointerDraggable( internal fun Modifier.multiPointerDraggable( orientation: Orientation, orientation: Orientation, Loading @@ -78,6 +82,7 @@ internal fun Modifier.multiPointerDraggable( startDragImmediately: (startedPosition: Offset) -> Boolean, startDragImmediately: (startedPosition: Offset) -> Boolean, onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, swipeDetector: SwipeDetector = DefaultSwipeDetector, swipeDetector: SwipeDetector = DefaultSwipeDetector, dispatcher: NestedScrollDispatcher, ): Modifier = ): Modifier = this.then( this.then( MultiPointerDraggableElement( MultiPointerDraggableElement( Loading @@ -86,6 +91,7 @@ internal fun Modifier.multiPointerDraggable( startDragImmediately, startDragImmediately, onDragStarted, onDragStarted, swipeDetector, swipeDetector, dispatcher, ) ) ) ) Loading @@ -96,6 +102,7 @@ private data class MultiPointerDraggableElement( private val onDragStarted: private val onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, private val swipeDetector: SwipeDetector, private val swipeDetector: SwipeDetector, private val dispatcher: NestedScrollDispatcher, ) : ModifierNodeElement<MultiPointerDraggableNode>() { ) : ModifierNodeElement<MultiPointerDraggableNode>() { override fun create(): MultiPointerDraggableNode = override fun create(): MultiPointerDraggableNode = MultiPointerDraggableNode( MultiPointerDraggableNode( Loading @@ -104,6 +111,7 @@ private data class MultiPointerDraggableElement( startDragImmediately = startDragImmediately, startDragImmediately = startDragImmediately, onDragStarted = onDragStarted, onDragStarted = onDragStarted, swipeDetector = swipeDetector, swipeDetector = swipeDetector, dispatcher = dispatcher, ) ) override fun update(node: MultiPointerDraggableNode) { override fun update(node: MultiPointerDraggableNode) { Loading @@ -122,11 +130,13 @@ internal class MultiPointerDraggableNode( var onDragStarted: var onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, var swipeDetector: SwipeDetector = DefaultSwipeDetector, var swipeDetector: SwipeDetector = DefaultSwipeDetector, private val dispatcher: NestedScrollDispatcher, ) : ) : DelegatingNode(), DelegatingNode(), PointerInputModifierNode, PointerInputModifierNode, CompositionLocalConsumerModifierNode, CompositionLocalConsumerModifierNode, ObserverModifierNode { ObserverModifierNode, SpaceVectorConverter { private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { pointerInput() } private val pointerInputHandler: suspend PointerInputScope.() -> Unit = { pointerInput() } private val delegate = delegate(SuspendingPointerInputModifierNode(pointerInputHandler)) private val delegate = delegate(SuspendingPointerInputModifierNode(pointerInputHandler)) private val velocityTracker = VelocityTracker() private val velocityTracker = VelocityTracker() Loading @@ -141,26 +151,22 @@ internal class MultiPointerDraggableNode( } } } } private var _toFloat = orientation.toFunctionOffsetToFloat() private var converter = SpaceVectorConverter(orientation) private fun Offset.toFloat(): Float = _toFloat(this) override fun Offset.toFloat(): Float = with(converter) { this@toFloat.toFloat() } private fun Orientation.toFunctionOffsetToFloat(): (Offset) -> Float = override fun Velocity.toFloat(): Float = with(converter) { this@toFloat.toFloat() } when (this) { Orientation.Vertical -> { override fun Float.toOffset(): Offset = with(converter) { this@toOffset.toOffset() } { it.y } } override fun Float.toVelocity(): Velocity = with(converter) { this@toVelocity.toVelocity() } Orientation.Horizontal -> { { it.x } } } var orientation: Orientation = orientation var orientation: Orientation = orientation set(value) { set(value) { // Reset the pointer input whenever orientation changed. // Reset the pointer input whenever orientation changed. if (value != field) { if (value != field) { field = value field = value _toFloat = field.toFunctionOffsetToFloat() converter = SpaceVectorConverter(value) delegate.resetPointerInputHandler() delegate.resetPointerInputHandler() } } } } Loading Loading @@ -240,28 +246,32 @@ internal class MultiPointerDraggableNode( }, }, onDrag = { controller, change, amount -> onDrag = { controller, change, amount -> velocityTracker.addPointerInputChange(change) velocityTracker.addPointerInputChange(change) controller.onDrag(amount) dispatchScrollEvents( availableOnPreScroll = amount, onScroll = { controller.onDrag(it) }, source = NestedScrollSource.UserInput, ) }, }, onDragEnd = { controller -> onDragEnd = { controller -> val viewConfiguration = currentValueOf(LocalViewConfiguration) startFlingGesture( val maxVelocity = initialVelocity = viewConfiguration.maximumFlingVelocity.let { currentValueOf(LocalViewConfiguration) Velocity(it, it) .maximumFlingVelocity } .let { val velocity = velocityTracker.calculateVelocity(maxVelocity) val maxVelocity = Velocity(it, it) controller.onStop( velocityTracker.calculateVelocity(maxVelocity) velocity = } when (orientation) { .toFloat(), Orientation.Horizontal -> velocity.x onFling = { controller.onStop(it, canChangeScene = true) } Orientation.Vertical -> velocity.y }, canChangeScene = true, ) ) }, }, onDragCancel = { controller -> onDragCancel = { controller -> controller.onStop(velocity = 0f, canChangeScene = true) startFlingGesture( initialVelocity = 0f, onFling = { controller.onStop(it, canChangeScene = true) } ) }, }, swipeDetector = swipeDetector swipeDetector = swipeDetector, ) ) } catch (exception: CancellationException) { } catch (exception: CancellationException) { // If the coroutine scope is active, we can just restart the drag cycle. // If the coroutine scope is active, we can just restart the drag cycle. Loading @@ -275,6 +285,101 @@ internal class MultiPointerDraggableNode( } } } } /** * Start a fling gesture in another CoroutineScope, this is to ensure that even when the pointer * input scope is reset we will continue any coroutine scope that we started from these methods * while the pointer input scope was active. * * Note: Inspired by [androidx.compose.foundation.gestures.ScrollableNode.onDragStopped] */ private fun startFlingGesture(initialVelocity: Float, onFling: (velocity: Float) -> Float) { // Note: [AwaitPointerEventScope] is annotated as @RestrictsSuspension, we need another // CoroutineScope to run the fling gestures. // We do not need to cancel this [Job], the source will take care of emitting an // [onPostFling] before starting a new gesture. dispatcher.coroutineScope.launch { dispatchFlingEvents(availableOnPreFling = initialVelocity, onFling = onFling) } } /** * Use the nested scroll system to fire scroll events. This allows us to consume events from our * ancestors during the pre-scroll and post-scroll phases. * * @param availableOnPreScroll amount available before the scroll, this can be partially * consumed by our ancestors. * @param onScroll function that returns the amount consumed during a scroll given the amount * available after the [NestedScrollConnection.onPreScroll]. * @param source the source of the scroll event * @return Total offset consumed. */ private inline fun dispatchScrollEvents( availableOnPreScroll: Float, onScroll: (delta: Float) -> Float, source: NestedScrollSource, ): Float { // PreScroll phase val consumedByPreScroll = dispatcher .dispatchPreScroll( available = availableOnPreScroll.toOffset(), source = source, ) .toFloat() // Scroll phase val availableOnScroll = availableOnPreScroll - consumedByPreScroll val consumedBySelfScroll = onScroll(availableOnScroll) // PostScroll phase val availableOnPostScroll = availableOnScroll - consumedBySelfScroll val consumedByPostScroll = dispatcher .dispatchPostScroll( consumed = consumedBySelfScroll.toOffset(), available = availableOnPostScroll.toOffset(), source = source, ) .toFloat() return consumedByPreScroll + consumedBySelfScroll + consumedByPostScroll } /** * Use the nested scroll system to fire fling events. This allows us to consume events from our * ancestors during the pre-fling and post-fling phases. * * @param availableOnPreFling velocity available before the fling, this can be partially * consumed by our ancestors. * @param onFling function that returns the velocity consumed during the fling given the * velocity available after the [NestedScrollConnection.onPreFling]. * @return Total velocity consumed. */ private suspend inline fun dispatchFlingEvents( availableOnPreFling: Float, onFling: (velocity: Float) -> Float, ): Float { // PreFling phase val consumedByPreFling = dispatcher.dispatchPreFling(available = availableOnPreFling.toVelocity()).toFloat() // Fling phase val availableOnFling = availableOnPreFling - consumedByPreFling val consumedBySelfFling = onFling(availableOnFling) // PostFling phase val availableOnPostFling = availableOnFling - consumedBySelfFling val consumedByPostFling = dispatcher .dispatchPostFling( consumed = consumedBySelfFling.toVelocity(), available = availableOnPostFling.toVelocity(), ) .toFloat() return consumedByPreFling + consumedBySelfFling + consumedByPostFling } /** /** * Detect drag gestures in the given [orientation]. * Detect drag gestures in the given [orientation]. * * Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt +4 −1 Original line number Original line Diff line number Diff line Loading @@ -20,6 +20,7 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass Loading Loading @@ -57,6 +58,7 @@ private class SwipeToSceneNode( draggableHandler: DraggableHandlerImpl, draggableHandler: DraggableHandlerImpl, swipeDetector: SwipeDetector, swipeDetector: SwipeDetector, ) : DelegatingNode(), PointerInputModifierNode { ) : DelegatingNode(), PointerInputModifierNode { private val dispatcher = NestedScrollDispatcher() private val multiPointerDraggableNode = private val multiPointerDraggableNode = delegate( delegate( MultiPointerDraggableNode( MultiPointerDraggableNode( Loading @@ -65,6 +67,7 @@ private class SwipeToSceneNode( startDragImmediately = ::startDragImmediately, startDragImmediately = ::startDragImmediately, onDragStarted = draggableHandler::onDragStarted, onDragStarted = draggableHandler::onDragStarted, swipeDetector = swipeDetector, swipeDetector = swipeDetector, dispatcher = dispatcher, ) ) ) ) Loading Loading @@ -93,7 +96,7 @@ private class SwipeToSceneNode( ) ) init { init { delegate(nestedScrollModifierNode(nestedScrollHandlerImpl.connection, dispatcher = null)) delegate(nestedScrollModifierNode(nestedScrollHandlerImpl.connection, dispatcher)) delegate(ScrollBehaviorOwnerNode(draggableHandler.nestedScrollKey, nestedScrollHandlerImpl)) delegate(ScrollBehaviorOwnerNode(draggableHandler.nestedScrollKey, nestedScrollHandlerImpl)) } } Loading
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt +136 −4 Original line number Original line Diff line number Diff line Loading @@ -28,6 +28,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputChange Loading @@ -37,6 +41,7 @@ import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.Velocity import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope Loading @@ -49,17 +54,22 @@ import org.junit.runner.RunWith class MultiPointerDraggableTest { class MultiPointerDraggableTest { @get:Rule val rule = createComposeRule() @get:Rule val rule = createComposeRule() private val emptyConnection = object : NestedScrollConnection {} private val defaultDispatcher = NestedScrollDispatcher() private fun Modifier.nestedScrollDispatcher() = nestedScroll(emptyConnection, defaultDispatcher) private class SimpleDragController( private class SimpleDragController( val onDrag: () -> Unit, val onDrag: (delta: Float) -> Unit, val onStop: () -> Unit, val onStop: (velocity: Float) -> Unit, ) : DragController { ) : DragController { override fun onDrag(delta: Float): Float { override fun onDrag(delta: Float): Float { onDrag() onDrag.invoke(delta) return delta return delta } } override fun onStop(velocity: Float, canChangeScene: Boolean): Float { override fun onStop(velocity: Float, canChangeScene: Boolean): Float { onStop() onStop.invoke(velocity) return velocity return velocity } } } } Loading @@ -79,6 +89,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { enabled }, enabled = { enabled }, Loading @@ -90,6 +101,7 @@ class MultiPointerDraggableTest { onStop = { stopped = true }, onStop = { stopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) ) } } Loading Loading @@ -145,6 +157,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -157,6 +170,7 @@ class MultiPointerDraggableTest { onStop = { stopped = true }, onStop = { stopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) .pointerInput(Unit) { .pointerInput(Unit) { coroutineScope { coroutineScope { Loading Loading @@ -217,6 +231,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -228,6 +243,7 @@ class MultiPointerDraggableTest { onStop = { stopped = true }, onStop = { stopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) { ) { if (hasScrollable) { if (hasScrollable) { Loading Loading @@ -335,6 +351,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -346,6 +363,7 @@ class MultiPointerDraggableTest { onStop = { stopped = true }, onStop = { stopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) { ) { Box( Box( Loading Loading @@ -436,6 +454,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -447,6 +466,7 @@ class MultiPointerDraggableTest { onStop = { verticalStopped = true }, onStop = { verticalStopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Horizontal, orientation = Orientation.Horizontal, Loading @@ -459,6 +479,7 @@ class MultiPointerDraggableTest { onStop = { horizontalStopped = true }, onStop = { horizontalStopped = true }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) ) } } Loading Loading @@ -539,6 +560,7 @@ class MultiPointerDraggableTest { touchSlop = LocalViewConfiguration.current.touchSlop touchSlop = LocalViewConfiguration.current.touchSlop Box( Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() .multiPointerDraggable( .multiPointerDraggable( orientation = Orientation.Vertical, orientation = Orientation.Vertical, enabled = { true }, enabled = { true }, Loading @@ -557,6 +579,7 @@ class MultiPointerDraggableTest { onStop = { /* do nothing */ }, onStop = { /* do nothing */ }, ) ) }, }, dispatcher = defaultDispatcher, ) ) ) {} ) {} } } Loading Loading @@ -587,4 +610,113 @@ class MultiPointerDraggableTest { assertThat(started).isTrue() assertThat(started).isTrue() } } @Test fun multiPointerNestedScrollDispatcher() { val size = 200f val middle = Offset(size / 2f, size / 2f) var touchSlop = 0f var consumedOnPreScroll = 0f var availableOnPreScroll = Float.MIN_VALUE var availableOnPostScroll = Float.MIN_VALUE var availableOnPreFling = Float.MIN_VALUE var availableOnPostFling = Float.MIN_VALUE var consumedOnDrag = 0f var consumedOnDragStop = 0f val connection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { availableOnPreScroll = available.y return Offset(0f, consumedOnPreScroll) } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { availableOnPostScroll = available.y return Offset.Zero } override suspend fun onPreFling(available: Velocity): Velocity { availableOnPreFling = available.y return Velocity.Zero } override suspend fun onPostFling( consumed: Velocity, available: Velocity ): Velocity { availableOnPostFling = available.y return Velocity.Zero } } rule.setContent { touchSlop = LocalViewConfiguration.current.touchSlop Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScroll(connection) .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> SimpleDragController( onDrag = { consumedOnDrag = it }, onStop = { consumedOnDragStop = it }, ) }, dispatcher = defaultDispatcher, ) ) } fun startDrag() { rule.onRoot().performTouchInput { down(middle) moveBy(Offset(0f, touchSlop)) } } fun continueDrag() { rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } } fun stopDrag() { rule.onRoot().performTouchInput { up() } } startDrag() continueDrag() assertThat(availableOnPreScroll).isEqualTo(touchSlop) assertThat(consumedOnDrag).isEqualTo(touchSlop) assertThat(availableOnPostScroll).isEqualTo(0f) // Parent node consumes half of the gesture consumedOnPreScroll = touchSlop / 2f continueDrag() assertThat(availableOnPreScroll).isEqualTo(touchSlop) assertThat(consumedOnDrag).isEqualTo(touchSlop / 2f) assertThat(availableOnPostScroll).isEqualTo(0f) // Parent node consumes the gesture consumedOnPreScroll = touchSlop continueDrag() assertThat(availableOnPreScroll).isEqualTo(touchSlop) assertThat(consumedOnDrag).isEqualTo(0f) assertThat(availableOnPostScroll).isEqualTo(0f) // Parent node can intercept the velocity on stop stopDrag() assertThat(availableOnPreFling).isEqualTo(consumedOnDragStop) assertThat(availableOnPostFling).isEqualTo(0f) } } }