From b168524063e63344c1cb652d97fa2660914cd4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 12 Oct 2026 16:15:31 +0200 Subject: [PATCH 1/4] [Android] Cancel gestures on native ACTION_CANCEL --- .../core/GestureHandlerOrchestrator.kt | 10 ++++++++++ .../react/RNGestureHandlerDetectorView.kt | 14 ++++++++++++-- .../react/RNGestureHandlerRootHelper.kt | 17 +++++++++++++++++ .../react/RNGestureHandlerRootView.kt | 4 ++++ 4 files changed, 43 insertions(+), 2 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 7ff89e5398..0b3f107929 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 @@ -403,6 +403,16 @@ class GestureHandlerOrchestrator( cancelledByHook || cancelledByDetector } + /** + * Cancels pending detector handlers whose detector got ACTION_CANCEL from the native dispatch of + * the event with `eventTime`. Unlike a disallow-intercept request, the CANCEL names its target. + */ + fun cancelHandlersOnNativeTouchCancel(eventTime: Long) = cancelHandlersMatching { + !it.isActive && + GestureHandler.usesNativeOrVirtualDetector(it.actionType) && + it.hostDetectorView?.shouldCancelOnNativeTouchCancel(eventTime) == true + } + /** * isViewAttachedUnderWrapper checks whether all of parents for view related to handler * view are attached. Since there might be an issue rarely observed when view 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..65440c5bec 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 @@ -27,6 +27,10 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { // bypasses `dispatchTouchEvent`, so a DOWN intercepted by an ancestor never updates it. private var lastNativeDownTime = -1L + // Event time of the last CANCEL native dispatch delivered to this view. The cancel loop of an + // intercepting ancestor reuses the intercepted event, so it matches that event's time. + private var lastNativeCancelTime = -1L + data class VirtualChildren(val handlerTags: List, val viewTag: Int) fun setHandlerTags(handlerTags: ReadableArray?) { @@ -50,12 +54,18 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) { } override fun dispatchTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - lastNativeDownTime = event.downTime + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> lastNativeDownTime = event.downTime + MotionEvent.ACTION_CANCEL -> { + lastNativeCancelTime = event.eventTime + RNGestureHandlerRootView.findGestureHandlerRootView(this)?.onNativeTouchCancelled() + } } return super.dispatchTouchEvent(event) } + fun shouldCancelOnNativeTouchCancel(eventTime: Long) = lastNativeCancelTime == eventTime + /** * 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: 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..baba360c10 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 @@ -24,6 +24,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: private var passingTouch = false private var passingNativeTouch = false private var nativeTouchGrabRequested = false + private var nativeTouchCancelled = false init { val registry = @@ -138,6 +139,17 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: passingNativeTouch = true } + /** + * A detector got ACTION_CANCEL from the native dispatch: an ancestor took over the touch and its + * disallow-intercept request never reached the root (stopped at a flagged ancestor, or never + * made, e.g. DrawerLayout). CANCELs outside a native pass are the root's or a native handler's own. + */ + fun onNativeTouchCancelled() { + if (passingNativeTouch) { + nativeTouchCancelled = true + } + } + /** * Deferred handling of a disallow-intercept request recorded during this dispatch pass. The * request alone doesn't say what the caller did with the event: a scrollable calls it when it @@ -158,6 +170,11 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture, event.downTime) } + + if (nativeTouchCancelled) { + nativeTouchCancelled = false + orchestrator?.cancelHandlersOnNativeTouchCancel(event.eventTime) + } } fun dispatchTouchEvent(event: MotionEvent): Boolean { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt index 15571c476b..297c60740b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt @@ -89,6 +89,10 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { rootHelper?.activateNativeHandlers(view) } + fun onNativeTouchCancelled() { + rootHelper?.onNativeTouchCancelled() + } + fun isRootViewEnabled() = rootViewEnabled fun setUnstableForceActive(active: Boolean) { From cd3c5b9dd8d0a818cba27321c8d4a234583a4093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 12 Oct 2026 17:02:06 +0200 Subject: [PATCH 2/4] Clear awaitingHandlers --- .../core/GestureHandlerOrchestrator.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 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 0b3f107929..d6e23b0a28 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 @@ -366,19 +366,23 @@ class GestureHandlerOrchestrator( } private inline fun cancelHandlersMatching(predicate: (GestureHandler) -> Boolean) { - val handlersToProcess = obtainHandlerList() - handlersToProcess.addAll(gestureHandlers) + val handlersToCancel = obtainHandlerList() + gestureHandlers.filterTo(handlersToCancel, predicate) try { - handlersToProcess.forEach { - if (predicate(it)) { - it.cancel() + // Cancelling a handler activates the handlers awaiting its failure. Drop the batch from the + // awaiting pool first, so cancelling one member cannot activate another before its turn. + for (handler in handlersToCancel) { + if (awaitingHandlersTags.remove(handler.tag)) { + awaitingHandlers.remove(handler) } } + handlersToCancel.forEach { it.cancel() } + scheduleFinishedHandlersCleanup() } finally { - recycleHandlerList(handlersToProcess) + recycleHandlerList(handlersToCancel) } } From b1e600e3c1ed1901076e42b141a1a642fc99d585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 14 Oct 2026 15:18:38 +0200 Subject: [PATCH 3/4] Add Native takeover example and e2e test --- .argent/flows/native-takeover-test.yaml | 63 ++++++ apps/common-app/src/new_api/index.tsx | 6 + .../new_api/tests/nativeTakeover/index.tsx | 197 ++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 .argent/flows/native-takeover-test.yaml create mode 100644 apps/common-app/src/new_api/tests/nativeTakeover/index.tsx diff --git a/.argent/flows/native-takeover-test.yaml b/.argent/flows/native-takeover-test.yaml new file mode 100644 index 0000000000..09ac6b4687 --- /dev/null +++ b/.argent/flows/native-takeover-test.yaml @@ -0,0 +1,63 @@ +# Android only: a native view (nested PagerView) takes over the touch without +# the root seeing a disallow-intercept request. The tap under it must be +# cancelled by the framework's ACTION_CANCEL instead of firing on lift. +steps: + - echo: Launching Expo Example + - launch: com.example.ExpoExample + - await: { visible: { text: "Empty Example" }, timeout: 15000 } + - when: { platform: android } + steps: + - echo: "On the Home list, search for the native takeover example" + - type: { text: "takeover", into: { id: "search-examples" } } + - await: { visible: { text: "Native takeover" } } + - echo: "Tap the example to open it" + - tap: { text: "Native takeover" } + - await: { visible: { id: "tap-box" } } + - await: { idle: true } + + - echo: "Clear console" + - await: { visible: { id: "open-console-button" } } + - tap: { id: "open-console-button" } + - await: { visible: { id: "clear-console-button" } } + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + + - echo: "Swipe the inner pager starting on the tap box" + - assert: { visible: { text: "Tap count: 0" } } + - swipe: { from: { id: "tap-box" }, direction: left } + - await: { visible: { id: "inner-page-2" } } + - await: { idle: true } + + - echo: "The pager moved and the tap did not fire" + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: "1. onBegin" } } + - assert: { visible: { text: "2. onFinalize" } } + - assert: { hidden: { text: "onActivate" } } + - assert: { hidden: { text: "onDeactivate" } } + + - echo: "Clear console" + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + + - echo: "Swipe back to the first page" + - swipe: { from: { id: "inner-page-2" }, direction: right } + - await: { visible: { id: "tap-box" } } + - await: { idle: true } + - assert: { visible: { text: "Tap count: 0" } } + + - echo: "A plain tap on the box still counts" + - tap: { id: "tap-box" } + - await: { visible: { text: "Tap count: 1" } } + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: "3. onBegin" } } + - assert: { visible: { text: "4. onActivate" } } + - assert: { visible: { text: "5. onDeactivate" } } + - assert: { visible: { text: "6. onFinalize" } } diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index f7d5ae390f..e877e9161c 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -35,6 +35,7 @@ import PinchExample from './simple/pinch'; import RotationExample from './simple/rotation'; import TapExample from './simple/tap'; import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps'; +import NativeTakeoverExample from './tests/nativeTakeover'; import NestedPressablesExample from './tests/nestedPressables'; import NestedRootViewExample from './tests/nestedRootView'; import NestedTouchablesExample from './tests/nestedTouchables'; @@ -130,6 +131,11 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'Modal with Nested Root View', component: NestedRootViewExample }, { name: 'Nested pressables', component: NestedPressablesExample }, { name: 'Nested touchables', component: NestedTouchablesExample }, + { + name: 'Native takeover', + component: NativeTakeoverExample, + unsupportedPlatforms: new Set(['ios', 'macos', 'web']), + }, { name: 'Pressable', component: PressableExample }, { name: 'RN responder cancellation', diff --git a/apps/common-app/src/new_api/tests/nativeTakeover/index.tsx b/apps/common-app/src/new_api/tests/nativeTakeover/index.tsx new file mode 100644 index 0000000000..0ef3ade917 --- /dev/null +++ b/apps/common-app/src/new_api/tests/nativeTakeover/index.tsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; +import { + DrawerLayoutAndroid, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { GestureDetector, useTapGesture } from 'react-native-gesture-handler'; +import PagerView from 'react-native-pager-view'; +import Animated, { + interpolateColor, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; + +import { COLORS, useIndexedLogger } from '../../../common'; + +// A native view takes over the touch without the root ever seeing a +// disallow-intercept request. The framework still sends ACTION_CANCEL to the +// tap's detector, which is what cancels the tap below. +// - Pager: swipe the inner pager starting on the box. The inner pager host +// asks its ancestors not to intercept on DOWN, so the pager's own request on +// MOVE stops at the already flagged page view and never reaches the root. +// - Drawer: swipe the drawer open from the right edge starting on the box. +// DrawerLayout intercepts on MOVE and never requests at all. With gesture +// navigation the system back swipe takes the edge, use 3-button navigation. +// In both cases the pager / drawer moves and the tap count stays the same. + +type Mode = 'pager' | 'drawer'; + +type TapBoxProps = { + count: number; + onTap: () => void; + log: (message: string) => void; +}; + +function TapBox({ count, onTap, log }: TapBoxProps) { + const colorProgress = useSharedValue(0); + + const animatedStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + colorProgress.value, + [0, 1], + [COLORS.NAVY, COLORS.KINDA_BLUE] + ), + })); + + const tap = useTapGesture({ + onBegin: () => { + log('onBegin'); + colorProgress.value = withTiming(1, { duration: 100 }); + }, + onActivate: () => { + log('onActivate'); + scheduleOnRN(onTap); + }, + onDeactivate: () => { + log('onDeactivate'); + }, + onFinalize: () => { + log('onFinalize'); + colorProgress.value = withTiming(0, { duration: 100 }); + }, + }); + + return ( + + + Tap count: {count} + + + ); +} + +function PagerMode(props: TapBoxProps) { + return ( + + + + + + + + Inner page 2 + + + + + Outer page 2 + + + ); +} + +function DrawerMode(props: TapBoxProps) { + return ( + ( + + Drawer + + )}> + + + ); +} + +export default function NativeTakeoverExample() { + const [mode, setMode] = useState('pager'); + const [count, setCount] = useState(0); + const log = useIndexedLogger(); + + const boxProps = { + count, + log, + onTap: () => setCount((c) => c + 1), + }; + + return ( + + + + Swipe the pager or drag the drawer open from the right edge, starting + on the box. The count must not change. + + + + + + + {mode === 'pager' ? ( + + ) : ( + + )} + + ); +} + +type ModeButtonProps = { + mode: Mode; + current: Mode; + onSelect: (mode: Mode) => void; +}; + +function ModeButton({ mode, current, onSelect }: ModeButtonProps) { + return ( + onSelect(mode)}> + + {mode === 'pager' ? 'Pager' : 'Drawer'} + + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + center: { alignItems: 'center', justifyContent: 'center' }, + header: { + padding: 12, + gap: 8, + backgroundColor: COLORS.offWhite, + }, + hint: { + textAlign: 'center', + opacity: 0.6, + fontSize: 14, + }, + buttons: { flexDirection: 'row', justifyContent: 'center', gap: 8 }, + button: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 6, + backgroundColor: COLORS.KINDA_YELLOW, + }, + buttonActive: { backgroundColor: COLORS.YELLOW }, + buttonText: { fontWeight: '600', color: COLORS.NAVY }, + box: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + boxText: { + fontSize: 20, + fontWeight: '700', + color: COLORS.offWhite, + }, +}); From 16ec0b98ad1d8f5634851f391fb78be51fdab911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 14 Oct 2026 15:27:54 +0200 Subject: [PATCH 4/4] Rename example --- ...-test.yaml => native-intercept-cancellation-test.yaml} | 8 ++++---- apps/common-app/src/new_api/index.tsx | 6 +++--- .../index.tsx | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) rename .argent/flows/{native-takeover-test.yaml => native-intercept-cancellation-test.yaml} (89%) rename apps/common-app/src/new_api/tests/{nativeTakeover => nativeInterceptCancellation}/index.tsx (98%) diff --git a/.argent/flows/native-takeover-test.yaml b/.argent/flows/native-intercept-cancellation-test.yaml similarity index 89% rename from .argent/flows/native-takeover-test.yaml rename to .argent/flows/native-intercept-cancellation-test.yaml index 09ac6b4687..2929425ddf 100644 --- a/.argent/flows/native-takeover-test.yaml +++ b/.argent/flows/native-intercept-cancellation-test.yaml @@ -7,11 +7,11 @@ steps: - await: { visible: { text: "Empty Example" }, timeout: 15000 } - when: { platform: android } steps: - - echo: "On the Home list, search for the native takeover example" - - type: { text: "takeover", into: { id: "search-examples" } } - - await: { visible: { text: "Native takeover" } } + - echo: "On the Home list, search for the native intercept cancellation example" + - type: { text: "intercept", into: { id: "search-examples" } } + - await: { visible: { text: "Native intercept cancellation" } } - echo: "Tap the example to open it" - - tap: { text: "Native takeover" } + - tap: { text: "Native intercept cancellation" } - await: { visible: { id: "tap-box" } } - await: { idle: true } diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index e877e9161c..953e2241c6 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -35,7 +35,7 @@ import PinchExample from './simple/pinch'; import RotationExample from './simple/rotation'; import TapExample from './simple/tap'; import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps'; -import NativeTakeoverExample from './tests/nativeTakeover'; +import NativeInterceptCancellationExample from './tests/nativeInterceptCancellation'; import NestedPressablesExample from './tests/nestedPressables'; import NestedRootViewExample from './tests/nestedRootView'; import NestedTouchablesExample from './tests/nestedTouchables'; @@ -132,8 +132,8 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'Nested pressables', component: NestedPressablesExample }, { name: 'Nested touchables', component: NestedTouchablesExample }, { - name: 'Native takeover', - component: NativeTakeoverExample, + name: 'Native intercept cancellation', + component: NativeInterceptCancellationExample, unsupportedPlatforms: new Set(['ios', 'macos', 'web']), }, { name: 'Pressable', component: PressableExample }, diff --git a/apps/common-app/src/new_api/tests/nativeTakeover/index.tsx b/apps/common-app/src/new_api/tests/nativeInterceptCancellation/index.tsx similarity index 98% rename from apps/common-app/src/new_api/tests/nativeTakeover/index.tsx rename to apps/common-app/src/new_api/tests/nativeInterceptCancellation/index.tsx index 0ef3ade917..0f10bed33b 100644 --- a/apps/common-app/src/new_api/tests/nativeTakeover/index.tsx +++ b/apps/common-app/src/new_api/tests/nativeInterceptCancellation/index.tsx @@ -111,7 +111,7 @@ function DrawerMode(props: TapBoxProps) { ); } -export default function NativeTakeoverExample() { +export default function NativeInterceptCancellationExample() { const [mode, setMode] = useState('pager'); const [count, setCount] = useState(0); const log = useIndexedLogger();