Make drag gesture detectors more forgiving.
Fixes: 173626538
When drag gesture detectors are fed a pointerId that isn't down,
they were throwing a NPE. This has been changed to return early
instead.
Relnote: N/A
Test: new test
Change-Id: Ic50e5b154cb63ed75baafcb5da917bdbcadd8096
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt
index 25d8998..4f6652d 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt
@@ -19,6 +19,7 @@
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.gesture.ExperimentalPointerInput
import androidx.compose.ui.input.pointer.HandlePointerInputScope
+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
@@ -36,7 +37,8 @@
/**
* Waits for drag motion to pass [touch slop][ViewConfiguration.touchSlop], using [pointerId] as
* the pointer to examine. If [pointerId] is raised, another pointer from those that are down
- * will be chosen to lead the gesture, and if none are down, `null` is returned.
+ * will be chosen to lead the gesture, and if none are down, `null` is returned. If [pointerId]
+ * is not down when [awaitTouchSlopOrCancellation] is called, then `null` is returned.
* [onTouchSlopReached] is called after [ViewConfiguration.touchSlop] motion in the any direction
* with the change that caused the motion beyond touch slop and the [Offset] beyond touch slop that
@@ -59,6 +61,9 @@
pointerId: PointerId,
onTouchSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit
): PointerInputChange? {
+ if (currentEvent.isPointerUp(pointerId)) {
+ return null // The pointer has already been lifted, so the gesture is canceled
+ }
var offset = Offset.Zero
val touchSlop = viewConfiguration.touchSlop
@@ -144,7 +149,8 @@
* that is down will be used, if available, so the returned [PointerInputChange.id] may
* differ from [pointerId]. If the position change in the any direction has been
* consumed by the [PointerEventPass.Main] pass, then the drag is considered canceled and `null`
- * is returned.
+ * is returned. If [pointerId] is not down when [awaitDragOrCancellation] is called, then
+ * `null` is returned.
*
* Example Usage:
* @sample androidx.compose.foundation.samples.AwaitDragOrCancellationSample
@@ -157,6 +163,9 @@
suspend fun HandlePointerInputScope.awaitDragOrCancellation(
pointerId: PointerId,
): PointerInputChange? {
+ if (currentEvent.isPointerUp(pointerId)) {
+ return null // The pointer has already been lifted, so the gesture is canceled
+ }
val change = awaitDragOrUp(pointerId) { it.positionChangedIgnoreConsumed() }
return if (change.anyPositionChangeConsumed()) null else change
}
@@ -207,7 +216,9 @@
* Waits for vertical drag motion to pass [touch slop][ViewConfiguration.touchSlop], using
* [pointerId] as the pointer to examine. If [pointerId] is raised, another pointer from
* those that are down will be chosen to lead the gesture, and if none are down, `null` is returned.
-
+ * If [pointerId] is not down when [awaitVerticalTouchSlopOrCancellation] is called, then `null`
+ * is returned.
+ *
* [onTouchSlopReached] is called after [ViewConfiguration.touchSlop] motion in the vertical
* direction with the change that caused the motion beyond touch slop and the pixels beyond touch
* slop. [onTouchSlopReached] should consume the position change if it accepts the motion.
@@ -272,7 +283,8 @@
* that is down will be used, if available, so the returned [PointerInputChange.id] may
* differ from [pointerId]. If the position change in the vertical direction has been
* consumed by the [PointerEventPass.Main] pass, then the drag is considered canceled and `null` is
- * returned.
+ * returned. If [pointerId] is not down when [awaitVerticalDragOrCancellation] is called, then
+ * `null` is returned.
*
* Example Usage:
* @sample androidx.compose.foundation.samples.AwaitVerticalDragOrCancellationSample
@@ -285,6 +297,9 @@
suspend fun HandlePointerInputScope.awaitVerticalDragOrCancellation(
pointerId: PointerId,
): PointerInputChange? {
+ if (currentEvent.isPointerUp(pointerId)) {
+ return null // The pointer has already been lifted, so the gesture is canceled
+ }
val change = awaitDragOrUp(pointerId) { it.positionChangeIgnoreConsumed().y != 0f }
return if (change.consumed.positionChange.y != 0f) null else change
}
@@ -341,7 +356,8 @@
* direction with the change that caused the motion beyond touch slop and the pixels beyond touch
* slop. [onTouchSlopReached] should consume the position change if it accepts the motion.
* If it does, then the method returns that [PointerInputChange]. If not, touch slop detection will
- * continue.
+ * continue. If [pointerId] is not down when [awaitHorizontalTouchSlopOrCancellation] is called,
+ * then `null` is returned.
*
* @return The [PointerInputChange] that was consumed in [onTouchSlopReached] or `null` if all
* pointers are raised before touch slop is detected or another gesture consumed the position
@@ -398,7 +414,8 @@
* that is down will be used, if available, so the returned [PointerInputChange.id] may
* differ from [pointerId]. If the position change in the horizontal direction has been
* consumed by the [PointerEventPass.Main] pass, then the drag is considered canceled and `null`
- * is returned.
+ * is returned. If [pointerId] is not down when [awaitHorizontalDragOrCancellation] is called,
+ * then `null` is returned.
*
* Example Usage:
* @sample androidx.compose.foundation.samples.AwaitHorizontalDragOrCancellationSample
@@ -411,6 +428,9 @@
suspend fun HandlePointerInputScope.awaitHorizontalDragOrCancellation(
pointerId: PointerId,
): PointerInputChange? {
+ if (currentEvent.isPointerUp(pointerId)) {
+ return null // The pointer has already been lifted, so the gesture is canceled
+ }
val change = awaitDragOrUp(pointerId) { it.positionChangeIgnoreConsumed().x != 0f }
return if (change.consumed.positionChange.x != 0f) null else change
}
@@ -475,6 +495,9 @@
motionFromChange: (PointerInputChange) -> Float,
motionConsumed: (PointerInputChange) -> Boolean
): Boolean {
+ if (currentEvent.isPointerUp(pointerId)) {
+ return false // The pointer has already been lifted, so the gesture is canceled
+ }
var pointer = pointerId
while (true) {
val change = awaitDragOrUp(pointer) { motionFromChange(it) != 0f }
@@ -526,7 +549,8 @@
* Waits for drag motion along one axis based on [getDragDirectionValue] to pass touch slop,
* using [pointerId] as the pointer to examine. If [pointerId] is raised, another pointer
* from those that are down will be chosen to lead the gesture, and if none are down,
- * `null` is returned.
+ * `null` is returned. If [pointerId] is not down when [awaitTouchSlopOrCancellation] is called,
+ * then `null` is returned.
*
* When touch slop is detected, [onTouchSlopReached] is called with the change and the distance
* beyond the touch slop. [getDragDirectionValue] should return the position change in the direction
@@ -552,6 +576,9 @@
consumeMotion: (PointerInputChange, Float) -> Unit,
getCrossDirectionValue: (Offset) -> Float
): PointerInputChange? {
+ if (currentEvent.isPointerUp(pointerId)) {
+ return null // The pointer has already been lifted, so the gesture is canceled
+ }
val touchSlop = viewConfiguration.touchSlop
var pointer: PointerId = pointerId
var totalPositionChange = 0f
@@ -617,3 +644,6 @@
}
}
}
+
+private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean =
+ changes.firstOrNull { it.id == pointerId }?.current?.down != true
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ForEachGesture.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ForEachGesture.kt
index b116305..d3cd6e1 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ForEachGesture.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ForEachGesture.kt
@@ -60,7 +60,8 @@
* if any of the pointers are down.
*/
@ExperimentalPointerInput
-internal fun HandlePointerInputScope.allPointersUp(): Boolean = !currentPointers.fastAny { it.down }
+internal fun HandlePointerInputScope.allPointersUp(): Boolean =
+ !currentEvent.changes.fastAny { it.current.down }
/**
* Waits for all pointers to be up before returning.
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/DragGestureDetectorTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/DragGestureDetectorTest.kt
index f1130b2..a146012f 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/DragGestureDetectorTest.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/DragGestureDetectorTest.kt
@@ -57,6 +57,7 @@
private var gestureEnded = false
private var gestureCanceled = false
private var consumePositiveOnly = false
+ private var sloppyDetector = false
private val DragTouchSlopUtil = SuspendingGestureTestUtil(width = 100, height = 100) {
detectDragGestures(
@@ -109,8 +110,8 @@
change.consumePositionChange(0f, change.positionChange().y)
}
}
- if (slopChange != null) {
- var pointer = slopChange.id
+ if (slopChange != null || sloppyDetector) {
+ var pointer = if (sloppyDetector) down.id else slopChange!!.id
do {
val change = awaitVerticalDragOrCancellation(pointer)
if (change == null) {
@@ -141,8 +142,8 @@
change.consumePositionChange(change.positionChange().x, 0f)
}
}
- if (slopChange != null) {
- var pointer = slopChange.id
+ if (slopChange != null || sloppyDetector) {
+ var pointer = if (sloppyDetector) down.id else slopChange!!.id
do {
val change = awaitHorizontalDragOrCancellation(pointer)
if (change == null) {
@@ -173,8 +174,8 @@
change.consumeAllChanges()
}
}
- if (slopChange != null) {
- var pointer = slopChange.id
+ if (slopChange != null || sloppyDetector) {
+ var pointer = if (sloppyDetector) down.id else slopChange!!.id
do {
val change = awaitDragOrCancellation(pointer)
if (change == null) {
@@ -224,6 +225,13 @@
else -> false
}
+ private val supportsSloppyGesture = when (dragType) {
+ GestureType.AwaitVerticalDragOrCancel,
+ GestureType.AwaitHorizontalDragOrCancel,
+ GestureType.AwaitDragOrCancel -> true
+ else -> false
+ }
+
@Before
fun setup() {
dragDistance = 0f
@@ -440,4 +448,27 @@
consumePositiveOnly = false
}
}
+
+ /**
+ * When gesture detectors use the wrong pointer for the drag, it should just not
+ * detect the touch.
+ */
+ @Test
+ fun pointerUpTooQuickly() = util.executeInComposition {
+ if (supportsSloppyGesture) {
+ try {
+ sloppyDetector = true
+
+ val finger1 = down()
+ val finger2 = down()
+ finger1.up()
+ finger2.moveBy(dragMotion).up()
+
+ // The sloppy detector doesn't know to look at finger2
+ assertTrue(gestureCanceled)
+ } finally {
+ sloppyDetector = false
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index 0f82a40..80e9028 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -1475,10 +1475,10 @@
@androidx.compose.ui.gesture.ExperimentalPointerInput @kotlin.coroutines.RestrictsSuspension public interface HandlePointerInputScope extends androidx.compose.ui.unit.Density {
method public suspend Object? awaitCustomEvent(optional androidx.compose.ui.input.pointer.PointerEventPass pass, optional kotlin.coroutines.Continuation<? super androidx.compose.ui.input.pointer.CustomEvent> p);
method public suspend Object? awaitPointerEvent(optional androidx.compose.ui.input.pointer.PointerEventPass pass, optional kotlin.coroutines.Continuation<? super androidx.compose.ui.input.pointer.PointerEvent> p);
- method public java.util.List<androidx.compose.ui.input.pointer.PointerInputData> getCurrentPointers();
+ method public androidx.compose.ui.input.pointer.PointerEvent getCurrentEvent();
method public long getSize-YbymL2g();
method public androidx.compose.ui.platform.ViewConfiguration getViewConfiguration();
- property public abstract java.util.List<androidx.compose.ui.input.pointer.PointerInputData> currentPointers;
+ property public abstract androidx.compose.ui.input.pointer.PointerEvent currentEvent;
property public abstract long size;
property public abstract androidx.compose.ui.platform.ViewConfiguration viewConfiguration;
}
diff --git a/compose/ui/ui/api/public_plus_experimental_current.txt b/compose/ui/ui/api/public_plus_experimental_current.txt
index 0f82a40..80e9028 100644
--- a/compose/ui/ui/api/public_plus_experimental_current.txt
+++ b/compose/ui/ui/api/public_plus_experimental_current.txt
@@ -1475,10 +1475,10 @@
@androidx.compose.ui.gesture.ExperimentalPointerInput @kotlin.coroutines.RestrictsSuspension public interface HandlePointerInputScope extends androidx.compose.ui.unit.Density {
method public suspend Object? awaitCustomEvent(optional androidx.compose.ui.input.pointer.PointerEventPass pass, optional kotlin.coroutines.Continuation<? super androidx.compose.ui.input.pointer.CustomEvent> p);
method public suspend Object? awaitPointerEvent(optional androidx.compose.ui.input.pointer.PointerEventPass pass, optional kotlin.coroutines.Continuation<? super androidx.compose.ui.input.pointer.PointerEvent> p);
- method public java.util.List<androidx.compose.ui.input.pointer.PointerInputData> getCurrentPointers();
+ method public androidx.compose.ui.input.pointer.PointerEvent getCurrentEvent();
method public long getSize-YbymL2g();
method public androidx.compose.ui.platform.ViewConfiguration getViewConfiguration();
- property public abstract java.util.List<androidx.compose.ui.input.pointer.PointerInputData> currentPointers;
+ property public abstract androidx.compose.ui.input.pointer.PointerEvent currentEvent;
property public abstract long size;
property public abstract androidx.compose.ui.platform.ViewConfiguration viewConfiguration;
}
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index ad78dad..ff5adf0 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -1475,10 +1475,10 @@
@androidx.compose.ui.gesture.ExperimentalPointerInput @kotlin.coroutines.RestrictsSuspension public interface HandlePointerInputScope extends androidx.compose.ui.unit.Density {
method public suspend Object? awaitCustomEvent(optional androidx.compose.ui.input.pointer.PointerEventPass pass, optional kotlin.coroutines.Continuation<? super androidx.compose.ui.input.pointer.CustomEvent> p);
method public suspend Object? awaitPointerEvent(optional androidx.compose.ui.input.pointer.PointerEventPass pass, optional kotlin.coroutines.Continuation<? super androidx.compose.ui.input.pointer.PointerEvent> p);
- method public java.util.List<androidx.compose.ui.input.pointer.PointerInputData> getCurrentPointers();
+ method public androidx.compose.ui.input.pointer.PointerEvent getCurrentEvent();
method public long getSize-YbymL2g();
method public androidx.compose.ui.platform.ViewConfiguration getViewConfiguration();
- property public abstract java.util.List<androidx.compose.ui.input.pointer.PointerInputData> currentPointers;
+ property public abstract androidx.compose.ui.input.pointer.PointerEvent currentEvent;
property public abstract long size;
property public abstract androidx.compose.ui.platform.ViewConfiguration viewConfiguration;
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
index de8dcf9..252d4b9 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
@@ -29,7 +29,6 @@
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastAll
-import androidx.compose.ui.util.fastMapTo
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.Continuation
import kotlin.coroutines.ContinuationInterceptor
@@ -58,9 +57,9 @@
val size: IntSize
/**
- * The state of the pointers as of the most recent event
+ * The [PointerEvent] from the most recent touch event.
*/
- val currentPointers: List<PointerInputData>
+ val currentEvent: PointerEvent
/**
* The [ViewConfiguration] used to tune gesture detectors.
@@ -198,7 +197,7 @@
private var _customEventDispatcher: CustomEventDispatcher? = null
- val currentPointers = mutableListOf<PointerInputData>()
+ private var currentEvent: PointerEvent? = null
/**
* TODO: work out whether this is actually a race or not.
@@ -291,8 +290,7 @@
) {
boundsSize = bounds
if (pass == PointerEventPass.Initial) {
- currentPointers.clear()
- pointerEvent.changes.fastMapTo(currentPointers) { it.current }
+ currentEvent = pointerEvent
}
dispatchPointerEvent(pointerEvent, pass)
@@ -337,13 +335,7 @@
override suspend fun <R> handlePointerInput(
handler: suspend HandlePointerInputScope.() -> R
): R = suspendCancellableCoroutine { continuation ->
- val handlerCoroutine = PointerEventHandlerCoroutine(
- continuation,
- currentPointers,
- boundsSize,
- viewConfiguration,
- this
- )
+ val handlerCoroutine = PointerEventHandlerCoroutine(continuation, this)
synchronized(pointerHandlers) {
pointerHandlers += handlerCoroutine
@@ -374,15 +366,17 @@
*/
private inner class PointerEventHandlerCoroutine<R>(
private val completion: Continuation<R>,
- override val currentPointers: List<PointerInputData>,
- override val size: IntSize,
- override val viewConfiguration: ViewConfiguration,
- density: Density
- ) : HandlePointerInputScope, Density by density, Continuation<R> {
+ private val pointerInputFilter: SuspendingPointerInputFilter,
+ ) : HandlePointerInputScope, Density by pointerInputFilter, Continuation<R> {
private var pointerAwaiter: Continuation<PointerEvent>? = null
private var customAwaiter: Continuation<CustomEvent>? = null
private var awaitPass: PointerEventPass = PointerEventPass.Main
+ override val currentEvent: PointerEvent get() = pointerInputFilter.currentEvent!!
+ override val size: IntSize get() = pointerInputFilter.boundsSize
+ override val viewConfiguration: ViewConfiguration
+ get() = pointerInputFilter.viewConfiguration
+
fun offerPointerEvent(event: PointerEvent, pass: PointerEventPass) {
if (pass == awaitPass) {
pointerAwaiter?.run {