From 3ee155675c46e168ac4447b47446a40c30b8bf8a Mon Sep 17 00:00:00 2001 From: Jacques Leupin Date: Wed, 7 Oct 2026 16:18:00 -0700 Subject: [PATCH 1/4] [Android] Cancel detector gestures when a native view above them takes over the touch --- .../core/GestureHandlerOrchestrator.kt | 13 +++++++++--- .../react/RNGestureHandlerDetectorView.kt | 21 +++++++++++++++++++ .../react/RNGestureHandlerRootHelper.kt | 8 +++---- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 2e1f7c23e3..35435bf0e3 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -390,10 +390,17 @@ class GestureHandlerOrchestrator( } /** - * Cancels handlers whose view opted out of surviving a native view taking over the touch stream. + * Cancels handlers that shouldn't survive a native view taking over the touch stream: handlers + * whose view opted out through its hook (buttons), and detector handlers that haven't activated. */ - fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean) = cancelHandlersMatching { - it is NativeViewGestureHandler && it.shouldCancelOnNativeTouchGrab(grabbedMidGesture) + fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) = cancelHandlersMatching { + if (it is NativeViewGestureHandler) { + it.shouldCancelOnNativeTouchGrab(grabbedMidGesture) + } else { + !it.isActive && + GestureHandler.usesNativeOrVirtualDetector(it.actionType) && + it.hostDetectorView?.shouldCancelOnNativeTouchGrab(grabbedMidGesture, downTime) == true + } } /** diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt index c4a8199a82..b09f0ceb13 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt @@ -1,6 +1,7 @@ package com.swmansion.gesturehandler.react import android.content.Context +import android.view.MotionEvent import android.view.View import com.facebook.react.bridge.ReadableArray import com.facebook.react.uimanager.ThemedReactContext @@ -22,6 +23,10 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { private var subscribedVirtualHandlers: MutableMap> = mutableMapOf() private var moduleId: Int = -1 + // Down time of the last DOWN that native dispatch delivered to this view. Handler delivery + // bypasses `dispatchTouchEvent`, so a DOWN intercepted by an ancestor never updates it. + private var lastNativeDownTime = -1L + data class VirtualChildren(val handlerTags: List, val viewTag: Int) fun setHandlerTags(handlerTags: ReadableArray?) { @@ -44,6 +49,22 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { super.onDetachedFromWindow() } + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + lastNativeDownTime = event.downTime + } + return super.dispatchTouchEvent(event) + } + + /** + * A native view takes over the touch stream with `requestDisallowInterceptTouchEvent`, which only + * bubbles up, so handlers of detectors below it are never notified. Mirror the rule buttons use: + * cancel when the grab happens after DOWN, or when it happens on DOWN and kept the event from + * reaching this detector (e.g. a scroll view catching a fling). + */ + fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) = + grabbedMidGesture || lastNativeDownTime != downTime + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { super.requestDisallowInterceptTouchEvent(disallowIntercept) if (!disallowIntercept) { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index 9e67452498..d74a4b82b5 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -143,9 +143,9 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: * request alone doesn't say what the caller did with the event: a scrollable calls it when it * takes over the touch, but e.g. a nested pager calls it already on DOWN, just to keep its * ancestors from stealing a swipe it may recognize later, and the event still reaches the - * button - at request time both calls look identical. They only become - * distinguishable once the native dispatch completes (did the button receive the DOWN?), which - * is why cancellation runs here instead of in `requestDisallowInterceptTouchEvent`. + * button or detector - at request time both calls look identical. They only become + * distinguishable once the native dispatch completes (did the button or detector receive the + * DOWN?), which is why cancellation runs here instead of in `requestDisallowInterceptTouchEvent`. */ fun onNativeDispatchEnd(event: MotionEvent) { passingNativeTouch = false @@ -156,7 +156,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: val grabbedMidGesture = event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_POINTER_DOWN - orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture) + orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture, event.downTime) } } From 6e034f39065f060ca17eb4eec75ec7d7671e8362 Mon Sep 17 00:00:00 2001 From: Jacques Leupin Date: Wed, 7 Oct 2026 16:28:25 -0700 Subject: [PATCH 2/4] Compare the grab's event time so a takeover on a later pointer's DOWN is caught --- .../core/GestureHandlerOrchestrator.kt | 4 ++-- .../react/RNGestureHandlerDetectorView.kt | 20 ++++++++++--------- .../react/RNGestureHandlerRootHelper.kt | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 35435bf0e3..70e89a5e62 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -393,13 +393,13 @@ class GestureHandlerOrchestrator( * Cancels handlers that shouldn't survive a native view taking over the touch stream: handlers * whose view opted out through its hook (buttons), and detector handlers that haven't activated. */ - fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) = cancelHandlersMatching { + fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean, eventTime: Long) = cancelHandlersMatching { if (it is NativeViewGestureHandler) { it.shouldCancelOnNativeTouchGrab(grabbedMidGesture) } else { !it.isActive && GestureHandler.usesNativeOrVirtualDetector(it.actionType) && - it.hostDetectorView?.shouldCancelOnNativeTouchGrab(grabbedMidGesture, downTime) == true + it.hostDetectorView?.shouldCancelOnNativeTouchGrab(grabbedMidGesture, eventTime) == true } } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt index b09f0ceb13..c67ad2ef3a 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt @@ -23,9 +23,10 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { private var subscribedVirtualHandlers: MutableMap> = mutableMapOf() private var moduleId: Int = -1 - // Down time of the last DOWN that native dispatch delivered to this view. Handler delivery - // bypasses `dispatchTouchEvent`, so a DOWN intercepted by an ancestor never updates it. - private var lastNativeDownTime = -1L + // Event time of the last DOWN or POINTER_DOWN that native dispatch delivered to this view. + // Handler delivery bypasses `dispatchTouchEvent`, so an event intercepted by an ancestor never + // updates it. Down time can't be used: every event of a stream carries the first DOWN's. + private var lastNativeDownEventTime = -1L data class VirtualChildren(val handlerTags: List, val viewTag: Int) @@ -50,8 +51,8 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { } override fun dispatchTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - lastNativeDownTime = event.downTime + if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) { + lastNativeDownEventTime = event.eventTime } return super.dispatchTouchEvent(event) } @@ -59,11 +60,12 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { /** * A native view takes over the touch stream with `requestDisallowInterceptTouchEvent`, which only * bubbles up, so handlers of detectors below it are never notified. Mirror the rule buttons use: - * cancel when the grab happens after DOWN, or when it happens on DOWN and kept the event from - * reaching this detector (e.g. a scroll view catching a fling). + * cancel when the grab happens after a DOWN, or when it happens on a DOWN or POINTER_DOWN that + * never reached this detector (e.g. a scroll view catching a fling, or an ancestor taking over + * when another pointer lands). */ - fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) = - grabbedMidGesture || lastNativeDownTime != downTime + fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean, eventTime: Long) = + grabbedMidGesture || lastNativeDownEventTime != eventTime override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { super.requestDisallowInterceptTouchEvent(disallowIntercept) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index d74a4b82b5..2366c9fc46 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -156,7 +156,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: val grabbedMidGesture = event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_POINTER_DOWN - orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture, event.downTime) + orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture, event.eventTime) } } From b4eb4d4421075acaeef3e13523cb3b0e95abd3a8 Mon Sep 17 00:00:00 2001 From: Jacques Leupin Date: Fri, 9 Oct 2026 16:25:07 -0700 Subject: [PATCH 3/4] Revert "Compare the grab's event time so a takeover on a later pointer's DOWN is caught" This reverts commit 6e034f39065f060ca17eb4eec75ec7d7671e8362. A detector tracking an earlier pointer receives a new pointer's POINTER_DOWN as a split MOVE, so it never recorded that event's time. When the view under the new pointer requested disallow-intercept on its DOWN without intercepting (a nested pager), the sweep cancelled the held gesture even though its native stream continued. --- .../core/GestureHandlerOrchestrator.kt | 4 ++-- .../react/RNGestureHandlerDetectorView.kt | 20 +++++++++---------- .../react/RNGestureHandlerRootHelper.kt | 2 +- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 70e89a5e62..35435bf0e3 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -393,13 +393,13 @@ class GestureHandlerOrchestrator( * Cancels handlers that shouldn't survive a native view taking over the touch stream: handlers * whose view opted out through its hook (buttons), and detector handlers that haven't activated. */ - fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean, eventTime: Long) = cancelHandlersMatching { + fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) = cancelHandlersMatching { if (it is NativeViewGestureHandler) { it.shouldCancelOnNativeTouchGrab(grabbedMidGesture) } else { !it.isActive && GestureHandler.usesNativeOrVirtualDetector(it.actionType) && - it.hostDetectorView?.shouldCancelOnNativeTouchGrab(grabbedMidGesture, eventTime) == true + it.hostDetectorView?.shouldCancelOnNativeTouchGrab(grabbedMidGesture, downTime) == true } } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt index c67ad2ef3a..b09f0ceb13 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt @@ -23,10 +23,9 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { private var subscribedVirtualHandlers: MutableMap> = mutableMapOf() private var moduleId: Int = -1 - // Event time of the last DOWN or POINTER_DOWN that native dispatch delivered to this view. - // Handler delivery bypasses `dispatchTouchEvent`, so an event intercepted by an ancestor never - // updates it. Down time can't be used: every event of a stream carries the first DOWN's. - private var lastNativeDownEventTime = -1L + // Down time of the last DOWN that native dispatch delivered to this view. Handler delivery + // bypasses `dispatchTouchEvent`, so a DOWN intercepted by an ancestor never updates it. + private var lastNativeDownTime = -1L data class VirtualChildren(val handlerTags: List, val viewTag: Int) @@ -51,8 +50,8 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { } override fun dispatchTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) { - lastNativeDownEventTime = event.eventTime + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + lastNativeDownTime = event.downTime } return super.dispatchTouchEvent(event) } @@ -60,12 +59,11 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { /** * A native view takes over the touch stream with `requestDisallowInterceptTouchEvent`, which only * bubbles up, so handlers of detectors below it are never notified. Mirror the rule buttons use: - * cancel when the grab happens after a DOWN, or when it happens on a DOWN or POINTER_DOWN that - * never reached this detector (e.g. a scroll view catching a fling, or an ancestor taking over - * when another pointer lands). + * cancel when the grab happens after DOWN, or when it happens on DOWN and kept the event from + * reaching this detector (e.g. a scroll view catching a fling). */ - fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean, eventTime: Long) = - grabbedMidGesture || lastNativeDownEventTime != eventTime + fun shouldCancelOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) = + grabbedMidGesture || lastNativeDownTime != downTime override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { super.requestDisallowInterceptTouchEvent(disallowIntercept) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index 2366c9fc46..d74a4b82b5 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -156,7 +156,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: val grabbedMidGesture = event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_POINTER_DOWN - orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture, event.eventTime) + orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture, event.downTime) } } From 927e6f2511e313f4dc099647272bbc97fbf6c1e9 Mon Sep 17 00:00:00 2001 From: Jacques Leupin Date: Fri, 9 Oct 2026 16:30:05 -0700 Subject: [PATCH 4/4] Apply the detector rule to native gestures attached through a detector The sweep asked a `NativeViewGestureHandler` only its view's hook, which keeps it by default, so a native gesture attached through a detector below the view that took over stayed alive and could still activate on lift (e.g. `useNativeGesture` on a `Text`, whose hook always lets it activate). Buttons are still checked by their hook as well. --- .../core/GestureHandlerOrchestrator.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 35435bf0e3..4ed520daa6 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -391,16 +391,16 @@ class GestureHandlerOrchestrator( /** * Cancels handlers that shouldn't survive a native view taking over the touch stream: handlers - * whose view opted out through its hook (buttons), and detector handlers that haven't activated. + * whose view opted out through its hook (buttons), and handlers attached through a detector that + * haven't activated, native gestures included. */ fun cancelHandlersOnNativeTouchGrab(grabbedMidGesture: Boolean, downTime: Long) = cancelHandlersMatching { - if (it is NativeViewGestureHandler) { - it.shouldCancelOnNativeTouchGrab(grabbedMidGesture) - } else { - !it.isActive && - GestureHandler.usesNativeOrVirtualDetector(it.actionType) && - it.hostDetectorView?.shouldCancelOnNativeTouchGrab(grabbedMidGesture, downTime) == true - } + val cancelledByHook = it is NativeViewGestureHandler && it.shouldCancelOnNativeTouchGrab(grabbedMidGesture) + val cancelledByDetector = !it.isActive && + GestureHandler.usesNativeOrVirtualDetector(it.actionType) && + it.hostDetectorView?.shouldCancelOnNativeTouchGrab(grabbedMidGesture, downTime) == true + + cancelledByHook || cancelledByDetector } /**