From c3806d54e16213ee15401e44e625533820e30d7a Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Sun, 27 Sep 2026 23:13:57 -0400 Subject: [PATCH 1/5] fix: prevent presses when stopping Android scroll flings --- .../gesturehandler/core/NativeViewGestureHandler.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index 85e8a61b5a..3c6692a79f 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -340,7 +340,16 @@ class NativeViewGestureHandler : GestureHandler() { } } - private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event) + private fun tryIntercept(view: View, event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN && + (view is ReactScrollView || view is ReactHorizontalScrollView) + ) { + // ReactScrollView must receive DOWN through native dispatch first; calling onInterceptTouchEvent + // here consumes its fling-catch decision before the child button can be cancelled. + return false + } + return view is ViewGroup && view.onInterceptTouchEvent(event) + } private val defaultHook = object : NativeViewGestureHandlerHook {} From 59d607b433f85e05143f4e37a793362e73432562 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 28 Sep 2026 12:10:58 -0400 Subject: [PATCH 2/5] fix: keep scroll view DOWN interception for shouldActivateOnStart --- .../core/NativeViewGestureHandler.kt | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index 3c6692a79f..83d7c1e27f 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -189,7 +189,7 @@ class NativeViewGestureHandler : GestureHandler() { activate() } - tryIntercept(view, event) -> { + !isScrollViewDown(view, event) && tryIntercept(view, event) -> { hook.sendTouchEvent(view, event) activate() } @@ -340,16 +340,14 @@ class NativeViewGestureHandler : GestureHandler() { } } - private fun tryIntercept(view: View, event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN && - (view is ReactScrollView || view is ReactHorizontalScrollView) - ) { - // ReactScrollView must receive DOWN through native dispatch first; calling onInterceptTouchEvent - // here consumes its fling-catch decision before the child button can be cancelled. - return false - } - return view is ViewGroup && view.onInterceptTouchEvent(event) - } + private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event) + + // ReactScrollView must receive DOWN through native dispatch first; calling onInterceptTouchEvent + // here consumes its fling-catch decision before the child button can be cancelled. Native dispatch + // only reaches the scroll view while this handler is not active, so this does not apply to + // `shouldActivateOnStart`. + private fun isScrollViewDown(view: View, event: MotionEvent) = event.actionMasked == MotionEvent.ACTION_DOWN && + (view is ReactScrollView || view is ReactHorizontalScrollView) private val defaultHook = object : NativeViewGestureHandlerHook {} From f772720ddb1907c5f7d8d9f0364cd92e11b9fefe Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 28 Sep 2026 13:02:50 -0400 Subject: [PATCH 3/5] fix: keep scroll view flings alive across the root's synthetic CANCEL An active scroll view handler feeds the view through onTouchEvent, so the child that received the native DOWN stays recorded as the scroll view's touch target. The root view's synthetic ACTION_CANCEL before the next DOWN then runs ScrollView.onInterceptTouchEvent(CANCEL), whose springBack marks the fling finished, so the next tap reaches the Touchable under it. Clear those stale targets when the active gesture ends, and drop the DOWN special case in tryIntercept, which is no longer needed: with the fling alive, the handler intercepts the DOWN and cancels the Touchable. --- .../core/NativeViewGestureHandler.kt | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index 83d7c1e27f..dc25ebf43e 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -164,6 +164,9 @@ class NativeViewGestureHandler : GestureHandler() { if (shouldStopNestedScroll()) { view.stopNestedScroll() } + if (state == STATE_ACTIVE && hook.shouldClearChildTouchTargets()) { + clearChildTouchTargets(view as ViewGroup, event) + } if ((state == STATE_UNDETERMINED || state == STATE_BEGAN) && hook.canActivate(view)) { activate() @@ -189,7 +192,7 @@ class NativeViewGestureHandler : GestureHandler() { activate() } - !isScrollViewDown(view, event) && tryIntercept(view, event) -> { + tryIntercept(view, event) -> { hook.sendTouchEvent(view, event) activate() } @@ -215,6 +218,17 @@ class NativeViewGestureHandler : GestureHandler() { event.recycle() } + // While active, the view gets touches through `onTouchEvent`, so the child it dispatched the native + // DOWN to stays recorded as its touch target. The root's next ACTION_CANCEL would then pass through + // `ScrollView.onInterceptTouchEvent`, whose CANCEL branch calls `springBack` and ends the fling this + // UP just started. Disallowing interception lets the CANCEL reach only the stale children. + private fun clearChildTouchTargets(view: ViewGroup, event: MotionEvent) { + val cancelEvent = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL } + view.requestDisallowInterceptTouchEvent(true) + view.dispatchTouchEvent(cancelEvent) + cancelEvent.recycle() + } + // Once the handler is active, it delivers touches straight to the view's `onTouchEvent`. Normally // touches arrive through `View.dispatchTouchEvent`, which also ends the nested scroll when the finger // goes up. Because we skip it, the nested scroll stays open and the parent never finds out that the @@ -342,13 +356,6 @@ class NativeViewGestureHandler : GestureHandler() { private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event) - // ReactScrollView must receive DOWN through native dispatch first; calling onInterceptTouchEvent - // here consumes its fling-catch decision before the child button can be cancelled. Native dispatch - // only reaches the scroll view while this handler is not active, so this does not apply to - // `shouldActivateOnStart`. - private fun isScrollViewDown(view: View, event: MotionEvent) = event.actionMasked == MotionEvent.ACTION_DOWN && - (view is ReactScrollView || view is ReactHorizontalScrollView) - private val defaultHook = object : NativeViewGestureHandlerHook {} enum class ScrollDirection(val value: Int) { @@ -374,6 +381,11 @@ class NativeViewGestureHandler : GestureHandler() { */ fun shouldStopNestedScroll() = false + /** + * Whether the view's stale child touch targets should be cleared when the active gesture ends. + */ + fun shouldClearChildTouchTargets() = false + /** * Checks whether handler can activate. Used by TextViewHook. */ @@ -553,6 +565,8 @@ class NativeViewGestureHandler : GestureHandler() { // ScrollView starts a nested scroll on DOWN but never stops it itself. Without this the // parent's `onStopNestedScroll` never runs, e.g. SwipeRefreshLayout never triggers refresh. override fun shouldStopNestedScroll() = true + + override fun shouldClearChildTouchTargets() = true } private class ReactViewGroupHook : NativeViewGestureHandlerHook { From 34d7c6cde2f63e6493a5e92f1458511b3f572020 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 29 Sep 2026 11:07:01 -0400 Subject: [PATCH 4/5] fix: leave scroll view DOWN to native dispatch again When an ancestor scroll view catches its fling on DOWN, native dispatch never delivers that DOWN to the nested scroll view. Calling onInterceptTouchEvent(DOWN) on it from the handler still records the pointer, so a MOVE that drifts sideways past the touch slop makes the nested scroll view intercept, activate its handler and take the stream from the ancestor's drag: the ancestor stops and does not re-fling. With flings kept alive across the root's synthetic CANCEL, native dispatch lets an RNGH scroll view catch its own fling on DOWN, so the handler does not need to see that DOWN except for shouldActivateOnStart. --- .../gesturehandler/core/NativeViewGestureHandler.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index dc25ebf43e..092850e20b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -192,7 +192,7 @@ class NativeViewGestureHandler : GestureHandler() { activate() } - tryIntercept(view, event) -> { + !isScrollViewDown(view, event) && tryIntercept(view, event) -> { hook.sendTouchEvent(view, event) activate() } @@ -356,6 +356,13 @@ class NativeViewGestureHandler : GestureHandler() { private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event) + // A scroll view gets DOWN through native dispatch, which skips it when an ancestor scroll view + // intercepts the DOWN to stop its own fling. Passing the DOWN to `onInterceptTouchEvent` here would + // still record the pointer, so a later MOVE drifting sideways past the touch slop would let this + // view take over the ancestor's drag. + private fun isScrollViewDown(view: View, event: MotionEvent) = event.actionMasked == MotionEvent.ACTION_DOWN && + (view is ReactScrollView || view is ReactHorizontalScrollView) + private val defaultHook = object : NativeViewGestureHandlerHook {} enum class ScrollDirection(val value: Int) { From a83bd20180762de806f590e5c9759cbfeb04c374 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 30 Sep 2026 10:48:02 -0400 Subject: [PATCH 5/5] Revert "fix: leave scroll view DOWN to native dispatch again" This reverts commit 34d7c6cde2f63e6493a5e92f1458511b3f572020. --- .../gesturehandler/core/NativeViewGestureHandler.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index 092850e20b..dc25ebf43e 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -192,7 +192,7 @@ class NativeViewGestureHandler : GestureHandler() { activate() } - !isScrollViewDown(view, event) && tryIntercept(view, event) -> { + tryIntercept(view, event) -> { hook.sendTouchEvent(view, event) activate() } @@ -356,13 +356,6 @@ class NativeViewGestureHandler : GestureHandler() { private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event) - // A scroll view gets DOWN through native dispatch, which skips it when an ancestor scroll view - // intercepts the DOWN to stop its own fling. Passing the DOWN to `onInterceptTouchEvent` here would - // still record the pointer, so a later MOVE drifting sideways past the touch slop would let this - // view take over the ancestor's drag. - private fun isScrollViewDown(view: View, event: MotionEvent) = event.actionMasked == MotionEvent.ACTION_DOWN && - (view is ReactScrollView || view is ReactHorizontalScrollView) - private val defaultHook = object : NativeViewGestureHandlerHook {} enum class ScrollDirection(val value: Int) {