Loading packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt +115 −66 Original line number Diff line number Diff line Loading @@ -17,8 +17,6 @@ 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 Loading @@ -26,12 +24,14 @@ import androidx.compose.foundation.gestures.verticalDrag import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEvent 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.SuspendingPointerInputModifierNode import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange Loading @@ -45,8 +45,12 @@ import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastForEach import kotlin.coroutines.cancellation.CancellationException import kotlin.math.sign import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive /** * Make an element draggable in the given [orientation]. Loading Loading @@ -163,6 +167,10 @@ internal class MultiPointerDraggableNode( return } coroutineScope { awaitPointerEventScope { while (isActive) { try { detectDragGestures( orientation = orientation, startDragImmediately = startDragImmediately, Loading @@ -176,7 +184,8 @@ internal class MultiPointerDraggableNode( }, onDragEnd = { controller -> val viewConfiguration = currentValueOf(LocalViewConfiguration) val maxVelocity = viewConfiguration.maximumFlingVelocity.let { Velocity(it, it) } val maxVelocity = viewConfiguration.maximumFlingVelocity.let { Velocity(it, it) } val velocity = velocityTracker.calculateVelocity(maxVelocity) controller.onStop( velocity = Loading @@ -191,6 +200,14 @@ internal class MultiPointerDraggableNode( controller.onStop(velocity = 0f, canChangeScene = true) }, ) } catch (exception: CancellationException) { // If the coroutine scope is active, we can just restart the drag cycle. if (!isActive) { throw exception } } } } } } Loading @@ -202,36 +219,43 @@ internal class MultiPointerDraggableNode( * 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( private suspend fun AwaitPointerEventScope.detectDragGestures( orientation: Orientation, startDragImmediately: (startedPosition: Offset) -> Boolean, onDragStart: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, onDragEnd: (controller: DragController) -> Unit, onDragCancel: (controller: DragController) -> Unit, onDragStart: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, onDrag: (controller: DragController, change: PointerInputChange, dragAmount: Float) -> Unit, onDragEnd: (controller: DragController) -> Unit, onDragCancel: (controller: DragController) -> Unit ) { awaitEachGesture { val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) // Wait for a consumable event in [PointerEventPass.Main] pass val consumablePointer = awaitConsumableEvent().changes.first() var overSlop = 0f val drag = if (startDragImmediately(initialDown.position)) { initialDown.consume() initialDown if (startDragImmediately(consumablePointer.position)) { consumablePointer.consume() consumablePointer } 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. // TODO(b/291055080): Replace by await[Orientation]PointerSlopOrCancellation once it // is public. val drag = when (orientation) { Orientation.Horizontal -> awaitHorizontalTouchSlopOrCancellation(down.id, onSlopReached) awaitHorizontalTouchSlopOrCancellation( consumablePointer.id, onSlopReached ) Orientation.Vertical -> awaitVerticalTouchSlopOrCancellation(down.id, onSlopReached) awaitVerticalTouchSlopOrCancellation( consumablePointer.id, onSlopReached ) } // Make sure that overSlop is not 0f. This can happen when the user drags by exactly Loading @@ -240,16 +264,10 @@ private suspend fun PointerInputScope.detectDragGestures( // we intercept an ongoing swipe transition (i.e. startDragImmediately() returned // true). if (drag != null && overSlop == 0f) { val deltaOffset = drag.position - initialDown.position val delta = when (orientation) { Orientation.Horizontal -> deltaOffset.x Orientation.Vertical -> deltaOffset.y } val delta = (drag.position - consumablePointer.position).toFloat() check(delta != 0f) { "delta is equal to 0" } overSlop = delta.sign } drag } Loading @@ -272,12 +290,12 @@ private suspend fun PointerInputScope.detectDragGestures( when (orientation) { Orientation.Horizontal -> horizontalDrag(drag.id) { onDrag(controller, it, it.positionChange().x) onDrag(controller, it, it.positionChange().toFloat()) it.consume() } Orientation.Vertical -> verticalDrag(drag.id) { onDrag(controller, it, it.positionChange().y) onDrag(controller, it, it.positionChange().toFloat()) it.consume() } } Loading @@ -293,4 +311,35 @@ private suspend fun PointerInputScope.detectDragGestures( } } } private suspend fun AwaitPointerEventScope.awaitConsumableEvent(): PointerEvent { fun canBeConsumed(changes: List<PointerInputChange>): Boolean { // All pointers must be: return changes.fastAll { // A) recently pressed: even if the event has already been consumed, we can still // use the recently added finger event to determine whether to initiate dragging the // scene. it.changedToDownIgnoreConsumed() || // B) unconsumed AND in a new position (on the current axis) it.positionChange().toFloat() != 0f } } var event: PointerEvent do { // To allow the descendants with the opportunity to consume the event, we wait for it in // the Main pass. event = awaitPointerEvent() } while (!canBeConsumed(event.changes)) // We found a consumable event in the Main pass return event } private fun Offset.toFloat(): Float { return when (orientation) { Orientation.Vertical -> y Orientation.Horizontal -> x } } } packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt +236 −0 Original line number Diff line number Diff line Loading @@ -17,7 +17,10 @@ package com.android.compose.animation.scene import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.rememberScrollableState import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf Loading @@ -25,6 +28,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.test.junit4.createComposeRule Loading @@ -32,6 +38,8 @@ import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith Loading Loading @@ -110,4 +118,232 @@ class MultiPointerDraggableTest { assertThat(dragged).isTrue() assertThat(stopped).isTrue() } @Test fun handleDisappearingScrollableDuringAGesture() { val size = 200f val middle = Offset(size / 2f, size / 2f) var started = false var dragged = false var stopped = false var consumedByScroll = false var hasScrollable by mutableStateOf(true) var touchSlop = 0f rule.setContent { touchSlop = LocalViewConfiguration.current.touchSlop Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .multiPointerDraggable( orientation = Orientation.Vertical, enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> started = true object : DragController { override fun onDrag(delta: Float) { dragged = true } override fun onStop(velocity: Float, canChangeScene: Boolean) { stopped = true } } }, ) ) { if (hasScrollable) { Box( Modifier.scrollable( // Consume all the vertical scroll gestures rememberScrollableState( consumeScrollDelta = { consumedByScroll = true it } ), Orientation.Vertical ) .fillMaxSize() ) } } } fun startDraggingDown() { rule.onRoot().performTouchInput { down(middle) moveBy(Offset(0f, touchSlop)) } } fun continueDraggingDown() { rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } } fun releaseFinger() { rule.onRoot().performTouchInput { up() } } // Swipe down. This should intercepted by the scrollable modifier. startDraggingDown() assertThat(consumedByScroll).isTrue() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() // Reset the scroll state for the test consumedByScroll = false // Suddenly remove the scrollable container hasScrollable = false rule.waitForIdle() // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop // before consuming it. continueDraggingDown() assertThat(consumedByScroll).isFalse() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() // Swipe down. This should both call onDragStarted() and onDragDelta(). continueDraggingDown() assertThat(consumedByScroll).isFalse() assertThat(started).isTrue() assertThat(dragged).isTrue() assertThat(stopped).isFalse() rule.waitForIdle() releaseFinger() assertThat(stopped).isTrue() } @Test fun multiPointerWaitAConsumableEventInMainPass() { val size = 200f val middle = Offset(size / 2f, size / 2f) var started = false var dragged = false var stopped = false var childConsumesOnPass: PointerEventPass? = null suspend fun AwaitPointerEventScope.childPointerInputScope() { awaitPointerEvent(PointerEventPass.Initial).also { initial -> // Check unconsumed: it should be always true assertThat(initial.changes.any { it.isConsumed }).isFalse() if (childConsumesOnPass == PointerEventPass.Initial) { initial.changes.first().consume() } } awaitPointerEvent(PointerEventPass.Main).also { main -> // Check unconsumed if (childConsumesOnPass != PointerEventPass.Initial) { assertThat(main.changes.any { it.isConsumed }).isFalse() } if (childConsumesOnPass == PointerEventPass.Main) { main.changes.first().consume() } } } var touchSlop = 0f rule.setContent { touchSlop = LocalViewConfiguration.current.touchSlop Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .multiPointerDraggable( orientation = Orientation.Vertical, enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> started = true object : DragController { override fun onDrag(delta: Float) { dragged = true } override fun onStop(velocity: Float, canChangeScene: Boolean) { stopped = true } } }, ) ) { Box( Modifier.pointerInput(Unit) { coroutineScope { awaitPointerEventScope { while (isActive) { childPointerInputScope() } } } } .fillMaxSize() ) } } fun startDraggingDown() { rule.onRoot().performTouchInput { down(middle) moveBy(Offset(0f, touchSlop)) } } fun continueDraggingDown() { rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } } childConsumesOnPass = PointerEventPass.Initial startDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() continueDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() childConsumesOnPass = PointerEventPass.Main continueDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() continueDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() childConsumesOnPass = null // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop // before consuming it. continueDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() // Swipe down. This should both call onDragStarted() and onDragDelta(). continueDraggingDown() assertThat(started).isTrue() assertThat(dragged).isTrue() assertThat(stopped).isFalse() childConsumesOnPass = PointerEventPass.Main continueDraggingDown() assertThat(stopped).isTrue() } } Loading
packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt +115 −66 Original line number Diff line number Diff line Loading @@ -17,8 +17,6 @@ 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 Loading @@ -26,12 +24,14 @@ import androidx.compose.foundation.gestures.verticalDrag import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEvent 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.SuspendingPointerInputModifierNode import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange Loading @@ -45,8 +45,12 @@ import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastForEach import kotlin.coroutines.cancellation.CancellationException import kotlin.math.sign import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive /** * Make an element draggable in the given [orientation]. Loading Loading @@ -163,6 +167,10 @@ internal class MultiPointerDraggableNode( return } coroutineScope { awaitPointerEventScope { while (isActive) { try { detectDragGestures( orientation = orientation, startDragImmediately = startDragImmediately, Loading @@ -176,7 +184,8 @@ internal class MultiPointerDraggableNode( }, onDragEnd = { controller -> val viewConfiguration = currentValueOf(LocalViewConfiguration) val maxVelocity = viewConfiguration.maximumFlingVelocity.let { Velocity(it, it) } val maxVelocity = viewConfiguration.maximumFlingVelocity.let { Velocity(it, it) } val velocity = velocityTracker.calculateVelocity(maxVelocity) controller.onStop( velocity = Loading @@ -191,6 +200,14 @@ internal class MultiPointerDraggableNode( controller.onStop(velocity = 0f, canChangeScene = true) }, ) } catch (exception: CancellationException) { // If the coroutine scope is active, we can just restart the drag cycle. if (!isActive) { throw exception } } } } } } Loading @@ -202,36 +219,43 @@ internal class MultiPointerDraggableNode( * 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( private suspend fun AwaitPointerEventScope.detectDragGestures( orientation: Orientation, startDragImmediately: (startedPosition: Offset) -> Boolean, onDragStart: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, onDragEnd: (controller: DragController) -> Unit, onDragCancel: (controller: DragController) -> Unit, onDragStart: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, onDrag: (controller: DragController, change: PointerInputChange, dragAmount: Float) -> Unit, onDragEnd: (controller: DragController) -> Unit, onDragCancel: (controller: DragController) -> Unit ) { awaitEachGesture { val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) // Wait for a consumable event in [PointerEventPass.Main] pass val consumablePointer = awaitConsumableEvent().changes.first() var overSlop = 0f val drag = if (startDragImmediately(initialDown.position)) { initialDown.consume() initialDown if (startDragImmediately(consumablePointer.position)) { consumablePointer.consume() consumablePointer } 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. // TODO(b/291055080): Replace by await[Orientation]PointerSlopOrCancellation once it // is public. val drag = when (orientation) { Orientation.Horizontal -> awaitHorizontalTouchSlopOrCancellation(down.id, onSlopReached) awaitHorizontalTouchSlopOrCancellation( consumablePointer.id, onSlopReached ) Orientation.Vertical -> awaitVerticalTouchSlopOrCancellation(down.id, onSlopReached) awaitVerticalTouchSlopOrCancellation( consumablePointer.id, onSlopReached ) } // Make sure that overSlop is not 0f. This can happen when the user drags by exactly Loading @@ -240,16 +264,10 @@ private suspend fun PointerInputScope.detectDragGestures( // we intercept an ongoing swipe transition (i.e. startDragImmediately() returned // true). if (drag != null && overSlop == 0f) { val deltaOffset = drag.position - initialDown.position val delta = when (orientation) { Orientation.Horizontal -> deltaOffset.x Orientation.Vertical -> deltaOffset.y } val delta = (drag.position - consumablePointer.position).toFloat() check(delta != 0f) { "delta is equal to 0" } overSlop = delta.sign } drag } Loading @@ -272,12 +290,12 @@ private suspend fun PointerInputScope.detectDragGestures( when (orientation) { Orientation.Horizontal -> horizontalDrag(drag.id) { onDrag(controller, it, it.positionChange().x) onDrag(controller, it, it.positionChange().toFloat()) it.consume() } Orientation.Vertical -> verticalDrag(drag.id) { onDrag(controller, it, it.positionChange().y) onDrag(controller, it, it.positionChange().toFloat()) it.consume() } } Loading @@ -293,4 +311,35 @@ private suspend fun PointerInputScope.detectDragGestures( } } } private suspend fun AwaitPointerEventScope.awaitConsumableEvent(): PointerEvent { fun canBeConsumed(changes: List<PointerInputChange>): Boolean { // All pointers must be: return changes.fastAll { // A) recently pressed: even if the event has already been consumed, we can still // use the recently added finger event to determine whether to initiate dragging the // scene. it.changedToDownIgnoreConsumed() || // B) unconsumed AND in a new position (on the current axis) it.positionChange().toFloat() != 0f } } var event: PointerEvent do { // To allow the descendants with the opportunity to consume the event, we wait for it in // the Main pass. event = awaitPointerEvent() } while (!canBeConsumed(event.changes)) // We found a consumable event in the Main pass return event } private fun Offset.toFloat(): Float { return when (orientation) { Orientation.Vertical -> y Orientation.Horizontal -> x } } }
packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt +236 −0 Original line number Diff line number Diff line Loading @@ -17,7 +17,10 @@ package com.android.compose.animation.scene import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.rememberScrollableState import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf Loading @@ -25,6 +28,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.test.junit4.createComposeRule Loading @@ -32,6 +38,8 @@ import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith Loading Loading @@ -110,4 +118,232 @@ class MultiPointerDraggableTest { assertThat(dragged).isTrue() assertThat(stopped).isTrue() } @Test fun handleDisappearingScrollableDuringAGesture() { val size = 200f val middle = Offset(size / 2f, size / 2f) var started = false var dragged = false var stopped = false var consumedByScroll = false var hasScrollable by mutableStateOf(true) var touchSlop = 0f rule.setContent { touchSlop = LocalViewConfiguration.current.touchSlop Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .multiPointerDraggable( orientation = Orientation.Vertical, enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> started = true object : DragController { override fun onDrag(delta: Float) { dragged = true } override fun onStop(velocity: Float, canChangeScene: Boolean) { stopped = true } } }, ) ) { if (hasScrollable) { Box( Modifier.scrollable( // Consume all the vertical scroll gestures rememberScrollableState( consumeScrollDelta = { consumedByScroll = true it } ), Orientation.Vertical ) .fillMaxSize() ) } } } fun startDraggingDown() { rule.onRoot().performTouchInput { down(middle) moveBy(Offset(0f, touchSlop)) } } fun continueDraggingDown() { rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } } fun releaseFinger() { rule.onRoot().performTouchInput { up() } } // Swipe down. This should intercepted by the scrollable modifier. startDraggingDown() assertThat(consumedByScroll).isTrue() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() // Reset the scroll state for the test consumedByScroll = false // Suddenly remove the scrollable container hasScrollable = false rule.waitForIdle() // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop // before consuming it. continueDraggingDown() assertThat(consumedByScroll).isFalse() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() // Swipe down. This should both call onDragStarted() and onDragDelta(). continueDraggingDown() assertThat(consumedByScroll).isFalse() assertThat(started).isTrue() assertThat(dragged).isTrue() assertThat(stopped).isFalse() rule.waitForIdle() releaseFinger() assertThat(stopped).isTrue() } @Test fun multiPointerWaitAConsumableEventInMainPass() { val size = 200f val middle = Offset(size / 2f, size / 2f) var started = false var dragged = false var stopped = false var childConsumesOnPass: PointerEventPass? = null suspend fun AwaitPointerEventScope.childPointerInputScope() { awaitPointerEvent(PointerEventPass.Initial).also { initial -> // Check unconsumed: it should be always true assertThat(initial.changes.any { it.isConsumed }).isFalse() if (childConsumesOnPass == PointerEventPass.Initial) { initial.changes.first().consume() } } awaitPointerEvent(PointerEventPass.Main).also { main -> // Check unconsumed if (childConsumesOnPass != PointerEventPass.Initial) { assertThat(main.changes.any { it.isConsumed }).isFalse() } if (childConsumesOnPass == PointerEventPass.Main) { main.changes.first().consume() } } } var touchSlop = 0f rule.setContent { touchSlop = LocalViewConfiguration.current.touchSlop Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .multiPointerDraggable( orientation = Orientation.Vertical, enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> started = true object : DragController { override fun onDrag(delta: Float) { dragged = true } override fun onStop(velocity: Float, canChangeScene: Boolean) { stopped = true } } }, ) ) { Box( Modifier.pointerInput(Unit) { coroutineScope { awaitPointerEventScope { while (isActive) { childPointerInputScope() } } } } .fillMaxSize() ) } } fun startDraggingDown() { rule.onRoot().performTouchInput { down(middle) moveBy(Offset(0f, touchSlop)) } } fun continueDraggingDown() { rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) } } childConsumesOnPass = PointerEventPass.Initial startDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() continueDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() childConsumesOnPass = PointerEventPass.Main continueDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() continueDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() childConsumesOnPass = null // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop // before consuming it. continueDraggingDown() assertThat(started).isFalse() assertThat(dragged).isFalse() assertThat(stopped).isFalse() // Swipe down. This should both call onDragStarted() and onDragDelta(). continueDraggingDown() assertThat(started).isTrue() assertThat(dragged).isTrue() assertThat(stopped).isFalse() childConsumesOnPass = PointerEventPass.Main continueDraggingDown() assertThat(stopped).isTrue() } }