Skip to content

Commit 0f8b2ba

Browse files
[Android] Cancel detector gestures when a native view above them takes over the touch (#4582)
## Description On Android, a v3 gesture below a native view that takes over the touch keeps running and activates on lift. That native view might be a `ScrollView`/`FlatList` that starts dragging, or one that catches a fling on DOWN. So scrolling from a list row, or tapping a row to stop a fling, fires the row's tap. The list also loses its fling, because the root starts intercepting when the tap activates. Fixes #4581. The scroller's `requestDisallowInterceptTouchEvent` only bubbles up, and the deferred sweep from #4441 only handles buttons. This PR extends that sweep to detector gestures: - The detector remembers the down time of the last DOWN it got through native dispatch. - When a native view takes over, pending detector gestures (native ones included) are cancelled in two cases: the takeover happened after DOWN, or their detector never got the DOWN (a fling catch). - Eager requests on DOWN that still let the event through, like a nested pager (#2383) or a `TextInput`, leave gestures that got the DOWN alone. That includes gestures held by another finger. - Active gestures are left alone, and buttons keep their own check. Like buttons since #4441 (and legacy handlers), this is stricter than `main` in two cases: - A pending gesture under one finger is cancelled when another finger starts scrolling. The root doesn't know which view took over or which pointers it took, so the sweep can't be narrower. - A touch in a gesture's `hitSlop`, outside its detector, is cancelled if the view under it requests on DOWN (e.g. a `TextInput`). Known limits, same as before: - If an ancestor already requested disallow-intercept on DOWN, later requests from below stop there. So swiping a nested pager that started on a tap still fires the tap. Buttons have the same limit. - An ancestor taking over on a later pointer's POINTER_DOWN isn't caught for gestures on earlier pointers. That's the same trade-off as in #4441, and none of the stock scrollers I checked do it. ## Test plan I compared `main` with this branch on an Android 16 emulator using the `expo-example` debug build, 5 tries each, driving the two-finger cases through the emulator console. The screen is the [repro gist](https://gist.github.com/JacquesLeupin/0a30f72fcc02c82144f4b374e55d19a3) plus the multitouch repro from review. | Scenario | `main` | This PR | | --- | --- | --- | | Drag-scroll from a list row, lift | Phantom tap 4/5, list flings 1/5 | No tap, list flings 5/5 | | Tap a row to stop a fling | Phantom tap 5/5 | No tap | | Tap or swipe a row at rest | Works 5/5 | Works 5/5 | | Tap or long press inside nested `PagerView`s | Works 5/5 | Works 5/5 | | Swipe the inner pager starting on a tap | Phantom tap 5/5 | Phantom tap 5/5 (known limit) | | Hold a tap; a second finger taps a nested pager or a `TextInput`, or stops a list fling | Tap fires 5/5 | Tap fires 5/5 | | Hold a tap; a second finger drags another list | Tap fires 5/5 | Cancelled (stricter) | | `useNativeGesture` on a `Text` row: drag-scroll / tap to stop a fling | Activates 3/5 / 5/5 | No activation | | Gesture Handler `ScrollView` carousel catches a fling, then sideways drift | Takes over 5/5 | List keeps scrolling | | `RectButton` touched once, then taps to stop a fling | Presses 5/5 | No press | | `Touchable` / `Pressable` rows | No change | No change | | Tap in a `hitSlop` over a plain `View` / over a `TextInput` | Fires / fires | Fires / cancelled (stricter) | `yarn lint:android` passes. We hit this in a transaction list in our app (React Native 0.87.1), where we carry an earlier version of this fix as a patch on 3.3.0.
1 parent cba84fe commit 0f8b2ba

3 files changed

Lines changed: 35 additions & 7 deletions

File tree

‎packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -390,10 +390,17 @@ class GestureHandlerOrchestrator(
390390
}
391391

392392
/**
393-
* Cancels handlers whose view opted out of surviving a native view taking over the touch stream.
393+
* Cancels handlers that shouldn't survive a native view taking over the touch stream: handlers
394+
* whose view opted out through its hook (buttons), and handlers attached through a detector that
395+
* haven't activated, native gestures included.
394396
*/
395-
fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean) = cancelHandlersMatching {
396-
it is NativeViewGestureHandler && it.shouldCancelOnNativeTouchGrab(grabbedMidGesture)
397+
fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) = cancelHandlersMatching {
398+
val cancelledByHook = it is NativeViewGestureHandler && it.shouldCancelOnNativeTouchGrab(grabbedMidGesture)
399+
val cancelledByDetector = !it.isActive &&
400+
GestureHandler.usesNativeOrVirtualDetector(it.actionType) &&
401+
it.hostDetectorView?.shouldCancelOnNativeTouchGrab(grabbedMidGesture, downTime) == true
402+
403+
cancelledByHook || cancelledByDetector
397404
}
398405

399406
/**

‎packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.swmansion.gesturehandler.react
22

33
import android.content.Context
4+
import android.view.MotionEvent
45
import android.view.View
56
import com.facebook.react.bridge.ReadableArray
67
import com.facebook.react.uimanager.ThemedReactContext
@@ -22,6 +23,10 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
2223
private var subscribedVirtualHandlers: MutableMap<Int, MutableSet<Int>> = mutableMapOf()
2324
private var moduleId: Int = -1
2425

26+
// Down time of the last DOWN that native dispatch delivered to this view. Handler delivery
27+
// bypasses `dispatchTouchEvent`, so a DOWN intercepted by an ancestor never updates it.
28+
private var lastNativeDownTime = -1L
29+
2530
data class VirtualChildren(val handlerTags: List<Int>, val viewTag: Int)
2631

2732
fun setHandlerTags(handlerTags: ReadableArray?) {
@@ -44,6 +49,22 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
4449
super.onDetachedFromWindow()
4550
}
4651

52+
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
53+
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
54+
lastNativeDownTime = event.downTime
55+
}
56+
return super.dispatchTouchEvent(event)
57+
}
58+
59+
/**
60+
* A native view takes over the touch stream with `requestDisallowInterceptTouchEvent`, which only
61+
* bubbles up, so handlers of detectors below it are never notified. Mirror the rule buttons use:
62+
* cancel when the grab happens after DOWN, or when it happens on DOWN and kept the event from
63+
* reaching this detector (e.g. a scroll view catching a fling).
64+
*/
65+
fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) =
66+
grabbedMidGesture || lastNativeDownTime != downTime
67+
4768
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
4869
super.requestDisallowInterceptTouchEvent(disallowIntercept)
4970
if (!disallowIntercept) {

‎packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,9 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:
143143
* request alone doesn't say what the caller did with the event: a scrollable calls it when it
144144
* takes over the touch, but e.g. a nested pager calls it already on DOWN, just to keep its
145145
* ancestors from stealing a swipe it may recognize later, and the event still reaches the
146-
* button - at request time both calls look identical. They only become
147-
* distinguishable once the native dispatch completes (did the button receive the DOWN?), which
148-
* is why cancellation runs here instead of in `requestDisallowInterceptTouchEvent`.
146+
* button or detector - at request time both calls look identical. They only become
147+
* distinguishable once the native dispatch completes (did the button or detector receive the
148+
* DOWN?), which is why cancellation runs here instead of in `requestDisallowInterceptTouchEvent`.
149149
*/
150150
fun onNativeDispatchEnd(event: MotionEvent) {
151151
passingNativeTouch = false
@@ -156,7 +156,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:
156156
val grabbedMidGesture = event.actionMasked != MotionEvent.ACTION_DOWN &&
157157
event.actionMasked != MotionEvent.ACTION_POINTER_DOWN
158158

159-
orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture)
159+
orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture, event.downTime)
160160
}
161161
}
162162

0 commit comments

Comments
 (0)