[go: nahoru, domu]

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 {