diff --git a/.argent/flows/native-intercept-cancellation-test.yaml b/.argent/flows/native-intercept-cancellation-test.yaml
new file mode 100644
index 0000000000..2929425ddf
--- /dev/null
+++ b/.argent/flows/native-intercept-cancellation-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 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 intercept cancellation" }
+ - 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..953e2241c6 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 NativeInterceptCancellationExample from './tests/nativeInterceptCancellation';
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 intercept cancellation',
+ component: NativeInterceptCancellationExample,
+ unsupportedPlatforms: new Set(['ios', 'macos', 'web']),
+ },
{ name: 'Pressable', component: PressableExample },
{
name: 'RN responder cancellation',
diff --git a/apps/common-app/src/new_api/tests/nativeInterceptCancellation/index.tsx b/apps/common-app/src/new_api/tests/nativeInterceptCancellation/index.tsx
new file mode 100644
index 0000000000..0f10bed33b
--- /dev/null
+++ b/apps/common-app/src/new_api/tests/nativeInterceptCancellation/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 NativeInterceptCancellationExample() {
+ 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,
+ },
+});
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 123d73cfd2..5b03319a9f 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)
}
}
@@ -403,6 +407,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) {