From 4897ae064bb440df9e8dbc144d5f3c3f27395211 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 8 Apr 2026 16:36:04 +0200 Subject: [PATCH 01/18] initial --- apps/common-app/src/new_api/index.tsx | 5 + .../tests/rnResponderCancellation/index.tsx | 158 ++++++++++++++++++ .../docs/fundamentals/root-view.mdx | 17 +- .../react/RNGestureHandlerModule.kt | 3 + .../apple/RNGestureHandlerManager.h | 2 + .../apple/RNGestureHandlerManager.mm | 11 ++ .../apple/RNGestureHandlerModule.mm | 6 + .../apple/RNRootViewGestureRecognizer.h | 1 + .../apple/RNRootViewGestureRecognizer.m | 4 +- .../src/RNGestureHandlerModule.web.ts | 3 + .../src/RNGestureHandlerModule.windows.ts | 3 + .../GestureHandlerRootView.android.tsx | 7 +- .../src/components/GestureHandlerRootView.tsx | 19 ++- .../components/GestureHandlerRootView.web.tsx | 7 +- .../src/mocks/module.tsx | 2 + .../src/specs/NativeRNGestureHandlerModule.ts | 1 + 16 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 9327721c0d..faa2905271 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -44,6 +44,7 @@ import ReattachingExample from './tests/reattaching'; import NestedRootViewExample from './tests/nestedRootView'; import NestedPressablesExample from './tests/nestedPressables'; import PressableExample from './tests/pressable'; +import RNResponderCancellationExample from './tests/rnResponderCancellation'; import { ExamplesSection } from '../common'; import EmptyExample from '../empty'; @@ -131,6 +132,10 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'Modal with Nested Root View', component: NestedRootViewExample }, { name: 'Nested pressables', component: NestedPressablesExample }, { name: 'Pressable', component: PressableExample }, + { + name: 'RN responder cancellation', + component: RNResponderCancellationExample, + }, ], }, ]; diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx new file mode 100644 index 0000000000..1988cb2b9a --- /dev/null +++ b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { StyleSheet, Switch, Text, View } from 'react-native'; +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler'; +import { + COLORS, + Feedback, + FeedbackHandle, + commonStyles, +} from '../../../common'; + +const MAX_EVENTS = 8; + +export default function RNResponderCancellationExample() { + const feedbackRef = useRef(null); + const sequenceRef = useRef(0); + const [events, setEvents] = useState([]); + const [preventRecognizers, setPreventRecognizers] = useState(true); + + const pushEvent = useCallback((label: string) => { + sequenceRef.current += 1; + const event = `${sequenceRef.current}. ${label}`; + + console.log(event); + feedbackRef.current?.showMessage(label); + setEvents((prev) => [event, ...prev].slice(0, MAX_EVENTS)); + }, []); + + const panGesture = useMemo( + () => + Gesture.Pan() + .minDistance(12) + .runOnJS(true) + .onStart(() => { + pushEvent('GH pan ACTIVE'); + }) + .onFinalize((_event, success) => { + pushEvent(`GH pan finalize (${success ? 'success' : 'cancel/fail'})`); + }), + [pushEvent] + ); + + return ( + + RN responder cancellation + + Toggle preventRecognizers and drag inside the box to compare behavior. + + + preventRecognizers + + + + + { + pushEvent('RN onStartShouldSetResponder -> true'); + return true; + }} + onMoveShouldSetResponder={() => { + pushEvent('RN onMoveShouldSetResponder -> true'); + return true; + }} + onResponderGrant={() => { + pushEvent('RN onResponderGrant'); + }} + onResponderMove={() => { + pushEvent('RN onResponderMove'); + }} + onResponderRelease={() => { + pushEvent('RN onResponderRelease'); + }} + onResponderTerminate={() => { + pushEvent('RN onResponderTerminate'); + }} + onResponderTerminationRequest={() => { + pushEvent('RN onResponderTerminationRequest -> true'); + return true; + }}> + Drag me + + + + + + {events.map((item) => ( + + {item} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 16, + paddingVertical: 24, + gap: 12, + alignItems: 'center', + backgroundColor: COLORS.offWhite, + }, + touchArea: { + width: '100%', + maxWidth: 340, + minHeight: 220, + borderRadius: 20, + borderWidth: 2, + borderColor: COLORS.NAVY, + backgroundColor: '#d8ebff', + justifyContent: 'center', + alignItems: 'center', + }, + row: { + width: '100%', + maxWidth: 340, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + rowLabel: { + color: COLORS.NAVY, + fontSize: 14, + fontWeight: '600', + }, + touchAreaLabel: { + color: COLORS.NAVY, + fontWeight: '700', + fontSize: 18, + }, + logContainer: { + width: '100%', + maxWidth: 380, + minHeight: 170, + borderRadius: 12, + padding: 12, + backgroundColor: '#ffffff', + borderWidth: 1, + borderColor: '#d5dbe6', + gap: 2, + }, + logLine: { + fontSize: 13, + color: '#2c3a4f', + fontFamily: 'Courier', + }, +}); diff --git a/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx b/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx index 362de2d34f..dc8566d32c 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx +++ b/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx @@ -33,11 +33,11 @@ If you're using Gesture Handler in your component library, you may want to wrap `GestureHandlerRootView` can be thought of as a regular `View` component, therefore it accepts all the same props, including [`style`](https://reactnative.dev/docs/0.81/view-style-props). -If you don't provide anything to the `style` prop, it will default to `{ flex: 1 }`. If you want to customize the styling of the root view, don't forget to also include `flex: 1` in the custom style, otherwise your app won't render anything. +If you don't provide anything to the `style` prop, it will default to `{ flex: 1 }`. If you want to customize the styling of the root view, don't forget to also include `flex: 1` in the custom style, otherwise your app won't render anything. ## Nesting root views -In case of nested root views, Gesture Handler will only use the top-most one and ignore the nested ones. If you're unsure if one of your dependencies already renders `GestureHandlerRootView` on its own, don't worry and add one at the root anyway. +In case of nested root views, Gesture Handler will only use the top-most one and ignore the nested ones. If you're unsure if one of your dependencies already renders `GestureHandlerRootView` on its own, don't worry and add one at the root anyway. ## unstable_forceActive @@ -46,3 +46,16 @@ unstable_forceActive?: boolean; ``` If you're having trouble with gestures not working when inside a component provided by a third-party library, even though you've wrapped the entry point with ``, you can try adding another `` closer to the place the gestures are defined. This way, you can prevent Android from canceling relevant gestures when one of the native views tries to grab lock for delivering touch events. + +## preventRecognizers + +```ts +preventRecognizers?: boolean; +``` + +This flag controls whether Gesture Handler cancels React Native JS responders when one of Gesture Handler recognizers activates. + +- `true` (default): keeps the current behavior where RN touch handlers are cancelled after Gesture Handler activates. +- `false`: disables this cancellation, so Gesture Handler callbacks and RN responder callbacks can run at the same time. + +This option is currently available only on iOS. diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt index 986f5c1e13..285f6b3fb8 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt @@ -129,6 +129,9 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : isReanimatedAvailable = isAvailable } + @ReactMethod + override fun setShouldPreventRecognizers(shouldPreventRecognizers: Boolean) = Unit + @DoNotStrip @Suppress("unused") fun setGestureHandlerState(handlerTag: Int, newState: Int) { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.h index 970de2d143..3920aa2f0f 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.h @@ -44,4 +44,6 @@ - (nullable RNGHUIView *)viewForReactTag:(nonnull NSNumber *)reactTag; - (void)reattachHandlersIfNeeded; + +- (void)setShouldPreventRecognizers:(BOOL)shouldPreventRecognizers; @end diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm index 671f80c1ce..9a4c40b27e 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm @@ -49,6 +49,7 @@ @implementation RNGestureHandlerManager { NSHashTable *_rootViewGestureRecognizers; NSMutableDictionary *_attachRetryCounter; NSMutableSet *_droppedHandlers; + BOOL _shouldPreventRecognizers; RCTModuleRegistry *_moduleRegistry; RCTViewRegistry *_viewRegistry; id _eventDispatcher; @@ -77,6 +78,7 @@ - (void)initCommonProps _rootViewGestureRecognizers = [NSHashTable hashTableWithOptions:NSPointerFunctionsWeakMemory]; _attachRetryCounter = [[NSMutableDictionary alloc] init]; _droppedHandlers = [NSMutableSet set]; + _shouldPreventRecognizers = YES; } - (void)createGestureHandler:(NSString *)handlerName tag:(NSNumber *)handlerTag config:(NSDictionary *)config @@ -304,6 +306,7 @@ - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childVie RCTLifecycleLog(@"[GESTURE HANDLER] Initialize gesture handler for view %@", touchHandlerView); RNRootViewGestureRecognizer *recognizer = [RNRootViewGestureRecognizer new]; + recognizer.preventRecognizers = _shouldPreventRecognizers; recognizer.delegate = self; #if !TARGET_OS_OSX touchHandlerView.userInteractionEnabled = YES; @@ -312,6 +315,14 @@ - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childVie [_rootViewGestureRecognizers addObject:recognizer]; } +- (void)setShouldPreventRecognizers:(BOOL)shouldPreventRecognizers +{ + _shouldPreventRecognizers = shouldPreventRecognizers; + for (RNRootViewGestureRecognizer *recognizer in _rootViewGestureRecognizers) { + recognizer.preventRecognizers = shouldPreventRecognizers; + } +} + - (void)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer didActivateInViewWithTouchHandler:(RNGHUIView *)viewWithTouchHandler { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm index d284a4d6b4..67e6def763 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm @@ -194,6 +194,12 @@ - (void)setReanimatedAvailable:(BOOL)isAvailable _isReanimatedAvailable = isAvailable; } +- (void)setShouldPreventRecognizers:(BOOL)shouldPreventRecognizers +{ + RNGestureHandlerManager *manager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; + [manager setShouldPreventRecognizers:shouldPreventRecognizers]; +} + - (void)setGestureState:(int)state forHandler:(int)handlerTag { if (RCTIsMainQueue()) { diff --git a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.h b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.h index a57dfc6661..bfddd85540 100644 --- a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.h +++ b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.h @@ -11,6 +11,7 @@ @interface RNRootViewGestureRecognizer : UIGestureRecognizer @property (nullable, nonatomic, weak) id delegate; +@property (nonatomic) BOOL preventRecognizers; - (void)blockOtherRecognizers; diff --git a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m index 474592d146..391a215723 100644 --- a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m +++ b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m @@ -26,9 +26,11 @@ - (instancetype)init if (self = [super init]) { self.delaysTouchesEnded = NO; self.delaysTouchesBegan = NO; + self.preventRecognizers = YES; } #else self = [super init]; + self.preventRecognizers = YES; #endif return self; } @@ -56,7 +58,7 @@ - (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)preventingGestu // to send an info to JS so that it cancells all JS responders, as long as the preventing // recognizer is from Gesture Handler, otherwise we might break some interactions RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:preventingGestureRecognizer]; - if (handler != nil) { + if (self.preventRecognizers && handler != nil) { [self.delegate gestureRecognizer:preventingGestureRecognizer didActivateInViewWithTouchHandler:self.view]; } diff --git a/packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts b/packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts index c30bfd7729..1bfd9b573f 100644 --- a/packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts +++ b/packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts @@ -92,4 +92,7 @@ export default { setReanimatedAvailable(_isAvailable: boolean) { // No-op on web }, + setShouldPreventRecognizers(_shouldPreventRecognizers: boolean) { + // No-op on web + }, }; diff --git a/packages/react-native-gesture-handler/src/RNGestureHandlerModule.windows.ts b/packages/react-native-gesture-handler/src/RNGestureHandlerModule.windows.ts index cf71b49397..16f3d96a06 100644 --- a/packages/react-native-gesture-handler/src/RNGestureHandlerModule.windows.ts +++ b/packages/react-native-gesture-handler/src/RNGestureHandlerModule.windows.ts @@ -56,4 +56,7 @@ export default { flushOperations() { // NO-OP }, + setShouldPreventRecognizers(_shouldPreventRecognizers: boolean) { + // NO-OP + }, }; diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx index 4a07468d47..fd49ebc313 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx @@ -6,12 +6,17 @@ import type { RootViewNativeProps } from '../specs/RNGestureHandlerRootViewNativ import GestureHandlerRootViewNativeComponent from '../specs/RNGestureHandlerRootViewNativeComponent'; export interface GestureHandlerRootViewProps - extends PropsWithChildren {} + extends PropsWithChildren { + preventRecognizers?: boolean; +} export default function GestureHandlerRootView({ style, + preventRecognizers, ...rest }: GestureHandlerRootViewProps) { + void preventRecognizers; + return ( void; +}; + export interface GestureHandlerRootViewProps - extends PropsWithChildren {} + extends PropsWithChildren { + preventRecognizers?: boolean; +} export default function GestureHandlerRootView({ style, + preventRecognizers = true, ...rest }: GestureHandlerRootViewProps) { + React.useEffect(() => { + console.log('Setting preventRecognizers to', preventRecognizers); + console.log(RNGestureHandlerModule.setShouldPreventRecognizers); + + ( + RNGestureHandlerModule as RootViewConfigModule + ).setShouldPreventRecognizers?.(preventRecognizers); + }, [preventRecognizers]); + return ( diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.web.tsx index 965f29f4af..7e4d542f5c 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.web.tsx @@ -4,12 +4,17 @@ import { View, ViewProps, StyleSheet } from 'react-native'; import GestureHandlerRootViewContext from '../GestureHandlerRootViewContext'; export interface GestureHandlerRootViewProps - extends PropsWithChildren {} + extends PropsWithChildren { + preventRecognizers?: boolean; +} export default function GestureHandlerRootView({ style, + preventRecognizers, ...rest }: GestureHandlerRootViewProps) { + void preventRecognizers; + return ( diff --git a/packages/react-native-gesture-handler/src/mocks/module.tsx b/packages/react-native-gesture-handler/src/mocks/module.tsx index c6b957e1d0..1e7abb449a 100644 --- a/packages/react-native-gesture-handler/src/mocks/module.tsx +++ b/packages/react-native-gesture-handler/src/mocks/module.tsx @@ -10,6 +10,7 @@ const updateGestureHandlerConfig = NOOP; const flushOperations = NOOP; const configureRelations = NOOP; const setReanimatedAvailable = NOOP; +const setShouldPreventRecognizers = NOOP; const install = NOOP; export default { @@ -20,6 +21,7 @@ export default { updateGestureHandlerConfig, configureRelations, setReanimatedAvailable, + setShouldPreventRecognizers, flushOperations, install, } as const; diff --git a/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts b/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts index c40798482b..2db622592e 100644 --- a/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts +++ b/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts @@ -25,6 +25,7 @@ export interface Spec extends TurboModule { dropGestureHandler: (handlerTag: Double) => void; flushOperations: () => void; setReanimatedAvailable: (isAvailable: boolean) => void; + setShouldPreventRecognizers: (shouldPreventRecognizers: boolean) => void; } export default TurboModuleRegistry.getEnforcing('RNGestureHandlerModule'); From 8fd0c116c80cda55994ebec9cd0d726756dac9d7 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 9 Apr 2026 16:20:01 +0200 Subject: [PATCH 02/18] Add on Android --- .../docs/fundamentals/root-view.mdx | 2 +- .../react/RNGestureHandlerModule.kt | 21 ++++++++++++++++++- .../react/RNGestureHandlerRootHelper.kt | 13 ++++++++++++ .../GestureHandlerRootView.android.tsx | 13 ++++++++++-- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx b/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx index dc8566d32c..939f8cb615 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx +++ b/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx @@ -58,4 +58,4 @@ This flag controls whether Gesture Handler cancels React Native JS responders wh - `true` (default): keeps the current behavior where RN touch handlers are cancelled after Gesture Handler activates. - `false`: disables this cancellation, so Gesture Handler callbacks and RN responder callbacks can run at the same time. -This option is currently available only on iOS. +This option is currently available on iOS and Android. diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt index 285f6b3fb8..cc6fdec093 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt @@ -30,6 +30,7 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : private var mHybridData: HybridData = initHybrid() private var isReanimatedAvailable = false private var uiRuntimeDecorated = false + private var shouldPreventRecognizers = true private val registry: RNGestureHandlerRegistry get() = registries[moduleId]!! @@ -130,7 +131,24 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : } @ReactMethod - override fun setShouldPreventRecognizers(shouldPreventRecognizers: Boolean) = Unit + override fun setShouldPreventRecognizers(shouldPreventRecognizers: Boolean) { + if (UiThreadUtil.isOnUiThread()) { + setShouldPreventRecognizersSync(shouldPreventRecognizers) + } else { + UiThreadUtil.runOnUiThread { + setShouldPreventRecognizersSync(shouldPreventRecognizers) + } + } + } + + private fun setShouldPreventRecognizersSync(shouldPreventRecognizers: Boolean) { + this.shouldPreventRecognizers = shouldPreventRecognizers + synchronized(roots) { + roots.forEach { root -> + root.setShouldPreventRecognizers(shouldPreventRecognizers) + } + } + } @DoNotStrip @Suppress("unused") @@ -196,6 +214,7 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : synchronized(roots) { assert(root !in roots) { "Root helper$root already registered" } roots.add(root) + root.setShouldPreventRecognizers(shouldPreventRecognizers) } } 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 fb327172be..b3ccbad578 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 @@ -20,6 +20,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: val rootView: ViewGroup private var shouldIntercept = false private var passingTouch = false + private var shouldPreventRecognizers = true init { val registry = @@ -94,6 +95,11 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: override fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) = handleEvent(event) override fun onCancel() { + if (!shouldPreventRecognizers) { + shouldIntercept = false + return + } + shouldIntercept = true val time = SystemClock.uptimeMillis() val event = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0).apply { @@ -106,6 +112,13 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: } } + fun setShouldPreventRecognizers(shouldPreventRecognizers: Boolean) { + this.shouldPreventRecognizers = shouldPreventRecognizers + if (!shouldPreventRecognizers) { + shouldIntercept = false + } + } + fun requestDisallowInterceptTouchEvent() { // If this method gets called it means that some native view is attempting to grab lock for // touch event delivery. In that case we cancel all gesture recognizers diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx index fd49ebc313..814f32a42a 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx @@ -2,9 +2,14 @@ import * as React from 'react'; import { PropsWithChildren } from 'react'; import { StyleSheet } from 'react-native'; import GestureHandlerRootViewContext from '../GestureHandlerRootViewContext'; +import RNGestureHandlerModule from '../RNGestureHandlerModule'; import type { RootViewNativeProps } from '../specs/RNGestureHandlerRootViewNativeComponent'; import GestureHandlerRootViewNativeComponent from '../specs/RNGestureHandlerRootViewNativeComponent'; +type RootViewConfigModule = { + setShouldPreventRecognizers?: (shouldPreventRecognizers: boolean) => void; +}; + export interface GestureHandlerRootViewProps extends PropsWithChildren { preventRecognizers?: boolean; @@ -12,10 +17,14 @@ export interface GestureHandlerRootViewProps export default function GestureHandlerRootView({ style, - preventRecognizers, + preventRecognizers = true, ...rest }: GestureHandlerRootViewProps) { - void preventRecognizers; + React.useEffect(() => { + ( + RNGestureHandlerModule as RootViewConfigModule + ).setShouldPreventRecognizers?.(preventRecognizers); + }, [preventRecognizers]); return ( From 1f123b1ffeda43cdcdadb512cd7d5298318a6a69 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 10 Apr 2026 11:09:51 +0200 Subject: [PATCH 03/18] pass preventRecognizers per Android root view --- .../react/RNGestureHandlerModule.kt | 21 +------------------ .../react/RNGestureHandlerRootHelper.kt | 6 +++--- .../react/RNGestureHandlerRootView.kt | 7 +++++++ .../react/RNGestureHandlerRootViewManager.kt | 5 +++++ 4 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt index cc6fdec093..285f6b3fb8 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt @@ -30,7 +30,6 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : private var mHybridData: HybridData = initHybrid() private var isReanimatedAvailable = false private var uiRuntimeDecorated = false - private var shouldPreventRecognizers = true private val registry: RNGestureHandlerRegistry get() = registries[moduleId]!! @@ -131,24 +130,7 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : } @ReactMethod - override fun setShouldPreventRecognizers(shouldPreventRecognizers: Boolean) { - if (UiThreadUtil.isOnUiThread()) { - setShouldPreventRecognizersSync(shouldPreventRecognizers) - } else { - UiThreadUtil.runOnUiThread { - setShouldPreventRecognizersSync(shouldPreventRecognizers) - } - } - } - - private fun setShouldPreventRecognizersSync(shouldPreventRecognizers: Boolean) { - this.shouldPreventRecognizers = shouldPreventRecognizers - synchronized(roots) { - roots.forEach { root -> - root.setShouldPreventRecognizers(shouldPreventRecognizers) - } - } - } + override fun setShouldPreventRecognizers(shouldPreventRecognizers: Boolean) = Unit @DoNotStrip @Suppress("unused") @@ -214,7 +196,6 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : synchronized(roots) { assert(root !in roots) { "Root helper$root already registered" } roots.add(root) - root.setShouldPreventRecognizers(shouldPreventRecognizers) } } 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 b3ccbad578..36aeb30da1 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 @@ -112,9 +112,9 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: } } - fun setShouldPreventRecognizers(shouldPreventRecognizers: Boolean) { - this.shouldPreventRecognizers = shouldPreventRecognizers - if (!shouldPreventRecognizers) { + fun setPreventRecognizers(preventRecognizers: Boolean) { + shouldPreventRecognizers = preventRecognizers + if (!preventRecognizers) { shouldIntercept = false } } 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 79167e9c27..a33add9e82 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 @@ -17,6 +17,7 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { private var moduleId: Int = -1 private var rootViewEnabled = false private var unstableForceActive = false + private var preventRecognizers = true private var rootHelper: RNGestureHandlerRootHelper? = null // TODO: resettable lateinit override fun onAttachedToWindow() { @@ -30,6 +31,7 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { } if (rootViewEnabled && rootHelper == null) { rootHelper = RNGestureHandlerRootHelper(context as ReactContext, this, moduleId) + rootHelper?.setPreventRecognizers(preventRecognizers) } } @@ -75,6 +77,11 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { this.unstableForceActive = active } + fun setPreventRecognizers(preventRecognizers: Boolean) { + this.preventRecognizers = preventRecognizers + rootHelper?.setPreventRecognizers(preventRecognizers) + } + companion object { private fun hasGestureHandlerEnabledRootView(viewGroup: ViewGroup): Boolean { UiThreadUtil.assertOnUiThread() diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt index 8292b72a73..56036bf6c7 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt @@ -44,6 +44,11 @@ class RNGestureHandlerRootViewManager : view.setUnstableForceActive(active) } + @ReactProp(name = "preventRecognizers") + override fun setPreventRecognizers(view: RNGestureHandlerRootView, preventRecognizers: Boolean) { + view.setPreventRecognizers(preventRecognizers) + } + /** * The following event configuration is necessary even if you are not using * GestureHandlerRootView component directly. From b939be17f4ed3cba9139285a893e03b6dafc0bd4 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 10 Apr 2026 17:29:10 +0200 Subject: [PATCH 04/18] preventRecognizers per gesture detector --- apps/common-app/src/new_api/index.tsx | 5 + .../tests/rnResponderCancellation/index.tsx | 36 ++--- .../rnResponderCancellationPerRoot/index.tsx | 145 ++++++++++++++++++ .../docs/fundamentals/gesture-detector.mdx | 124 ++++++++------- .../docs/fundamentals/root-view.mdx | 13 -- .../apple/RNGestureHandler.h | 1 + .../apple/RNGestureHandler.mm | 7 + .../apple/RNGestureHandlerManager.h | 2 - .../apple/RNGestureHandlerManager.mm | 11 -- .../apple/RNGestureHandlerModule.mm | 6 - .../apple/RNRootViewGestureRecognizer.h | 1 - .../apple/RNRootViewGestureRecognizer.m | 4 +- .../src/RNGestureHandlerModule.web.ts | 3 - .../src/RNGestureHandlerModule.windows.ts | 3 - .../GestureHandlerRootView.android.tsx | 16 +- .../src/components/GestureHandlerRootView.tsx | 19 +-- .../components/GestureHandlerRootView.web.tsx | 7 +- .../gestures/GestureDetector/index.tsx | 24 +++ .../gestures/GestureDetector/utils.ts | 1 + .../src/mocks/module.tsx | 2 - .../src/specs/NativeRNGestureHandlerModule.ts | 1 - .../src/v3/detectors/NativeDetector.tsx | 9 ++ .../src/v3/detectors/common.ts | 1 + .../src/v3/hooks/utils/propsWhiteList.ts | 1 + .../src/v3/types/ConfigTypes.ts | 1 + 25 files changed, 280 insertions(+), 163 deletions(-) create mode 100644 apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index faa2905271..48ddce2d0b 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -45,6 +45,7 @@ import NestedRootViewExample from './tests/nestedRootView'; import NestedPressablesExample from './tests/nestedPressables'; import PressableExample from './tests/pressable'; import RNResponderCancellationExample from './tests/rnResponderCancellation'; +import RNResponderCancellationPerRootExample from './tests/rnResponderCancellationPerRoot'; import { ExamplesSection } from '../common'; import EmptyExample from '../empty'; @@ -136,6 +137,10 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ name: 'RN responder cancellation', component: RNResponderCancellationExample, }, + { + name: 'RN responder cancellation (per detector)', + component: RNResponderCancellationPerRootExample, + }, ], }, ]; diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx index 1988cb2b9a..5ccb949da0 100644 --- a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx +++ b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx @@ -1,10 +1,6 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { StyleSheet, Switch, Text, View } from 'react-native'; -import { - Gesture, - GestureDetector, - GestureHandlerRootView, -} from 'react-native-gesture-handler'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { COLORS, Feedback, @@ -44,22 +40,22 @@ export default function RNResponderCancellationExample() { ); return ( - + RN responder cancellation Toggle preventRecognizers and drag inside the box to compare behavior. - - preventRecognizers + + preventRecognizers - + { @@ -98,7 +94,7 @@ export default function RNResponderCancellationExample() { ))} - + ); } @@ -122,23 +118,23 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - row: { + touchAreaLabel: { + color: COLORS.NAVY, + fontWeight: '700', + fontSize: 18, + }, + settingsRow: { width: '100%', maxWidth: 340, flexDirection: 'row', - justifyContent: 'space-between', alignItems: 'center', + justifyContent: 'space-between', }, - rowLabel: { + settingsLabel: { color: COLORS.NAVY, fontSize: 14, fontWeight: '600', }, - touchAreaLabel: { - color: COLORS.NAVY, - fontWeight: '700', - fontSize: 18, - }, logContainer: { width: '100%', maxWidth: 380, diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx new file mode 100644 index 0000000000..355d0817f7 --- /dev/null +++ b/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx @@ -0,0 +1,145 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { COLORS, commonStyles } from '../../../common'; + +const MAX_EVENTS = 5; + +type PanelProps = { + title: string; + preventRecognizers: boolean; +}; + +function EventPanel({ title, preventRecognizers }: PanelProps) { + const sequenceRef = useRef(0); + const [events, setEvents] = useState([]); + + const pushEvent = (label: string) => { + sequenceRef.current += 1; + const event = `${sequenceRef.current}. ${label}`; + setEvents((prev) => [event, ...prev].slice(0, MAX_EVENTS)); + }; + + const panGesture = useMemo( + () => + Gesture.Pan() + .minDistance(12) + .runOnJS(true) + .onStart(() => { + pushEvent('GH pan ACTIVE'); + }) + .onFinalize(() => { + pushEvent('GH pan finalize'); + }), + [] + ); + + return ( + + {title} + + preventRecognizers={String(preventRecognizers)} + + + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={() => { + pushEvent('RN grant'); + }} + onResponderMove={() => { + pushEvent('RN move'); + }} + onResponderRelease={() => { + pushEvent('RN release'); + }} + onResponderTerminate={() => { + pushEvent('RN terminate'); + }} + onResponderTerminationRequest={() => true}> + Drag here + + + + {events.map((event) => ( + + {event} + + ))} + + + ); +} + +export default function RNResponderCancellationPerRootExample() { + return ( + + + Per-detector responder cancellation + + + Compare both sections. Top should terminate RN responder after GH + activates, bottom should keep RN callbacks running. + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 12, + paddingTop: 14, + paddingBottom: 20, + gap: 10, + backgroundColor: COLORS.offWhite, + }, + panelContainer: { + borderRadius: 14, + borderWidth: 1, + borderColor: '#cbd8ea', + padding: 10, + backgroundColor: '#fdfefe', + gap: 8, + }, + panelTitle: { + fontSize: 16, + fontWeight: '700', + color: COLORS.NAVY, + }, + panelSubtitle: { + fontSize: 12, + color: '#495868', + }, + touchArea: { + minHeight: 120, + borderWidth: 2, + borderRadius: 12, + borderColor: COLORS.NAVY, + backgroundColor: '#d8ebff', + justifyContent: 'center', + alignItems: 'center', + }, + touchAreaLabel: { + color: COLORS.NAVY, + fontWeight: '700', + }, + logContainer: { + minHeight: 76, + borderWidth: 1, + borderColor: '#d5dbe6', + borderRadius: 8, + padding: 8, + backgroundColor: '#ffffff', + }, + logLine: { + fontSize: 12, + color: '#2c3a4f', + fontFamily: 'Courier', + }, +}); diff --git a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx index b493219d90..87bfa6732a 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx +++ b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx @@ -16,11 +16,12 @@ When using hook API, you can also integrate it directly with the [Animated API]( :::danger -#### Nesting Gesture Detectors +#### Nesting Gesture Detectors Because `GestureDetector` supports both the hook API and the builder pattern, it is important to avoid nesting detectors that use different APIs, as this can result in undefined behavior. #### Reusing Gestures + Using the same instance of a gesture across multiple Gesture Detectors may result in undefined behavior. ::: @@ -93,35 +94,34 @@ export default function App() { }, }); - return ( - - - - - {}} - /> - - - - - ); +return ( + + + + + {}} +/> + + + + +); } const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, +container: { +flex: 1, +alignItems: 'center', +justifyContent: 'center', +}, }); `}/> - #### Text You can use `VirtualGestureDetector` to add gesture handling to specific parts of a `Text` component. @@ -147,39 +147,38 @@ export default function App() { }, }); - const nestedTap = useTapGesture({ - onDeactivate: () => { - console.log('Tapped on nested part!'); - }, - }); +const nestedTap = useTapGesture({ +onDeactivate: () => { +console.log('Tapped on nested part!'); +}, +}); - return ( - - - - Nested text - - - try tapping on this part. - - - This part is not special :c - - - - ); +return ( + + + +Nested text + + +try tapping on this part. + + +This part is not special :c + + + +); } const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'space-around', - }, +container: { +flex: 1, +alignItems: 'center', +justifyContent: 'space-around', +}, }); `}/> - ## Properties ### gesture @@ -190,9 +189,7 @@ gesture: SingleGesture | ComposedGesture; A gesture object containing the configuration and callbacks. Can be any of the base gestures or any [`ComposedGesture`](/docs/fundamentals/gesture-composition). - -### userSelect - +### userSelect ```ts userSelect: 'none' | 'auto' | 'text'; @@ -200,9 +197,7 @@ userSelect: 'none' | 'auto' | 'text'; This parameter allows specifying which `userSelect` property should be applied to the underlying view. Default value is set to `"none"`. - -### touchAction - +### touchAction ```ts touchAction: TouchAction; @@ -210,12 +205,23 @@ touchAction: TouchAction; This parameter allows specifying which `touchAction` property should be applied to the underlying view. Supports all CSS [touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/touch-action) values. Default value is set to `"none"`. - -### enableContextMenu - +### enableContextMenu ```ts enableContextMenu: boolean; ``` Specifies whether the context menu should be enabled after clicking on the underlying view with the right mouse button. Default value is set to `false`. + + + ### preventRecognizers + + +```ts +preventRecognizers?: boolean; +``` + +Controls whether activating a Gesture Handler recognizer should cancel React Native JS responders. + +- `true` (default): keeps current behavior where RN touch handlers are cancelled after Gesture Handler activates. +- `false`: disables this cancellation, so Gesture Handler callbacks and RN responder callbacks can run at the same time. diff --git a/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx b/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx index 939f8cb615..c82da96006 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx +++ b/packages/docs-gesture-handler/docs/fundamentals/root-view.mdx @@ -46,16 +46,3 @@ unstable_forceActive?: boolean; ``` If you're having trouble with gestures not working when inside a component provided by a third-party library, even though you've wrapped the entry point with ``, you can try adding another `` closer to the place the gestures are defined. This way, you can prevent Android from canceling relevant gestures when one of the native views tries to grab lock for delivering touch events. - -## preventRecognizers - -```ts -preventRecognizers?: boolean; -``` - -This flag controls whether Gesture Handler cancels React Native JS responders when one of Gesture Handler recognizers activates. - -- `true` (default): keeps the current behavior where RN touch handlers are cancelled after Gesture Handler activates. -- `false`: disables this cancellation, so Gesture Handler callbacks and RN responder callbacks can run at the same time. - -This option is currently available on iOS and Android. diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.h b/packages/react-native-gesture-handler/apple/RNGestureHandler.h index 8598a65fe6..216868fc16 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.h @@ -81,6 +81,7 @@ @property (nonatomic) BOOL shouldCancelWhenOutside; @property (nonatomic) BOOL needsPointerData; @property (nonatomic) BOOL manualActivation; +@property (nonatomic) BOOL preventRecognizers; @property (nonatomic) BOOL dispatchesAnimatedEvents; @property (nonatomic) BOOL dispatchesReanimatedEvents; @property (nonatomic, weak, nullable) RNGHUIView *hostDetectorView; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index 4987126829..738be14659 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -1,3 +1,4 @@ + #import "RNGestureHandler.h" #import "RNManualActivationRecognizer.h" @@ -105,6 +106,7 @@ - (void)resetConfig self.testID = nil; self.manualActivation = NO; _shouldCancelWhenOutside = NO; + _preventRecognizers = YES; _hitSlop = RNGHHitSlopEmpty; _needsPointerData = NO; _dispatchesAnimatedEvents = NO; @@ -164,6 +166,11 @@ - (void)updateConfig:(NSDictionary *)config self.manualActivation = [RCTConvert BOOL:prop]; } + prop = config[@"preventRecognizers"]; + if (prop != nil) { + _preventRecognizers = [RCTConvert BOOL:prop]; + } + prop = config[@"hitSlop"]; if ([prop isKindOfClass:[NSNumber class]]) { _hitSlop.left = _hitSlop.right = _hitSlop.top = _hitSlop.bottom = [prop doubleValue]; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.h index 3920aa2f0f..970de2d143 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.h @@ -44,6 +44,4 @@ - (nullable RNGHUIView *)viewForReactTag:(nonnull NSNumber *)reactTag; - (void)reattachHandlersIfNeeded; - -- (void)setShouldPreventRecognizers:(BOOL)shouldPreventRecognizers; @end diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm index 9a4c40b27e..671f80c1ce 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm @@ -49,7 +49,6 @@ @implementation RNGestureHandlerManager { NSHashTable *_rootViewGestureRecognizers; NSMutableDictionary *_attachRetryCounter; NSMutableSet *_droppedHandlers; - BOOL _shouldPreventRecognizers; RCTModuleRegistry *_moduleRegistry; RCTViewRegistry *_viewRegistry; id _eventDispatcher; @@ -78,7 +77,6 @@ - (void)initCommonProps _rootViewGestureRecognizers = [NSHashTable hashTableWithOptions:NSPointerFunctionsWeakMemory]; _attachRetryCounter = [[NSMutableDictionary alloc] init]; _droppedHandlers = [NSMutableSet set]; - _shouldPreventRecognizers = YES; } - (void)createGestureHandler:(NSString *)handlerName tag:(NSNumber *)handlerTag config:(NSDictionary *)config @@ -306,7 +304,6 @@ - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childVie RCTLifecycleLog(@"[GESTURE HANDLER] Initialize gesture handler for view %@", touchHandlerView); RNRootViewGestureRecognizer *recognizer = [RNRootViewGestureRecognizer new]; - recognizer.preventRecognizers = _shouldPreventRecognizers; recognizer.delegate = self; #if !TARGET_OS_OSX touchHandlerView.userInteractionEnabled = YES; @@ -315,14 +312,6 @@ - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childVie [_rootViewGestureRecognizers addObject:recognizer]; } -- (void)setShouldPreventRecognizers:(BOOL)shouldPreventRecognizers -{ - _shouldPreventRecognizers = shouldPreventRecognizers; - for (RNRootViewGestureRecognizer *recognizer in _rootViewGestureRecognizers) { - recognizer.preventRecognizers = shouldPreventRecognizers; - } -} - - (void)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer didActivateInViewWithTouchHandler:(RNGHUIView *)viewWithTouchHandler { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm index 67e6def763..d284a4d6b4 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm @@ -194,12 +194,6 @@ - (void)setReanimatedAvailable:(BOOL)isAvailable _isReanimatedAvailable = isAvailable; } -- (void)setShouldPreventRecognizers:(BOOL)shouldPreventRecognizers -{ - RNGestureHandlerManager *manager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; - [manager setShouldPreventRecognizers:shouldPreventRecognizers]; -} - - (void)setGestureState:(int)state forHandler:(int)handlerTag { if (RCTIsMainQueue()) { diff --git a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.h b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.h index bfddd85540..a57dfc6661 100644 --- a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.h +++ b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.h @@ -11,7 +11,6 @@ @interface RNRootViewGestureRecognizer : UIGestureRecognizer @property (nullable, nonatomic, weak) id delegate; -@property (nonatomic) BOOL preventRecognizers; - (void)blockOtherRecognizers; diff --git a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m index 391a215723..e62b3515f9 100644 --- a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m +++ b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m @@ -26,11 +26,9 @@ - (instancetype)init if (self = [super init]) { self.delaysTouchesEnded = NO; self.delaysTouchesBegan = NO; - self.preventRecognizers = YES; } #else self = [super init]; - self.preventRecognizers = YES; #endif return self; } @@ -58,7 +56,7 @@ - (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)preventingGestu // to send an info to JS so that it cancells all JS responders, as long as the preventing // recognizer is from Gesture Handler, otherwise we might break some interactions RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:preventingGestureRecognizer]; - if (self.preventRecognizers && handler != nil) { + if (handler != nil && handler.preventRecognizers) { [self.delegate gestureRecognizer:preventingGestureRecognizer didActivateInViewWithTouchHandler:self.view]; } diff --git a/packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts b/packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts index 1bfd9b573f..c30bfd7729 100644 --- a/packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts +++ b/packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts @@ -92,7 +92,4 @@ export default { setReanimatedAvailable(_isAvailable: boolean) { // No-op on web }, - setShouldPreventRecognizers(_shouldPreventRecognizers: boolean) { - // No-op on web - }, }; diff --git a/packages/react-native-gesture-handler/src/RNGestureHandlerModule.windows.ts b/packages/react-native-gesture-handler/src/RNGestureHandlerModule.windows.ts index 16f3d96a06..cf71b49397 100644 --- a/packages/react-native-gesture-handler/src/RNGestureHandlerModule.windows.ts +++ b/packages/react-native-gesture-handler/src/RNGestureHandlerModule.windows.ts @@ -56,7 +56,4 @@ export default { flushOperations() { // NO-OP }, - setShouldPreventRecognizers(_shouldPreventRecognizers: boolean) { - // NO-OP - }, }; diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx index 814f32a42a..4a07468d47 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.android.tsx @@ -2,30 +2,16 @@ import * as React from 'react'; import { PropsWithChildren } from 'react'; import { StyleSheet } from 'react-native'; import GestureHandlerRootViewContext from '../GestureHandlerRootViewContext'; -import RNGestureHandlerModule from '../RNGestureHandlerModule'; import type { RootViewNativeProps } from '../specs/RNGestureHandlerRootViewNativeComponent'; import GestureHandlerRootViewNativeComponent from '../specs/RNGestureHandlerRootViewNativeComponent'; -type RootViewConfigModule = { - setShouldPreventRecognizers?: (shouldPreventRecognizers: boolean) => void; -}; - export interface GestureHandlerRootViewProps - extends PropsWithChildren { - preventRecognizers?: boolean; -} + extends PropsWithChildren {} export default function GestureHandlerRootView({ style, - preventRecognizers = true, ...rest }: GestureHandlerRootViewProps) { - React.useEffect(() => { - ( - RNGestureHandlerModule as RootViewConfigModule - ).setShouldPreventRecognizers?.(preventRecognizers); - }, [preventRecognizers]); - return ( void; -}; - export interface GestureHandlerRootViewProps - extends PropsWithChildren { - preventRecognizers?: boolean; -} + extends PropsWithChildren {} export default function GestureHandlerRootView({ style, - preventRecognizers = true, ...rest }: GestureHandlerRootViewProps) { - React.useEffect(() => { - console.log('Setting preventRecognizers to', preventRecognizers); - console.log(RNGestureHandlerModule.setShouldPreventRecognizers); - - ( - RNGestureHandlerModule as RootViewConfigModule - ).setShouldPreventRecognizers?.(preventRecognizers); - }, [preventRecognizers]); - return ( diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.web.tsx index 7e4d542f5c..965f29f4af 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerRootView.web.tsx @@ -4,17 +4,12 @@ import { View, ViewProps, StyleSheet } from 'react-native'; import GestureHandlerRootViewContext from '../GestureHandlerRootViewContext'; export interface GestureHandlerRootViewProps - extends PropsWithChildren { - preventRecognizers?: boolean; -} + extends PropsWithChildren {} export default function GestureHandlerRootView({ style, - preventRecognizers, ...rest }: GestureHandlerRootViewProps) { - void preventRecognizers; - return ( diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx index 52a4d22499..6190099442 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx @@ -1,5 +1,6 @@ /* eslint-disable react/no-unused-prop-types */ import React, { useEffect, useMemo, useRef } from 'react'; +import { Platform } from 'react-native'; import findNodeHandle from '../../../findNodeHandle'; import { GestureType } from '../gesture'; import { UserSelect, TouchAction } from '../../gestureHandlerCommon'; @@ -39,6 +40,20 @@ function propagateDetectorConfig( } } +function propagatePreventRecognizersConfig( + preventRecognizers: boolean, + gesture: ComposedGesture | GestureType +) { + if (Platform.OS !== 'ios') { + return; + } + + for (const g of gesture.toGestureArray()) { + const config = g.config as { [key: string]: unknown }; + config.preventRecognizers = preventRecognizers; + } +} + export interface GestureDetectorProps { children?: React.ReactNode; /** @@ -66,6 +81,11 @@ export interface GestureDetectorProps { * Supports all CSS touch-action values (e.g. `"none"`, `"pan-y"`). Default value is set to `"none"`. */ touchAction?: TouchAction; + /** + * Controls whether activating a Gesture Handler recognizer should cancel RN JS responders. + * Default is `true`. + */ + preventRecognizers?: boolean; } /** @@ -91,6 +111,10 @@ export const GestureDetector = (props: GestureDetectorProps) => { // Gesture config should be wrapped with useMemo to prevent unnecessary re-renders const gestureConfig = props.gesture; propagateDetectorConfig(props, gestureConfig); + propagatePreventRecognizersConfig( + props.preventRecognizers ?? true, + gestureConfig + ); const gesturesToAttach = useMemo( () => gestureConfig.toGestureArray(), diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts index 5a802135a2..0fead1d69b 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts @@ -26,6 +26,7 @@ import { export const ALLOWED_PROPS = [ ...baseGestureHandlerWithDetectorProps, + 'preventRecognizers', ...tapGestureHandlerProps, ...panGestureHandlerProps, ...panGestureHandlerCustomNativeProps, diff --git a/packages/react-native-gesture-handler/src/mocks/module.tsx b/packages/react-native-gesture-handler/src/mocks/module.tsx index 1e7abb449a..c6b957e1d0 100644 --- a/packages/react-native-gesture-handler/src/mocks/module.tsx +++ b/packages/react-native-gesture-handler/src/mocks/module.tsx @@ -10,7 +10,6 @@ const updateGestureHandlerConfig = NOOP; const flushOperations = NOOP; const configureRelations = NOOP; const setReanimatedAvailable = NOOP; -const setShouldPreventRecognizers = NOOP; const install = NOOP; export default { @@ -21,7 +20,6 @@ export default { updateGestureHandlerConfig, configureRelations, setReanimatedAvailable, - setShouldPreventRecognizers, flushOperations, install, } as const; diff --git a/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts b/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts index 2db622592e..c40798482b 100644 --- a/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts +++ b/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts @@ -25,7 +25,6 @@ export interface Spec extends TurboModule { dropGestureHandler: (handlerTag: Double) => void; flushOperations: () => void; setReanimatedAvailable: (isAvailable: boolean) => void; - setShouldPreventRecognizers: (shouldPreventRecognizers: boolean) => void; } export default TurboModuleRegistry.getEnforcing('RNGestureHandlerModule'); diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index 37614db708..28d3e77b72 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -20,6 +20,7 @@ export function NativeDetector< touchAction, userSelect, enableContextMenu, + preventRecognizers = true, }: NativeDetectorProps) { const NativeDetectorComponent = gesture.config.dispatchesAnimatedEvents ? AnimatedNativeDetector @@ -28,6 +29,14 @@ export function NativeDetector< : HostGestureDetector; ensureNativeDetectorComponent(NativeDetectorComponent); + + if ( + (Platform.OS === 'ios' || Platform.OS === 'android') && + !isComposedGesture(gesture) + ) { + gesture.config.preventRecognizers = preventRecognizers; + } + configureRelations(gesture); const handlerTags = useMemo(() => { diff --git a/packages/react-native-gesture-handler/src/v3/detectors/common.ts b/packages/react-native-gesture-handler/src/v3/detectors/common.ts index 77f036cb6e..8a2b8cc4c8 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/common.ts +++ b/packages/react-native-gesture-handler/src/v3/detectors/common.ts @@ -16,6 +16,7 @@ interface CommonGestureDetectorProps { userSelect?: UserSelect | undefined; touchAction?: TouchAction | undefined; enableContextMenu?: boolean | undefined; + preventRecognizers?: boolean | undefined; } export interface NativeDetectorProps< diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts index 77d4ab103c..930a28090c 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts @@ -23,6 +23,7 @@ const CommonConfig = new Set([ 'mouseButton', 'testID', 'cancelsTouchesInView', + 'preventRecognizers', 'manualActivation', ]); diff --git a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts index a86315788d..205bc0ddb9 100644 --- a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts @@ -79,6 +79,7 @@ export type CommonGestureConfig = { activeCursor?: ActiveCursor | undefined; mouseButton?: MouseButton | undefined; cancelsTouchesInView?: boolean | undefined; + preventRecognizers?: boolean | undefined; manualActivation?: boolean | undefined; }, ActiveCursor | MouseButton From dc9270f3211ed97e043fce14cae968c574021414 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Tue, 14 Apr 2026 17:51:20 +0200 Subject: [PATCH 05/18] prevent recognizers Android --- ANDROID_ARCHITECTURE.md | 247 ++++++++++++++++++ .../gesturehandler/core/GestureHandler.kt | 7 + .../core/GestureHandlerOrchestrator.kt | 15 ++ .../react/RNGestureHandlerModule.kt | 2 - .../react/RNGestureHandlerRootHelper.kt | 37 +-- .../react/RNGestureHandlerRootView.kt | 7 - .../react/RNGestureHandlerRootViewManager.kt | 5 - .../gestures/GestureDetector/index.tsx | 5 - 8 files changed, 281 insertions(+), 44 deletions(-) create mode 100644 ANDROID_ARCHITECTURE.md diff --git a/ANDROID_ARCHITECTURE.md b/ANDROID_ARCHITECTURE.md new file mode 100644 index 0000000000..280f9314ab --- /dev/null +++ b/ANDROID_ARCHITECTURE.md @@ -0,0 +1,247 @@ +# Android Architecture: React Native Gesture Handler + +--- + +## 1. Native Module Setup + +**Entry point**: `RNGestureHandlerPackage` extends `BaseReactPackage` and registers: +- `RNGestureHandlerModule` — the main TurboModule (annotated `@ReactModule(name = "RNGestureHandlerModule")`) +- `RNGestureHandlerRootViewManager`, `RNGestureHandlerButtonViewManager`, `RNGestureHandlerDetectorViewManager` — view managers registered on-demand + +**`RNGestureHandlerModule`** is the JS-facing API. Its main responsibilities are: +- `createGestureHandler(handlerName, handlerTag, config)` — instantiate a handler class +- `attachGestureHandler(handlerTag, viewTag, actionType)` — bind handler to a native view +- `updateGestureHandler(handlerTag, config)` — reconfigure a handler +- `dropGestureHandler(handlerTag)` — destroy a handler + +It implements `TurboModuleWithJSIBindings` for JSI/C++ interop (loading `gesturehandler` native library via `SoLoader`). One registry per React root is stored in `registries: MutableMap`. + +--- + +## 2. Handler Storage + +**`RNGestureHandlerRegistry`** holds all the data structures: +- `SparseArray` indexed by handler tag (integer, assigned from JS) +- `SparseArray` mapping handler tag → attached view tag +- `SparseArray>` mapping view tag → list of handlers on that view + +When `attachHandlerToView()` is called: +1. Handler is detached from any previous view +2. Registered to the target view tag's list +3. For detector-type handlers, the host detector view reference is set + +--- + +## 3. Root View & Touch Interception + +**`RNGestureHandlerRootView`** extends `ReactViewGroup`. On `dispatchTouchEvent()` and `dispatchGenericMotionEvent()`, it delegates to `RNGestureHandlerRootHelper` before calling `super`. Returns `true` (consumed) if the helper says to intercept. + +**`RNGestureHandlerRootHelper`** is the real coordinator: +- Creates a `GestureHandlerOrchestrator` with the root view, registry, and config helper +- Registers a synthetic **`RootViewGestureHandler`** (with tag = `-wrappedViewTag`) to monitor the root-level gesture +- On each touch event: calls `orchestrator.onTouchEvent(event)`, then returns `shouldIntercept` +- `shouldIntercept` is set to `true` when `RootViewGestureHandler.onCancel()` is triggered, signaling that a native child grabbed the touch lock + +**`RootViewGestureHandler`** (private inner class): +- Begins on `ACTION_DOWN`, ends on `ACTION_UP` +- `shouldBeCancelledBy(handler)` returns `handler.preventRecognizers` — this is the key hook for the `preventRecognizers` feature +- When cancelled, calls `rootView.onChildStartedNativeGesture()` to notify React + +--- + +## 4. Gesture Detection Pipeline + +Full event flow from a raw `MotionEvent`: + +``` +Android MotionEvent + ↓ +RNGestureHandlerRootView.dispatchTouchEvent() + ↓ +RNGestureHandlerRootHelper.dispatchTouchEvent() + ↓ +GestureHandlerOrchestrator.onTouchEvent() + ├─ ACTION_DOWN / POINTER_DOWN / HOVER_MOVE + │ └─ extractGestureHandlers(event) + │ └─ traverseWithPointerEvents() — walks view hierarchy + │ └─ recordViewHandlersForPointer() — registers handlers whose views + │ contain the touch point + ├─ ACTION_CANCEL → cancelAll() + └─ deliverEventToGestureHandlers(event) + └─ For each handler (sorted by activation priority): + ├─ transformEventToViewCoords() — transform to handler's local space + ├─ handler.updatePointerData() — if needsPointerData + ├─ handler.handle(event, source) — core processing, calls onHandle() + └─ handler.dispatchHandlerUpdate() — if currently ACTIVE +``` + +**View hierarchy traversal** in `extractGestureHandlers()`: +- Starts at the wrapper view, iterates children in **reverse drawing order** (topmost first) +- Skips clipped children or those outside bounds or with `pointerEvents=NONE` +- Transforms coordinates into each child's local space recursively +- Stops at nested `RNGestureHandlerRootView` to prevent double-processing +- Handles `ReactCompoundView` for compound children + +--- + +## 5. GestureHandler Base Class State Machine + +**States** (`GestureHandler.kt`): +``` +UNDETERMINED (0) + ↓ begin() + BEGAN (2) + ↓ activate() + ACTIVE (4) + ↓ end() + END (5) + +From any of UNDETERMINED/BEGAN/ACTIVE: + → FAILED (1) via fail() + → CANCELLED (3) via cancel() +``` + +**Key properties**: +- `state` — current state (private setter) +- `isActive`, `isAwaiting` — orchestrator-managed flags +- `activationIndex` — order of activation; used to prioritize event delivery +- `trackedPointerIDs[]` — maps Android pointer IDs to handler-local sequential IDs +- `trackedPointersCount` — active pointer count +- `x`, `y` — current touch position in handler's local space +- `preventRecognizers` — whether this handler blocks the root view's native gesture (default `true`) + +**Core methods**: +- `handle(transformedEvent, sourceEvent)` — entry point from orchestrator; routes to `onHandle()` in subclasses +- `prepare(view, orchestrator)` — called once when first recording the handler for a view +- `reset()` — clears all state after gesture lifecycle completes +- `shouldRecognizeSimultaneously(handler)`, `shouldBeCancelledBy(handler)`, `shouldRequireToWaitForFailure(handler)` — relationship queries + +**Pointer tracking**: Up to 17 simultaneous pointers (`MAX_POINTERS_COUNT`). Android pointer IDs are remapped to local sequential IDs 0–16 via `startTrackingPointer()` / `stopTrackingPointer()`. + +**Hit slop**: `isWithinBounds()` supports per-side padding plus `width`/`height` constraints on the touch target. + +--- + +## 6. Concrete Handler Implementations + +| Handler | Activation Trigger | Key Config | +|---|---|---| +| `TapGestureHandler` | `ACTION_UP` within time/distance limits | `numberOfTaps`, `maxDurationMs`, `maxDelayMs`, `maxDelta[XY]` | +| `PanGestureHandler` | Minimum distance or velocity threshold | `activeOffset[XY]`, `failOffset[XY]`, `minVelocity[XY]`, `minPointers`, `maxPointers` | +| `LongPressGestureHandler` | Timeout after hold (default 500ms) | `minDurationMs`, `maxDist`, `numberOfPointers` | +| `PinchGestureHandler` | Two-finger scale via `ScaleGestureDetector` | — | +| `RotationGestureHandler` | Two-finger rotation via `RotationGestureDetector` | — | +| `FlingGestureHandler` | Minimum swipe velocity | `numberOfPointers`, `direction`, `minVelocity` | +| `HoverGestureHandler` | `ACTION_HOVER_*` events only (no touch) | — | +| `ManualGestureHandler` | Never auto-activates; JS-controlled | — | +| `NativeViewGestureHandler` | Wraps native views (ScrollView, Button, etc.) | — | + +Pan has stylus support (`StylusData` with pressure/tilt), optional `averageTouches` for iOS-like multi-pointer averaging, and can activate after a long-press delay. + +--- + +## 7. Handler Interactions + +**`RNGestureHandlerInteractionManager`** stores three relationship maps: + +| Relationship | Data Structure | Semantics | +|---|---|---| +| Simultaneous | `SparseArray` | Both handlers can be ACTIVE at the same time | +| WaitFor / RequireToFail | `waitForRelations: SparseArray` | Handler waits for another to FAIL before activating | +| Blocking | `blockingRelations: SparseArray` | Handler blocks others until it resolves | + +**Orchestrator enforcement** in `makeActive()`: +1. `hasOtherHandlerToWaitFor()` — if something must fail first, handler enters `isAwaiting=true` state +2. On each awaiting handler: when its blocker ends in `FAILED`/`CANCELLED` → re-try `tryActivate()`; if blocker ends in `END` → cancel the awaiting handler +3. When a handler becomes truly ACTIVE: call `shouldHandlerBeCancelledBy()` on all other handlers and cancel those that return `true` + +--- + +## 8. Event/Callback System + +**`RNGestureHandlerEventDispatcher`** routes events based on `actionType`: + +| Action Type | Routing | +|---|---| +| `ACTION_TYPE_REANIMATED_WORKLET` | Reanimated worklet via `sendEventForReanimated()` | +| `ACTION_TYPE_NATIVE_ANIMATED_EVENT` | Native animated driver | +| `ACTION_TYPE_JS_FUNCTION_OLD_API` | Device event via `RNGestureHandlerEvent` (old bridge) | +| `ACTION_TYPE_JS_FUNCTION_NEW_API` | Device event via `RNGestureHandlerEvent` (new API) | +| `ACTION_TYPE_NATIVE_DETECTOR` | Directly on the detector view | + +**Events dispatched**: +- **Handler update events** — fired while state == `ACTIVE`, contains current position, velocity, scale, etc. +- **State change events** — fired on every state transition: `onBegin`, `onStart` (ACTIVE), `onEnd`, `onFinalize` + +**Touch pointer events** (when `needsPointerData=true`): `RNGestureHandlerTouchEvent` carries `changedTouches` and `allTouches` arrays with per-pointer `{id, x, y, absoluteX, absoluteY}`. + +**Full callback chain**: +``` +Handler state change + → orchestrator.onHandlerStateChange() + → dispatchStateChange() + → onTouchEventListener.onStateChange() + → RNGestureHandlerEventDispatcher.dispatchStateChangeEvent() + → [route by actionType] → JS / Reanimated / NativeAnimated +``` + +--- + +## 9. GestureHandlerOrchestrator + +The orchestrator is the central coordinator. Key data structures: +- `gestureHandlers[]` — all handlers currently receiving events +- `awaitingHandlers[]` — handlers blocked waiting for another to fail +- `preparedHandlers[]` — snapshot copy used during event delivery to avoid concurrent modification + +**Event delivery priority** (`handlersComparator`): +1. Active handlers first (by `activationIndex`, earliest first) +2. Awaiting handlers next +3. Inactive handlers last + +**Coordinate transformation**: `transformEventToViewCoords()` recursively walks the parent chain from the handler's view up to the wrapper view, accounting for scroll offsets, view positions, and matrix transforms. + +**Handler lifecycle**: +1. First touch in view's bounds → `recordHandlerIfNotPresent()` → `handler.prepare(view, this)` +2. Events delivered each frame +3. Handler reaches terminal state (END/FAILED/CANCELLED) → removed from active list, `reset()` called + +--- + +## 10. Old vs. New Architecture + +The library is **hybrid**: it supports both architectures simultaneously. + +- **TurboModule / JSI**: `RNGestureHandlerModule` implements `TurboModuleWithJSIBindings`, exposes C++ bindings via JNI for direct synchronous calls (no bridge round-trip) +- **Old bridge**: Falls back to `ACTION_TYPE_JS_FUNCTION_OLD_API` event dispatch via the React Native bridge +- **Fabric**: `RNGestureHandlerDetectorViewManager` has Fabric-aware view creation paths +- **Reanimated 2**: Detected at runtime via `setReanimatedAvailable()`; worklet routing bypasses the bridge entirely +- **NativeAnimated**: Gesture values can drive animations on the native thread via `ACTION_TYPE_NATIVE_ANIMATED_EVENT` + +--- + +## 11. `preventRecognizers` Feature + +**`GestureHandler.preventRecognizers: Boolean`** (default `true`) controls whether, when a handler activates, it cancels the root view's synthetic gesture handler. + +**Mechanism**: +1. Handler becomes ACTIVE → orchestrator calls `makeActive()` → calls `shouldHandlerBeCancelledBy(handler)` on all registered handlers +2. `RootViewGestureHandler.shouldBeCancelledBy(handler)` returns `handler.preventRecognizers` +3. If `true`: root handler is cancelled → `onChildStartedNativeGesture()` called → React treats the touch as consumed by RN +4. If `false`: root handler stays alive → native views can still process the touch in parallel + +**Config key**: `KEY_PREVENT_RECOGNIZERS` in `updateConfig()`. Recent commits extended this to be configurable **per gesture detector** (`b939be17f`) and **per root view** (`1f123b1ff`). + +--- + +## Key Architectural Patterns + +| Pattern | Where Used | +|---|---| +| **State Machine** | Every `GestureHandler` subclass | +| **Registry** | `RNGestureHandlerRegistry` (tag → handler, view → handlers) | +| **Visitor** | Orchestrator traversing the view hierarchy | +| **Observer** | Handlers notify orchestrator of state changes | +| **Strategy** | `RNGestureHandlerInteractionManager` for interaction policies | +| **Adapter** | Event builders adapt handler data to different event types | +| **Lazy init** | Handlers only prepared when first receiving events | diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 066a923ea0..95bb7a6e70 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -83,6 +83,7 @@ open class GestureHandler { var needsPointerData = false var dispatchesAnimatedEvents = false var dispatchesReanimatedEvents = false + var preventRecognizers = true private var hitSlop: FloatArray? = null var eventCoalescingKey: Short = 0 @@ -137,6 +138,7 @@ open class GestureHandler { mouseButton = DEFAULT_MOUSE_BUTTON dispatchesAnimatedEvents = DEFAULT_DISPATCHES_ANIMATED_EVENTS dispatchesReanimatedEvents = DEFAULT_DISPATCHES_REANIMATED_EVENTS + preventRecognizers = DEFAULT_PREVENT_RECOGNIZERS } fun hasCommonPointers(other: GestureHandler): Boolean { @@ -961,6 +963,9 @@ open class GestureHandler { if (config.hasKey(KEY_TEST_ID)) { handler.testID = config.getString(KEY_TEST_ID) } + if (config.hasKey(KEY_PREVENT_RECOGNIZERS)) { + handler.preventRecognizers = config.getBoolean(KEY_PREVENT_RECOGNIZERS) + } } abstract fun createEventBuilder(handler: T): GestureHandlerEventDataBuilder @@ -983,6 +988,7 @@ open class GestureHandler { private const val KEY_HIT_SLOP_WIDTH = "width" private const val KEY_HIT_SLOP_HEIGHT = "height" private const val KEY_TEST_ID = "testID" + private const val KEY_PREVENT_RECOGNIZERS = "preventRecognizers" private fun handleHitSlopProperty(handler: GestureHandler, config: ReadableMap) { if (config.getType(KEY_HIT_SLOP) == ReadableType.Number) { @@ -1046,6 +1052,7 @@ open class GestureHandler { private const val DEFAULT_MOUSE_BUTTON = 0 private const val DEFAULT_DISPATCHES_ANIMATED_EVENTS = false private const val DEFAULT_DISPATCHES_REANIMATED_EVENTS = false + private const val DEFAULT_PREVENT_RECOGNIZERS = true const val STATE_UNDETERMINED = 0 const val STATE_FAILED = 1 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 d1d6e55b4d..bb7db69649 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 @@ -42,6 +42,9 @@ class GestureHandlerOrchestrator( private var finishedHandlersCleanupScheduled = false private var activationIndex = 0 + var onGestureActivated: ((GestureHandler) -> Unit)? = null + var onGestureDeactivated: ((GestureHandler) -> Unit)? = null + /** * Should be called from the view wrapper */ @@ -143,6 +146,14 @@ class GestureHandlerOrchestrator( /*package*/ fun onHandlerStateChange(handler: GestureHandler, newState: Int, prevState: Int) { handlingChangeSemaphore += 1 + + if (isFinished(newState) && handler.isActive && handler.preventRecognizers) { + // Check if there are any other active handlers that are preventing recognizers. + if (gestureHandlers.none { it !== handler && it.isActive && it.preventRecognizers }) { + onGestureDeactivated?.invoke(handler) + } + } + if (isFinished(newState)) { // We have to loop through copy in order to avoid modifying collection // while iterating over its elements @@ -228,6 +239,10 @@ class GestureHandlerOrchestrator( } cleanupAwaitingHandlers() + if (handler.preventRecognizers) { + onGestureActivated?.invoke(handler) + } + // At this point the waiting handler is allowed to activate, so we need to send BEGAN -> ACTIVE event // as it wasn't sent before. If handler has finished recognizing the gesture before it was allowed to // activate, we also need to send ACTIVE -> END and END -> UNDETERMINED events, as it was blocked from diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt index 285f6b3fb8..c57081b2ba 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt @@ -130,8 +130,6 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : } @ReactMethod - override fun setShouldPreventRecognizers(shouldPreventRecognizers: Boolean) = Unit - @DoNotStrip @Suppress("unused") fun setGestureHandlerState(handlerTag: Int, newState: Int) { 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 36aeb30da1..c23104b9d6 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 @@ -20,7 +20,6 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: val rootView: ViewGroup private var shouldIntercept = false private var passingTouch = false - private var shouldPreventRecognizers = true init { val registry = @@ -42,6 +41,18 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: rootView, ).apply { minimumAlphaForTraversal = MIN_ALPHA_FOR_TOUCH + onGestureActivated = { _ -> + shouldIntercept = true + val time = SystemClock.uptimeMillis() + val event = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) + if (rootView is RootView) { + rootView.onChildStartedNativeGesture(rootView, event) + } + event.recycle() + } + onGestureDeactivated = { _ -> + shouldIntercept = false + } } jsGestureHandler = RootViewGestureHandler(handlerTag = -wrappedViewTag) registry.registerHandler(jsGestureHandler) @@ -93,30 +104,6 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) = handleEvent(event) override fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) = handleEvent(event) - - override fun onCancel() { - if (!shouldPreventRecognizers) { - shouldIntercept = false - return - } - - shouldIntercept = true - val time = SystemClock.uptimeMillis() - val event = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0).apply { - action = MotionEvent.ACTION_CANCEL - } - if (rootView is RootView) { - rootView.onChildStartedNativeGesture(rootView, event) - } - event.recycle() - } - } - - fun setPreventRecognizers(preventRecognizers: Boolean) { - shouldPreventRecognizers = preventRecognizers - if (!preventRecognizers) { - shouldIntercept = false - } } fun requestDisallowInterceptTouchEvent() { 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 a33add9e82..79167e9c27 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 @@ -17,7 +17,6 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { private var moduleId: Int = -1 private var rootViewEnabled = false private var unstableForceActive = false - private var preventRecognizers = true private var rootHelper: RNGestureHandlerRootHelper? = null // TODO: resettable lateinit override fun onAttachedToWindow() { @@ -31,7 +30,6 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { } if (rootViewEnabled && rootHelper == null) { rootHelper = RNGestureHandlerRootHelper(context as ReactContext, this, moduleId) - rootHelper?.setPreventRecognizers(preventRecognizers) } } @@ -77,11 +75,6 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { this.unstableForceActive = active } - fun setPreventRecognizers(preventRecognizers: Boolean) { - this.preventRecognizers = preventRecognizers - rootHelper?.setPreventRecognizers(preventRecognizers) - } - companion object { private fun hasGestureHandlerEnabledRootView(viewGroup: ViewGroup): Boolean { UiThreadUtil.assertOnUiThread() diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt index 56036bf6c7..8292b72a73 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt @@ -44,11 +44,6 @@ class RNGestureHandlerRootViewManager : view.setUnstableForceActive(active) } - @ReactProp(name = "preventRecognizers") - override fun setPreventRecognizers(view: RNGestureHandlerRootView, preventRecognizers: Boolean) { - view.setPreventRecognizers(preventRecognizers) - } - /** * The following event configuration is necessary even if you are not using * GestureHandlerRootView component directly. diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx index 6190099442..e9fbf0f073 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx @@ -1,6 +1,5 @@ /* eslint-disable react/no-unused-prop-types */ import React, { useEffect, useMemo, useRef } from 'react'; -import { Platform } from 'react-native'; import findNodeHandle from '../../../findNodeHandle'; import { GestureType } from '../gesture'; import { UserSelect, TouchAction } from '../../gestureHandlerCommon'; @@ -44,10 +43,6 @@ function propagatePreventRecognizersConfig( preventRecognizers: boolean, gesture: ComposedGesture | GestureType ) { - if (Platform.OS !== 'ios') { - return; - } - for (const g of gesture.toGestureArray()) { const config = g.config as { [key: string]: unknown }; config.preventRecognizers = preventRecognizers; From aae857bcbb701648055e6b6fa66590a605b78b8e Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Tue, 14 Apr 2026 17:53:20 +0200 Subject: [PATCH 06/18] rename --- .../gesturehandler/core/GestureHandlerOrchestrator.kt | 8 ++++---- .../gesturehandler/react/RNGestureHandlerRootHelper.kt | 4 ++-- 2 files changed, 6 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 bb7db69649..b86c060436 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 @@ -42,8 +42,8 @@ class GestureHandlerOrchestrator( private var finishedHandlersCleanupScheduled = false private var activationIndex = 0 - var onGestureActivated: ((GestureHandler) -> Unit)? = null - var onGestureDeactivated: ((GestureHandler) -> Unit)? = null + var onPreventRecognizersRequested: ((GestureHandler) -> Unit)? = null + var onPreventRecognizersReleased: ((GestureHandler) -> Unit)? = null /** * Should be called from the view wrapper @@ -150,7 +150,7 @@ class GestureHandlerOrchestrator( if (isFinished(newState) && handler.isActive && handler.preventRecognizers) { // Check if there are any other active handlers that are preventing recognizers. if (gestureHandlers.none { it !== handler && it.isActive && it.preventRecognizers }) { - onGestureDeactivated?.invoke(handler) + onPreventRecognizersReleased?.invoke(handler) } } @@ -240,7 +240,7 @@ class GestureHandlerOrchestrator( cleanupAwaitingHandlers() if (handler.preventRecognizers) { - onGestureActivated?.invoke(handler) + onPreventRecognizersRequested?.invoke(handler) } // At this point the waiting handler is allowed to activate, so we need to send BEGAN -> ACTIVE event 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 c23104b9d6..622b6ceb34 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 @@ -41,7 +41,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: rootView, ).apply { minimumAlphaForTraversal = MIN_ALPHA_FOR_TOUCH - onGestureActivated = { _ -> + onPreventRecognizersRequested = { _ -> shouldIntercept = true val time = SystemClock.uptimeMillis() val event = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) @@ -50,7 +50,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: } event.recycle() } - onGestureDeactivated = { _ -> + onPreventRecognizersReleased = { _ -> shouldIntercept = false } } From 986a56a46637d10b4f2196a52ffd83e3beb8c215 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Tue, 14 Apr 2026 18:02:42 +0200 Subject: [PATCH 07/18] remove smth --- ANDROID_ARCHITECTURE.md | 247 ---------------------------------------- 1 file changed, 247 deletions(-) delete mode 100644 ANDROID_ARCHITECTURE.md diff --git a/ANDROID_ARCHITECTURE.md b/ANDROID_ARCHITECTURE.md deleted file mode 100644 index 280f9314ab..0000000000 --- a/ANDROID_ARCHITECTURE.md +++ /dev/null @@ -1,247 +0,0 @@ -# Android Architecture: React Native Gesture Handler - ---- - -## 1. Native Module Setup - -**Entry point**: `RNGestureHandlerPackage` extends `BaseReactPackage` and registers: -- `RNGestureHandlerModule` — the main TurboModule (annotated `@ReactModule(name = "RNGestureHandlerModule")`) -- `RNGestureHandlerRootViewManager`, `RNGestureHandlerButtonViewManager`, `RNGestureHandlerDetectorViewManager` — view managers registered on-demand - -**`RNGestureHandlerModule`** is the JS-facing API. Its main responsibilities are: -- `createGestureHandler(handlerName, handlerTag, config)` — instantiate a handler class -- `attachGestureHandler(handlerTag, viewTag, actionType)` — bind handler to a native view -- `updateGestureHandler(handlerTag, config)` — reconfigure a handler -- `dropGestureHandler(handlerTag)` — destroy a handler - -It implements `TurboModuleWithJSIBindings` for JSI/C++ interop (loading `gesturehandler` native library via `SoLoader`). One registry per React root is stored in `registries: MutableMap`. - ---- - -## 2. Handler Storage - -**`RNGestureHandlerRegistry`** holds all the data structures: -- `SparseArray` indexed by handler tag (integer, assigned from JS) -- `SparseArray` mapping handler tag → attached view tag -- `SparseArray>` mapping view tag → list of handlers on that view - -When `attachHandlerToView()` is called: -1. Handler is detached from any previous view -2. Registered to the target view tag's list -3. For detector-type handlers, the host detector view reference is set - ---- - -## 3. Root View & Touch Interception - -**`RNGestureHandlerRootView`** extends `ReactViewGroup`. On `dispatchTouchEvent()` and `dispatchGenericMotionEvent()`, it delegates to `RNGestureHandlerRootHelper` before calling `super`. Returns `true` (consumed) if the helper says to intercept. - -**`RNGestureHandlerRootHelper`** is the real coordinator: -- Creates a `GestureHandlerOrchestrator` with the root view, registry, and config helper -- Registers a synthetic **`RootViewGestureHandler`** (with tag = `-wrappedViewTag`) to monitor the root-level gesture -- On each touch event: calls `orchestrator.onTouchEvent(event)`, then returns `shouldIntercept` -- `shouldIntercept` is set to `true` when `RootViewGestureHandler.onCancel()` is triggered, signaling that a native child grabbed the touch lock - -**`RootViewGestureHandler`** (private inner class): -- Begins on `ACTION_DOWN`, ends on `ACTION_UP` -- `shouldBeCancelledBy(handler)` returns `handler.preventRecognizers` — this is the key hook for the `preventRecognizers` feature -- When cancelled, calls `rootView.onChildStartedNativeGesture()` to notify React - ---- - -## 4. Gesture Detection Pipeline - -Full event flow from a raw `MotionEvent`: - -``` -Android MotionEvent - ↓ -RNGestureHandlerRootView.dispatchTouchEvent() - ↓ -RNGestureHandlerRootHelper.dispatchTouchEvent() - ↓ -GestureHandlerOrchestrator.onTouchEvent() - ├─ ACTION_DOWN / POINTER_DOWN / HOVER_MOVE - │ └─ extractGestureHandlers(event) - │ └─ traverseWithPointerEvents() — walks view hierarchy - │ └─ recordViewHandlersForPointer() — registers handlers whose views - │ contain the touch point - ├─ ACTION_CANCEL → cancelAll() - └─ deliverEventToGestureHandlers(event) - └─ For each handler (sorted by activation priority): - ├─ transformEventToViewCoords() — transform to handler's local space - ├─ handler.updatePointerData() — if needsPointerData - ├─ handler.handle(event, source) — core processing, calls onHandle() - └─ handler.dispatchHandlerUpdate() — if currently ACTIVE -``` - -**View hierarchy traversal** in `extractGestureHandlers()`: -- Starts at the wrapper view, iterates children in **reverse drawing order** (topmost first) -- Skips clipped children or those outside bounds or with `pointerEvents=NONE` -- Transforms coordinates into each child's local space recursively -- Stops at nested `RNGestureHandlerRootView` to prevent double-processing -- Handles `ReactCompoundView` for compound children - ---- - -## 5. GestureHandler Base Class State Machine - -**States** (`GestureHandler.kt`): -``` -UNDETERMINED (0) - ↓ begin() - BEGAN (2) - ↓ activate() - ACTIVE (4) - ↓ end() - END (5) - -From any of UNDETERMINED/BEGAN/ACTIVE: - → FAILED (1) via fail() - → CANCELLED (3) via cancel() -``` - -**Key properties**: -- `state` — current state (private setter) -- `isActive`, `isAwaiting` — orchestrator-managed flags -- `activationIndex` — order of activation; used to prioritize event delivery -- `trackedPointerIDs[]` — maps Android pointer IDs to handler-local sequential IDs -- `trackedPointersCount` — active pointer count -- `x`, `y` — current touch position in handler's local space -- `preventRecognizers` — whether this handler blocks the root view's native gesture (default `true`) - -**Core methods**: -- `handle(transformedEvent, sourceEvent)` — entry point from orchestrator; routes to `onHandle()` in subclasses -- `prepare(view, orchestrator)` — called once when first recording the handler for a view -- `reset()` — clears all state after gesture lifecycle completes -- `shouldRecognizeSimultaneously(handler)`, `shouldBeCancelledBy(handler)`, `shouldRequireToWaitForFailure(handler)` — relationship queries - -**Pointer tracking**: Up to 17 simultaneous pointers (`MAX_POINTERS_COUNT`). Android pointer IDs are remapped to local sequential IDs 0–16 via `startTrackingPointer()` / `stopTrackingPointer()`. - -**Hit slop**: `isWithinBounds()` supports per-side padding plus `width`/`height` constraints on the touch target. - ---- - -## 6. Concrete Handler Implementations - -| Handler | Activation Trigger | Key Config | -|---|---|---| -| `TapGestureHandler` | `ACTION_UP` within time/distance limits | `numberOfTaps`, `maxDurationMs`, `maxDelayMs`, `maxDelta[XY]` | -| `PanGestureHandler` | Minimum distance or velocity threshold | `activeOffset[XY]`, `failOffset[XY]`, `minVelocity[XY]`, `minPointers`, `maxPointers` | -| `LongPressGestureHandler` | Timeout after hold (default 500ms) | `minDurationMs`, `maxDist`, `numberOfPointers` | -| `PinchGestureHandler` | Two-finger scale via `ScaleGestureDetector` | — | -| `RotationGestureHandler` | Two-finger rotation via `RotationGestureDetector` | — | -| `FlingGestureHandler` | Minimum swipe velocity | `numberOfPointers`, `direction`, `minVelocity` | -| `HoverGestureHandler` | `ACTION_HOVER_*` events only (no touch) | — | -| `ManualGestureHandler` | Never auto-activates; JS-controlled | — | -| `NativeViewGestureHandler` | Wraps native views (ScrollView, Button, etc.) | — | - -Pan has stylus support (`StylusData` with pressure/tilt), optional `averageTouches` for iOS-like multi-pointer averaging, and can activate after a long-press delay. - ---- - -## 7. Handler Interactions - -**`RNGestureHandlerInteractionManager`** stores three relationship maps: - -| Relationship | Data Structure | Semantics | -|---|---|---| -| Simultaneous | `SparseArray` | Both handlers can be ACTIVE at the same time | -| WaitFor / RequireToFail | `waitForRelations: SparseArray` | Handler waits for another to FAIL before activating | -| Blocking | `blockingRelations: SparseArray` | Handler blocks others until it resolves | - -**Orchestrator enforcement** in `makeActive()`: -1. `hasOtherHandlerToWaitFor()` — if something must fail first, handler enters `isAwaiting=true` state -2. On each awaiting handler: when its blocker ends in `FAILED`/`CANCELLED` → re-try `tryActivate()`; if blocker ends in `END` → cancel the awaiting handler -3. When a handler becomes truly ACTIVE: call `shouldHandlerBeCancelledBy()` on all other handlers and cancel those that return `true` - ---- - -## 8. Event/Callback System - -**`RNGestureHandlerEventDispatcher`** routes events based on `actionType`: - -| Action Type | Routing | -|---|---| -| `ACTION_TYPE_REANIMATED_WORKLET` | Reanimated worklet via `sendEventForReanimated()` | -| `ACTION_TYPE_NATIVE_ANIMATED_EVENT` | Native animated driver | -| `ACTION_TYPE_JS_FUNCTION_OLD_API` | Device event via `RNGestureHandlerEvent` (old bridge) | -| `ACTION_TYPE_JS_FUNCTION_NEW_API` | Device event via `RNGestureHandlerEvent` (new API) | -| `ACTION_TYPE_NATIVE_DETECTOR` | Directly on the detector view | - -**Events dispatched**: -- **Handler update events** — fired while state == `ACTIVE`, contains current position, velocity, scale, etc. -- **State change events** — fired on every state transition: `onBegin`, `onStart` (ACTIVE), `onEnd`, `onFinalize` - -**Touch pointer events** (when `needsPointerData=true`): `RNGestureHandlerTouchEvent` carries `changedTouches` and `allTouches` arrays with per-pointer `{id, x, y, absoluteX, absoluteY}`. - -**Full callback chain**: -``` -Handler state change - → orchestrator.onHandlerStateChange() - → dispatchStateChange() - → onTouchEventListener.onStateChange() - → RNGestureHandlerEventDispatcher.dispatchStateChangeEvent() - → [route by actionType] → JS / Reanimated / NativeAnimated -``` - ---- - -## 9. GestureHandlerOrchestrator - -The orchestrator is the central coordinator. Key data structures: -- `gestureHandlers[]` — all handlers currently receiving events -- `awaitingHandlers[]` — handlers blocked waiting for another to fail -- `preparedHandlers[]` — snapshot copy used during event delivery to avoid concurrent modification - -**Event delivery priority** (`handlersComparator`): -1. Active handlers first (by `activationIndex`, earliest first) -2. Awaiting handlers next -3. Inactive handlers last - -**Coordinate transformation**: `transformEventToViewCoords()` recursively walks the parent chain from the handler's view up to the wrapper view, accounting for scroll offsets, view positions, and matrix transforms. - -**Handler lifecycle**: -1. First touch in view's bounds → `recordHandlerIfNotPresent()` → `handler.prepare(view, this)` -2. Events delivered each frame -3. Handler reaches terminal state (END/FAILED/CANCELLED) → removed from active list, `reset()` called - ---- - -## 10. Old vs. New Architecture - -The library is **hybrid**: it supports both architectures simultaneously. - -- **TurboModule / JSI**: `RNGestureHandlerModule` implements `TurboModuleWithJSIBindings`, exposes C++ bindings via JNI for direct synchronous calls (no bridge round-trip) -- **Old bridge**: Falls back to `ACTION_TYPE_JS_FUNCTION_OLD_API` event dispatch via the React Native bridge -- **Fabric**: `RNGestureHandlerDetectorViewManager` has Fabric-aware view creation paths -- **Reanimated 2**: Detected at runtime via `setReanimatedAvailable()`; worklet routing bypasses the bridge entirely -- **NativeAnimated**: Gesture values can drive animations on the native thread via `ACTION_TYPE_NATIVE_ANIMATED_EVENT` - ---- - -## 11. `preventRecognizers` Feature - -**`GestureHandler.preventRecognizers: Boolean`** (default `true`) controls whether, when a handler activates, it cancels the root view's synthetic gesture handler. - -**Mechanism**: -1. Handler becomes ACTIVE → orchestrator calls `makeActive()` → calls `shouldHandlerBeCancelledBy(handler)` on all registered handlers -2. `RootViewGestureHandler.shouldBeCancelledBy(handler)` returns `handler.preventRecognizers` -3. If `true`: root handler is cancelled → `onChildStartedNativeGesture()` called → React treats the touch as consumed by RN -4. If `false`: root handler stays alive → native views can still process the touch in parallel - -**Config key**: `KEY_PREVENT_RECOGNIZERS` in `updateConfig()`. Recent commits extended this to be configurable **per gesture detector** (`b939be17f`) and **per root view** (`1f123b1ff`). - ---- - -## Key Architectural Patterns - -| Pattern | Where Used | -|---|---| -| **State Machine** | Every `GestureHandler` subclass | -| **Registry** | `RNGestureHandlerRegistry` (tag → handler, view → handlers) | -| **Visitor** | Orchestrator traversing the view hierarchy | -| **Observer** | Handlers notify orchestrator of state changes | -| **Strategy** | `RNGestureHandlerInteractionManager` for interaction policies | -| **Adapter** | Event builders adapt handler data to different event types | -| **Lazy init** | Handlers only prepared when first receiving events | From 1197ee76452688840ddfc52df15ff3fc49ddb062 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 15 Apr 2026 13:49:16 +0200 Subject: [PATCH 08/18] testing v3 --- .../rnResponderCancellationPerRoot/index.tsx | 27 +++++++++---------- .../react/RNGestureHandlerModule.kt | 1 - .../gestures/GestureDetector/index.tsx | 14 ---------- 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx index 355d0817f7..6c353cf840 100644 --- a/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx +++ b/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx @@ -1,6 +1,6 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; import { COLORS, commonStyles } from '../../../common'; const MAX_EVENTS = 5; @@ -20,19 +20,16 @@ function EventPanel({ title, preventRecognizers }: PanelProps) { setEvents((prev) => [event, ...prev].slice(0, MAX_EVENTS)); }; - const panGesture = useMemo( - () => - Gesture.Pan() - .minDistance(12) - .runOnJS(true) - .onStart(() => { - pushEvent('GH pan ACTIVE'); - }) - .onFinalize(() => { - pushEvent('GH pan finalize'); - }), - [] - ); + const panGesture = usePanGesture({ + minDistance: 12, + runOnJS: true, + onFinalize: () => { + pushEvent('GH pan finalize'); + }, + onActivate: () => { + pushEvent('GH pan ACTIVE'); + }, + }); return ( diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt index c57081b2ba..986f5c1e13 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt @@ -129,7 +129,6 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) : isReanimatedAvailable = isAvailable } - @ReactMethod @DoNotStrip @Suppress("unused") fun setGestureHandlerState(handlerTag: Int, newState: Int) { diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx index e9fbf0f073..90aafcf4dc 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx @@ -39,16 +39,6 @@ function propagateDetectorConfig( } } -function propagatePreventRecognizersConfig( - preventRecognizers: boolean, - gesture: ComposedGesture | GestureType -) { - for (const g of gesture.toGestureArray()) { - const config = g.config as { [key: string]: unknown }; - config.preventRecognizers = preventRecognizers; - } -} - export interface GestureDetectorProps { children?: React.ReactNode; /** @@ -106,10 +96,6 @@ export const GestureDetector = (props: GestureDetectorProps) => { // Gesture config should be wrapped with useMemo to prevent unnecessary re-renders const gestureConfig = props.gesture; propagateDetectorConfig(props, gestureConfig); - propagatePreventRecognizersConfig( - props.preventRecognizers ?? true, - gestureConfig - ); const gesturesToAttach = useMemo( () => gestureConfig.toGestureArray(), From da47e894139ccd8c9fb3c9322871506ec4421991 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 15 Apr 2026 14:53:49 +0200 Subject: [PATCH 09/18] better example --- apps/common-app/src/new_api/index.tsx | 5 - .../tests/rnResponderCancellation/index.tsx | 38 +++-- .../rnResponderCancellationPerRoot/index.tsx | 142 ------------------ .../apple/RNGestureHandler.mm | 1 - 4 files changed, 22 insertions(+), 164 deletions(-) delete mode 100644 apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 48ddce2d0b..faa2905271 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -45,7 +45,6 @@ import NestedRootViewExample from './tests/nestedRootView'; import NestedPressablesExample from './tests/nestedPressables'; import PressableExample from './tests/pressable'; import RNResponderCancellationExample from './tests/rnResponderCancellation'; -import RNResponderCancellationPerRootExample from './tests/rnResponderCancellationPerRoot'; import { ExamplesSection } from '../common'; import EmptyExample from '../empty'; @@ -137,10 +136,6 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ name: 'RN responder cancellation', component: RNResponderCancellationExample, }, - { - name: 'RN responder cancellation (per detector)', - component: RNResponderCancellationPerRootExample, - }, ], }, ]; diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx index 5ccb949da0..afc199af34 100644 --- a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx +++ b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { StyleSheet, Switch, Text, View } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; import { COLORS, Feedback, @@ -25,19 +25,16 @@ export default function RNResponderCancellationExample() { setEvents((prev) => [event, ...prev].slice(0, MAX_EVENTS)); }, []); - const panGesture = useMemo( - () => - Gesture.Pan() - .minDistance(12) - .runOnJS(true) - .onStart(() => { - pushEvent('GH pan ACTIVE'); - }) - .onFinalize((_event, success) => { - pushEvent(`GH pan finalize (${success ? 'success' : 'cancel/fail'})`); - }), - [pushEvent] - ); + const panGesture = usePanGesture({ + minDistance: 12, + runOnJS: true, + onActivate: () => { + pushEvent('GH pan ACTIVE'); + }, + onFinalize: (_event, success) => { + pushEvent(`GH pan finalize (${success ? 'success' : 'cancel/fail'})`); + }, + }); return ( @@ -89,7 +86,12 @@ export default function RNResponderCancellationExample() { {events.map((item) => ( - + {item} ))} @@ -151,4 +153,8 @@ const styles = StyleSheet.create({ color: '#2c3a4f', fontFamily: 'Courier', }, + logLineActive: { + color: '#1565c0', + fontWeight: 'bold', + }, }); diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx deleted file mode 100644 index 6c353cf840..0000000000 --- a/apps/common-app/src/new_api/tests/rnResponderCancellationPerRoot/index.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React, { useRef, useState } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; -import { COLORS, commonStyles } from '../../../common'; - -const MAX_EVENTS = 5; - -type PanelProps = { - title: string; - preventRecognizers: boolean; -}; - -function EventPanel({ title, preventRecognizers }: PanelProps) { - const sequenceRef = useRef(0); - const [events, setEvents] = useState([]); - - const pushEvent = (label: string) => { - sequenceRef.current += 1; - const event = `${sequenceRef.current}. ${label}`; - setEvents((prev) => [event, ...prev].slice(0, MAX_EVENTS)); - }; - - const panGesture = usePanGesture({ - minDistance: 12, - runOnJS: true, - onFinalize: () => { - pushEvent('GH pan finalize'); - }, - onActivate: () => { - pushEvent('GH pan ACTIVE'); - }, - }); - - return ( - - {title} - - preventRecognizers={String(preventRecognizers)} - - - true} - onMoveShouldSetResponder={() => true} - onResponderGrant={() => { - pushEvent('RN grant'); - }} - onResponderMove={() => { - pushEvent('RN move'); - }} - onResponderRelease={() => { - pushEvent('RN release'); - }} - onResponderTerminate={() => { - pushEvent('RN terminate'); - }} - onResponderTerminationRequest={() => true}> - Drag here - - - - {events.map((event) => ( - - {event} - - ))} - - - ); -} - -export default function RNResponderCancellationPerRootExample() { - return ( - - - Per-detector responder cancellation - - - Compare both sections. Top should terminate RN responder after GH - activates, bottom should keep RN callbacks running. - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingHorizontal: 12, - paddingTop: 14, - paddingBottom: 20, - gap: 10, - backgroundColor: COLORS.offWhite, - }, - panelContainer: { - borderRadius: 14, - borderWidth: 1, - borderColor: '#cbd8ea', - padding: 10, - backgroundColor: '#fdfefe', - gap: 8, - }, - panelTitle: { - fontSize: 16, - fontWeight: '700', - color: COLORS.NAVY, - }, - panelSubtitle: { - fontSize: 12, - color: '#495868', - }, - touchArea: { - minHeight: 120, - borderWidth: 2, - borderRadius: 12, - borderColor: COLORS.NAVY, - backgroundColor: '#d8ebff', - justifyContent: 'center', - alignItems: 'center', - }, - touchAreaLabel: { - color: COLORS.NAVY, - fontWeight: '700', - }, - logContainer: { - minHeight: 76, - borderWidth: 1, - borderColor: '#d5dbe6', - borderRadius: 8, - padding: 8, - backgroundColor: '#ffffff', - }, - logLine: { - fontSize: 12, - color: '#2c3a4f', - fontFamily: 'Courier', - }, -}); diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index 738be14659..e9b05d30fd 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -1,4 +1,3 @@ - #import "RNGestureHandler.h" #import "RNManualActivationRecognizer.h" From a97742e9174c6c81bd6500dde228e1b3d414c7e4 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 15 Apr 2026 15:26:18 +0200 Subject: [PATCH 10/18] set preventRecognizers on gesture handlers --- .../src/new_api/tests/rnResponderCancellation/index.tsx | 5 ++--- .../src/handlers/gestures/GestureDetector/index.tsx | 5 ----- .../src/v3/detectors/NativeDetector.tsx | 8 -------- .../src/v3/detectors/common.ts | 1 - 4 files changed, 2 insertions(+), 17 deletions(-) diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx index afc199af34..706403f7f1 100644 --- a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx +++ b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx @@ -28,6 +28,7 @@ export default function RNResponderCancellationExample() { const panGesture = usePanGesture({ minDistance: 12, runOnJS: true, + preventRecognizers, onActivate: () => { pushEvent('GH pan ACTIVE'); }, @@ -50,9 +51,7 @@ export default function RNResponderCancellationExample() { /> - + { diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx index 90aafcf4dc..52a4d22499 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx @@ -66,11 +66,6 @@ export interface GestureDetectorProps { * Supports all CSS touch-action values (e.g. `"none"`, `"pan-y"`). Default value is set to `"none"`. */ touchAction?: TouchAction; - /** - * Controls whether activating a Gesture Handler recognizer should cancel RN JS responders. - * Default is `true`. - */ - preventRecognizers?: boolean; } /** diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index 28d3e77b72..af8342706a 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -20,7 +20,6 @@ export function NativeDetector< touchAction, userSelect, enableContextMenu, - preventRecognizers = true, }: NativeDetectorProps) { const NativeDetectorComponent = gesture.config.dispatchesAnimatedEvents ? AnimatedNativeDetector @@ -30,13 +29,6 @@ export function NativeDetector< ensureNativeDetectorComponent(NativeDetectorComponent); - if ( - (Platform.OS === 'ios' || Platform.OS === 'android') && - !isComposedGesture(gesture) - ) { - gesture.config.preventRecognizers = preventRecognizers; - } - configureRelations(gesture); const handlerTags = useMemo(() => { diff --git a/packages/react-native-gesture-handler/src/v3/detectors/common.ts b/packages/react-native-gesture-handler/src/v3/detectors/common.ts index 8a2b8cc4c8..77f036cb6e 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/common.ts +++ b/packages/react-native-gesture-handler/src/v3/detectors/common.ts @@ -16,7 +16,6 @@ interface CommonGestureDetectorProps { userSelect?: UserSelect | undefined; touchAction?: TouchAction | undefined; enableContextMenu?: boolean | undefined; - preventRecognizers?: boolean | undefined; } export interface NativeDetectorProps< From fca5a9860959d296dad4d3f245c3610ffededaa7 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 15 Apr 2026 17:22:10 +0200 Subject: [PATCH 11/18] fix docx --- .../docs/fundamentals/gesture-detector.mdx | 114 +++++++++--------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx index 87bfa6732a..aa3fbbab7c 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx +++ b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx @@ -5,9 +5,6 @@ sidebar_label: Gesture detectors sidebar_position: 4 --- -import CollapsibleCode from '@site/src/components/CollapsibleCode'; -import HeaderWithBadges from '@site/src/components/HeaderWithBadges'; - ## Gesture Detector The `GestureDetector` is a key component of `react-native-gesture-handler`. It supports gestures created either using the hook-based API or the builder pattern. Additionally, it allows for the recognition of multiple gestures through [gesture composition](/docs/fundamentals/gesture-composition). `GestureDetector` interacts closely with [`Reanimated`](https://docs.swmansion.com/react-native-reanimated/). For more details, refer to the [Integration with Reanimated](/docs/fundamentals/reanimated-interactions) section. @@ -16,12 +13,11 @@ When using hook API, you can also integrate it directly with the [Animated API]( :::danger -#### Nesting Gesture Detectors +#### Nesting Gesture Detectors Because `GestureDetector` supports both the hook API and the builder pattern, it is important to avoid nesting detectors that use different APIs, as this can result in undefined behavior. #### Reusing Gestures - Using the same instance of a gesture across multiple Gesture Detectors may result in undefined behavior. ::: @@ -94,34 +90,35 @@ export default function App() { }, }); -return ( - - - - - {}} -/> - - - - -); + return ( + + + + + {}} + /> + + + + + ); } const styles = StyleSheet.create({ -container: { -flex: 1, -alignItems: 'center', -justifyContent: 'center', -}, + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, }); `}/> + #### Text You can use `VirtualGestureDetector` to add gesture handling to specific parts of a `Text` component. @@ -147,38 +144,39 @@ export default function App() { }, }); -const nestedTap = useTapGesture({ -onDeactivate: () => { -console.log('Tapped on nested part!'); -}, -}); + const nestedTap = useTapGesture({ + onDeactivate: () => { + console.log('Tapped on nested part!'); + }, + }); -return ( - - - -Nested text - - -try tapping on this part. - - -This part is not special :c - - - -); + return ( + + + + Nested text + + + try tapping on this part. + + + This part is not special :c + + + + ); } const styles = StyleSheet.create({ -container: { -flex: 1, -alignItems: 'center', -justifyContent: 'space-around', -}, + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'space-around', + }, }); `}/> + ## Properties ### gesture @@ -189,7 +187,9 @@ gesture: SingleGesture | ComposedGesture; A gesture object containing the configuration and callbacks. Can be any of the base gestures or any [`ComposedGesture`](/docs/fundamentals/gesture-composition). -### userSelect + +### userSelect + ```ts userSelect: 'none' | 'auto' | 'text'; @@ -197,7 +197,9 @@ userSelect: 'none' | 'auto' | 'text'; This parameter allows specifying which `userSelect` property should be applied to the underlying view. Default value is set to `"none"`. -### touchAction + +### touchAction + ```ts touchAction: TouchAction; @@ -205,7 +207,9 @@ touchAction: TouchAction; This parameter allows specifying which `touchAction` property should be applied to the underlying view. Supports all CSS [touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/touch-action) values. Default value is set to `"none"`. -### enableContextMenu + +### enableContextMenu + ```ts enableContextMenu: boolean; From f9b2835e410aa1b1dfd01f2af4d57604acb72edf Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 16 Apr 2026 11:17:03 +0200 Subject: [PATCH 12/18] fix docs again --- .../docs/fundamentals/gesture-detector.mdx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx index aa3fbbab7c..00d948e0f6 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx +++ b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx @@ -5,6 +5,9 @@ sidebar_label: Gesture detectors sidebar_position: 4 --- +import CollapsibleCode from '@site/src/components/CollapsibleCode'; +import HeaderWithBadges from '@site/src/components/HeaderWithBadges'; + ## Gesture Detector The `GestureDetector` is a key component of `react-native-gesture-handler`. It supports gestures created either using the hook-based API or the builder pattern. Additionally, it allows for the recognition of multiple gestures through [gesture composition](/docs/fundamentals/gesture-composition). `GestureDetector` interacts closely with [`Reanimated`](https://docs.swmansion.com/react-native-reanimated/). For more details, refer to the [Integration with Reanimated](/docs/fundamentals/reanimated-interactions) section. @@ -187,9 +190,9 @@ gesture: SingleGesture | ComposedGesture; A gesture object containing the configuration and callbacks. Can be any of the base gestures or any [`ComposedGesture`](/docs/fundamentals/gesture-composition). - + ### userSelect - + ```ts userSelect: 'none' | 'auto' | 'text'; @@ -197,9 +200,9 @@ userSelect: 'none' | 'auto' | 'text'; This parameter allows specifying which `userSelect` property should be applied to the underlying view. Default value is set to `"none"`. - + ### touchAction - + ```ts touchAction: TouchAction; @@ -207,9 +210,9 @@ touchAction: TouchAction; This parameter allows specifying which `touchAction` property should be applied to the underlying view. Supports all CSS [touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/touch-action) values. Default value is set to `"none"`. - + ### enableContextMenu - + ```ts enableContextMenu: boolean; From ea95543ca21af7f3ccc14a214c697b2b37dac980 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 16 Apr 2026 11:37:02 +0200 Subject: [PATCH 13/18] fix --- .../docs-gesture-handler/docs/fundamentals/gesture-detector.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx index 00d948e0f6..773c97b2f0 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx +++ b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx @@ -202,7 +202,7 @@ This parameter allows specifying which `userSelect` property should be applied t ### touchAction - + ```ts touchAction: TouchAction; From 2e975308f9f3eb85e4762960cb8bbb7b7f5ad492 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 16 Apr 2026 13:29:50 +0200 Subject: [PATCH 14/18] block recognizers after the state is checked --- .../gesturehandler/core/GestureHandlerOrchestrator.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 b86c060436..4cb6d50850 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 @@ -239,10 +239,6 @@ class GestureHandlerOrchestrator( } cleanupAwaitingHandlers() - if (handler.preventRecognizers) { - onPreventRecognizersRequested?.invoke(handler) - } - // At this point the waiting handler is allowed to activate, so we need to send BEGAN -> ACTIVE event // as it wasn't sent before. If handler has finished recognizing the gesture before it was allowed to // activate, we also need to send ACTIVE -> END and END -> UNDETERMINED events, as it was blocked from @@ -256,6 +252,10 @@ class GestureHandlerOrchestrator( return } + if (handler.preventRecognizers) { + onPreventRecognizersRequested?.invoke(handler) + } + handler.dispatchStateChange(GestureHandler.STATE_ACTIVE, GestureHandler.STATE_BEGAN) if (currentState != GestureHandler.STATE_ACTIVE) { From c4b49ec4e4311d1b6873ad6ca496c3eb26ab329b Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 16 Apr 2026 13:30:16 +0200 Subject: [PATCH 15/18] fix typo --- .../apple/RNRootViewGestureRecognizer.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m index e62b3515f9..585150d670 100644 --- a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m +++ b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m @@ -53,7 +53,7 @@ - (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)preventedGestureRecog - (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)preventingGestureRecognizer { // When this method is called it means that one of handlers has activated, in this case we want - // to send an info to JS so that it cancells all JS responders, as long as the preventing + // to send an info to JS so that it cancels all JS responders, as long as the preventing // recognizer is from Gesture Handler, otherwise we might break some interactions RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:preventingGestureRecognizer]; if (handler != nil && handler.preventRecognizers) { From 68c31bb58e8124d3ec402909c8efeb3000d375e6 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 16 Apr 2026 14:58:03 +0200 Subject: [PATCH 16/18] docs --- .../docs/fundamentals/gesture-detector.mdx | 13 ------------- .../docs/gestures/_shared/base-gesture-config.mdx | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx index 773c97b2f0..b493219d90 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx +++ b/packages/docs-gesture-handler/docs/fundamentals/gesture-detector.mdx @@ -219,16 +219,3 @@ enableContextMenu: boolean; ``` Specifies whether the context menu should be enabled after clicking on the underlying view with the right mouse button. Default value is set to `false`. - - - ### preventRecognizers - - -```ts -preventRecognizers?: boolean; -``` - -Controls whether activating a Gesture Handler recognizer should cancel React Native JS responders. - -- `true` (default): keeps current behavior where RN touch handlers are cancelled after Gesture Handler activates. -- `false`: disables this cancellation, so Gesture Handler callbacks and RN responder callbacks can run at the same time. diff --git a/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx b/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx index bcb03e4259..fe1dcaa7f7 100644 --- a/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx +++ b/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx @@ -148,3 +148,18 @@ activeCursor: ActiveCursor | SharedValue; ``` This parameter allows specifying which cursor should be used when the gesture activates. Supports all [CSS cursor values](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/cursor#keyword) (e.g. `"grab"`, `"zoom-in"`). Default value is set to `"auto"`. + + + ### preventRecognizers + + +```ts +preventRecognizers?: boolean; +``` + +Controls whether activating a Gesture Handler recognizer should cancel React Native JS responders. + +This option is configured per gesture handler (for example in `usePanGesture({ preventRecognizers: ... })`). + +- `true` (default): when this gesture activates, it cancels React Native JS responders in the same root view. +- `false`: disables that cancellation, so Gesture Handler callbacks and RN responder callbacks can run at the same time. From ca4c6d3dd7ee7f2bd94dec10417f6b07719d45e4 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 17 Apr 2026 12:17:50 +0200 Subject: [PATCH 17/18] better example --- .../tests/rnResponderCancellation/index.tsx | 298 +++++++++++++++++- 1 file changed, 286 insertions(+), 12 deletions(-) diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx index 706403f7f1..56aab5e34f 100644 --- a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx +++ b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef, useState } from 'react'; -import { StyleSheet, Switch, Text, View } from 'react-native'; +import { ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; import { COLORS, @@ -8,9 +8,48 @@ import { commonStyles, } from '../../../common'; -const MAX_EVENTS = 8; +const SECTION_MIN_HEIGHT = 640; export default function RNResponderCancellationExample() { + return ( + + + + + + + + + + ); +} + +const scrollStyles = StyleSheet.create({ + scroll: { + flex: 1, + }, + content: { + flexGrow: 1, + }, + section: { + minHeight: SECTION_MIN_HEIGHT, + }, + divider: { + height: 2, + marginHorizontal: 16, + marginVertical: 4, + backgroundColor: '#b6bfd0', + }, +}); + +// ---------- Single handler -------------------------------------------------- + +const SINGLE_MAX_EVENTS = 8; + +function SingleHandlerExample() { const feedbackRef = useRef(null); const sequenceRef = useRef(0); const [events, setEvents] = useState([]); @@ -22,7 +61,7 @@ export default function RNResponderCancellationExample() { console.log(event); feedbackRef.current?.showMessage(label); - setEvents((prev) => [event, ...prev].slice(0, MAX_EVENTS)); + setEvents((prev) => [event, ...prev].slice(0, SINGLE_MAX_EVENTS)); }, []); const panGesture = usePanGesture({ @@ -38,13 +77,13 @@ export default function RNResponderCancellationExample() { }); return ( - + RN responder cancellation Toggle preventRecognizers and drag inside the box to compare behavior. - - preventRecognizers + + preventRecognizers { pushEvent('RN onStartShouldSetResponder -> true'); return true; @@ -78,18 +117,18 @@ export default function RNResponderCancellationExample() { pushEvent('RN onResponderTerminationRequest -> true'); return true; }}> - Drag me + Drag me - + {events.map((item) => ( {item} @@ -99,7 +138,7 @@ export default function RNResponderCancellationExample() { ); } -const styles = StyleSheet.create({ +const singleStyles = StyleSheet.create({ container: { flex: 1, paddingHorizontal: 16, @@ -157,3 +196,238 @@ const styles = StyleSheet.create({ fontWeight: 'bold', }, }); + +// ---------- Multi handler --------------------------------------------------- +// Validates that when two Gesture Handler recognizers are active at the same +// time, both with preventRecognizers set to true, finishing ONE of them does +// NOT unblock React Native JS responders — the block must stay in place until +// the LAST preventing recognizer finishes. +// +// Expected interaction to reproduce: +// 1. Finger 1: drag inside "Pan A" → GH_A ACTIVE (RN blocked) +// 2. Finger 2: drag inside "Pan B" → GH_B ACTIVE +// 3. Finger 3: tap the "RN responder zone" → grant must NOT fire +// 4. Release finger 1 → GH_A finalize +// 5. Finger 3: tap the "RN responder zone" → grant must STILL NOT fire +// (GH_B is still active) +// 6. Release finger 2 → GH_B finalize (released) +// 7. Finger 3: tap the "RN responder zone" → grant SHOULD now fire +// +// If step 5 logs "RN zone onResponderGrant" the invariant is broken. + +const MULTI_MAX_EVENTS = 14; + +function MultiHandlerExample() { + const feedbackRef = useRef(null); + const sequenceRef = useRef(0); + const [events, setEvents] = useState([]); + const [preventRecognizers, setPreventRecognizers] = useState(true); + + const pushEvent = useCallback((label: string) => { + sequenceRef.current += 1; + const event = `${sequenceRef.current}. ${label}`; + + console.log(event); + feedbackRef.current?.showMessage(label); + setEvents((prev) => [event, ...prev].slice(0, MULTI_MAX_EVENTS)); + }, []); + + const panA = usePanGesture({ + minDistance: 8, + runOnJS: true, + preventRecognizers, + onActivate: () => pushEvent('GH_A ACTIVE'), + onFinalize: (_e, success) => + pushEvent(`GH_A finalize (${success ? 'success' : 'cancel/fail'})`), + }); + + const panB = usePanGesture({ + minDistance: 8, + runOnJS: true, + // preventRecognizers, + onActivate: () => pushEvent('GH_B ACTIVE'), + onFinalize: (_e, success) => + pushEvent(`GH_B finalize (${success ? 'success' : 'cancel/fail'})`), + }); + + const clearLog = useCallback(() => { + sequenceRef.current = 0; + setEvents([]); + }, []); + + return ( + + preventRecognizers — multi + + Drag A and B with two fingers simultaneously, then tap the RN zone with + a third finger. Release one finger at a time and re-tap. + + + + preventRecognizers + + + clear + + + + + + + Pan A + + + + + Pan B + + + + + { + pushEvent('RN zone onStartShouldSetResponder -> true'); + return true; + }} + onResponderGrant={() => { + pushEvent( + 'RN zone onResponderGrant <-- NOT expected while GH active' + ); + }} + onResponderRelease={() => pushEvent('RN zone onResponderRelease')} + onResponderTerminate={() => + pushEvent('RN zone onResponderTerminate <-- cancelled by GH') + } + onResponderTerminationRequest={() => { + pushEvent('RN zone onResponderTerminationRequest -> true'); + return true; + }}> + RN responder zone (tap me) + + + + + + + {events.map((item) => ( + + {item} + + ))} + + + ); +} + +const multiStyles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 16, + paddingVertical: 16, + gap: 10, + alignItems: 'center', + backgroundColor: COLORS.offWhite, + }, + settingsRow: { + width: '100%', + maxWidth: 380, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + settingsLabel: { + color: COLORS.NAVY, + fontSize: 14, + fontWeight: '600', + }, + clearButton: { + color: COLORS.NAVY, + fontSize: 13, + fontWeight: '600', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 6, + borderWidth: 1, + borderColor: COLORS.NAVY, + }, + boxesRow: { + width: '100%', + maxWidth: 380, + flexDirection: 'row', + justifyContent: 'space-between', + gap: 10, + }, + panBox: { + flex: 1, + minHeight: 140, + borderRadius: 16, + borderWidth: 2, + borderColor: COLORS.NAVY, + justifyContent: 'center', + alignItems: 'center', + }, + panBoxA: { backgroundColor: '#d8ebff' }, + panBoxB: { backgroundColor: '#ffe0d8' }, + panLabel: { + color: COLORS.NAVY, + fontWeight: '700', + fontSize: 16, + }, + rnZone: { + width: '100%', + maxWidth: 380, + minHeight: 80, + borderRadius: 14, + borderWidth: 2, + borderStyle: 'dashed', + borderColor: '#7a4dff', + backgroundColor: '#ece2ff', + justifyContent: 'center', + alignItems: 'center', + }, + rnZoneLabel: { + color: '#3a1f9c', + fontWeight: '700', + fontSize: 15, + }, + feedbackSlot: { + width: '100%', + maxWidth: 420, + height: 84, + paddingHorizontal: 8, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + logContainer: { + width: '100%', + maxWidth: 420, + height: 260, + borderRadius: 12, + padding: 10, + backgroundColor: '#ffffff', + borderWidth: 1, + borderColor: '#d5dbe6', + gap: 2, + overflow: 'hidden', + }, + logLine: { + fontSize: 12, + color: '#2c3a4f', + fontFamily: 'Courier', + }, + logLineActive: { color: '#1565c0', fontWeight: 'bold' }, + logLineBad: { color: '#b71c1c', fontWeight: 'bold' }, + logLineCancel: { color: '#6a1b9a' }, +}); From fd5742234937f45a14359edcf3d0b51d8ccf7ba8 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 17 Apr 2026 12:32:33 +0200 Subject: [PATCH 18/18] rename to cancelsJsResponder --- .../tests/rnResponderCancellation/index.tsx | 30 +++++++++---------- .../gestures/_shared/base-gesture-config.mdx | 6 ++-- .../gesturehandler/core/GestureHandler.kt | 12 ++++---- .../core/GestureHandlerOrchestrator.kt | 16 +++++----- .../react/RNGestureHandlerRootHelper.kt | 4 +-- .../apple/RNGestureHandler.h | 2 +- .../apple/RNGestureHandler.mm | 6 ++-- .../apple/RNRootViewGestureRecognizer.m | 2 +- .../gestures/GestureDetector/utils.ts | 2 +- .../src/v3/hooks/utils/propsWhiteList.ts | 2 +- .../src/v3/types/ConfigTypes.ts | 2 +- 11 files changed, 42 insertions(+), 42 deletions(-) diff --git a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx index 56aab5e34f..d3a612c761 100644 --- a/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx +++ b/apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx @@ -53,7 +53,7 @@ function SingleHandlerExample() { const feedbackRef = useRef(null); const sequenceRef = useRef(0); const [events, setEvents] = useState([]); - const [preventRecognizers, setPreventRecognizers] = useState(true); + const [cancelsJSResponder, setCancelsJSResponder] = useState(true); const pushEvent = useCallback((label: string) => { sequenceRef.current += 1; @@ -67,7 +67,7 @@ function SingleHandlerExample() { const panGesture = usePanGesture({ minDistance: 12, runOnJS: true, - preventRecognizers, + cancelsJSResponder, onActivate: () => { pushEvent('GH pan ACTIVE'); }, @@ -80,13 +80,13 @@ function SingleHandlerExample() { RN responder cancellation - Toggle preventRecognizers and drag inside the box to compare behavior. + Toggle cancelsJSResponder and drag inside the box to compare behavior. - preventRecognizers + cancelsJSResponder @@ -199,9 +199,9 @@ const singleStyles = StyleSheet.create({ // ---------- Multi handler --------------------------------------------------- // Validates that when two Gesture Handler recognizers are active at the same -// time, both with preventRecognizers set to true, finishing ONE of them does +// time, both with cancelsJSResponder set to true, finishing ONE of them does // NOT unblock React Native JS responders — the block must stay in place until -// the LAST preventing recognizer finishes. +// the LAST cancelling recognizer finishes. // // Expected interaction to reproduce: // 1. Finger 1: drag inside "Pan A" → GH_A ACTIVE (RN blocked) @@ -221,7 +221,7 @@ function MultiHandlerExample() { const feedbackRef = useRef(null); const sequenceRef = useRef(0); const [events, setEvents] = useState([]); - const [preventRecognizers, setPreventRecognizers] = useState(true); + const [cancelsJSResponder, setCancelsJSResponder] = useState(true); const pushEvent = useCallback((label: string) => { sequenceRef.current += 1; @@ -235,7 +235,7 @@ function MultiHandlerExample() { const panA = usePanGesture({ minDistance: 8, runOnJS: true, - preventRecognizers, + cancelsJSResponder, onActivate: () => pushEvent('GH_A ACTIVE'), onFinalize: (_e, success) => pushEvent(`GH_A finalize (${success ? 'success' : 'cancel/fail'})`), @@ -244,7 +244,7 @@ function MultiHandlerExample() { const panB = usePanGesture({ minDistance: 8, runOnJS: true, - // preventRecognizers, + cancelsJSResponder, onActivate: () => pushEvent('GH_B ACTIVE'), onFinalize: (_e, success) => pushEvent(`GH_B finalize (${success ? 'success' : 'cancel/fail'})`), @@ -257,17 +257,17 @@ function MultiHandlerExample() { return ( - preventRecognizers — multi + cancelsJSResponder — multi Drag A and B with two fingers simultaneously, then tap the RN zone with a third finger. Release one finger at a time and re-tap. - preventRecognizers + cancelsJSResponder clear diff --git a/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx b/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx index fe1dcaa7f7..fd4fbabdad 100644 --- a/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx +++ b/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx @@ -150,16 +150,16 @@ activeCursor: ActiveCursor | SharedValue; This parameter allows specifying which cursor should be used when the gesture activates. Supports all [CSS cursor values](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/cursor#keyword) (e.g. `"grab"`, `"zoom-in"`). Default value is set to `"auto"`. - ### preventRecognizers + ### cancelsJSResponder ```ts -preventRecognizers?: boolean; +cancelsJSResponder?: boolean; ``` Controls whether activating a Gesture Handler recognizer should cancel React Native JS responders. -This option is configured per gesture handler (for example in `usePanGesture({ preventRecognizers: ... })`). +This option is configured per gesture handler (for example in `usePanGesture({ cancelsJSResponder: ... })`). - `true` (default): when this gesture activates, it cancels React Native JS responders in the same root view. - `false`: disables that cancellation, so Gesture Handler callbacks and RN responder callbacks can run at the same time. diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 95bb7a6e70..526f09cbf5 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -83,7 +83,7 @@ open class GestureHandler { var needsPointerData = false var dispatchesAnimatedEvents = false var dispatchesReanimatedEvents = false - var preventRecognizers = true + var cancelsJSResponder = true private var hitSlop: FloatArray? = null var eventCoalescingKey: Short = 0 @@ -138,7 +138,7 @@ open class GestureHandler { mouseButton = DEFAULT_MOUSE_BUTTON dispatchesAnimatedEvents = DEFAULT_DISPATCHES_ANIMATED_EVENTS dispatchesReanimatedEvents = DEFAULT_DISPATCHES_REANIMATED_EVENTS - preventRecognizers = DEFAULT_PREVENT_RECOGNIZERS + cancelsJSResponder = DEFAULT_CANCELS_JS_RESPONDER } fun hasCommonPointers(other: GestureHandler): Boolean { @@ -963,8 +963,8 @@ open class GestureHandler { if (config.hasKey(KEY_TEST_ID)) { handler.testID = config.getString(KEY_TEST_ID) } - if (config.hasKey(KEY_PREVENT_RECOGNIZERS)) { - handler.preventRecognizers = config.getBoolean(KEY_PREVENT_RECOGNIZERS) + if (config.hasKey(KEY_CANCELS_JS_RESPONDER)) { + handler.cancelsJSResponder = config.getBoolean(KEY_CANCELS_JS_RESPONDER) } } @@ -988,7 +988,7 @@ open class GestureHandler { private const val KEY_HIT_SLOP_WIDTH = "width" private const val KEY_HIT_SLOP_HEIGHT = "height" private const val KEY_TEST_ID = "testID" - private const val KEY_PREVENT_RECOGNIZERS = "preventRecognizers" + private const val KEY_CANCELS_JS_RESPONDER = "cancelsJSResponder" private fun handleHitSlopProperty(handler: GestureHandler, config: ReadableMap) { if (config.getType(KEY_HIT_SLOP) == ReadableType.Number) { @@ -1052,7 +1052,7 @@ open class GestureHandler { private const val DEFAULT_MOUSE_BUTTON = 0 private const val DEFAULT_DISPATCHES_ANIMATED_EVENTS = false private const val DEFAULT_DISPATCHES_REANIMATED_EVENTS = false - private const val DEFAULT_PREVENT_RECOGNIZERS = true + private const val DEFAULT_CANCELS_JS_RESPONDER = true const val STATE_UNDETERMINED = 0 const val STATE_FAILED = 1 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 4cb6d50850..6dd8bd4400 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 @@ -42,8 +42,8 @@ class GestureHandlerOrchestrator( private var finishedHandlersCleanupScheduled = false private var activationIndex = 0 - var onPreventRecognizersRequested: ((GestureHandler) -> Unit)? = null - var onPreventRecognizersReleased: ((GestureHandler) -> Unit)? = null + var onCancelJSResponderRequested: ((GestureHandler) -> Unit)? = null + var onCancelJSResponderReleased: ((GestureHandler) -> Unit)? = null /** * Should be called from the view wrapper @@ -147,10 +147,10 @@ class GestureHandlerOrchestrator( fun onHandlerStateChange(handler: GestureHandler, newState: Int, prevState: Int) { handlingChangeSemaphore += 1 - if (isFinished(newState) && handler.isActive && handler.preventRecognizers) { - // Check if there are any other active handlers that are preventing recognizers. - if (gestureHandlers.none { it !== handler && it.isActive && it.preventRecognizers }) { - onPreventRecognizersReleased?.invoke(handler) + if (isFinished(newState) && handler.isActive && handler.cancelsJSResponder) { + // Check if there are any other active handlers that still request the JS responder to be cancelled. + if (gestureHandlers.none { it !== handler && it.isActive && it.cancelsJSResponder }) { + onCancelJSResponderReleased?.invoke(handler) } } @@ -252,8 +252,8 @@ class GestureHandlerOrchestrator( return } - if (handler.preventRecognizers) { - onPreventRecognizersRequested?.invoke(handler) + if (handler.cancelsJSResponder) { + onCancelJSResponderRequested?.invoke(handler) } handler.dispatchStateChange(GestureHandler.STATE_ACTIVE, GestureHandler.STATE_BEGAN) 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 622b6ceb34..014eed120e 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 @@ -41,7 +41,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: rootView, ).apply { minimumAlphaForTraversal = MIN_ALPHA_FOR_TOUCH - onPreventRecognizersRequested = { _ -> + onCancelJSResponderRequested = { _ -> shouldIntercept = true val time = SystemClock.uptimeMillis() val event = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) @@ -50,7 +50,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: } event.recycle() } - onPreventRecognizersReleased = { _ -> + onCancelJSResponderReleased = { _ -> shouldIntercept = false } } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.h b/packages/react-native-gesture-handler/apple/RNGestureHandler.h index 216868fc16..42ff12e2aa 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.h @@ -81,7 +81,7 @@ @property (nonatomic) BOOL shouldCancelWhenOutside; @property (nonatomic) BOOL needsPointerData; @property (nonatomic) BOOL manualActivation; -@property (nonatomic) BOOL preventRecognizers; +@property (nonatomic) BOOL cancelsJSResponder; @property (nonatomic) BOOL dispatchesAnimatedEvents; @property (nonatomic) BOOL dispatchesReanimatedEvents; @property (nonatomic, weak, nullable) RNGHUIView *hostDetectorView; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index e9b05d30fd..ce69517148 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -105,7 +105,7 @@ - (void)resetConfig self.testID = nil; self.manualActivation = NO; _shouldCancelWhenOutside = NO; - _preventRecognizers = YES; + _cancelsJSResponder = YES; _hitSlop = RNGHHitSlopEmpty; _needsPointerData = NO; _dispatchesAnimatedEvents = NO; @@ -165,9 +165,9 @@ - (void)updateConfig:(NSDictionary *)config self.manualActivation = [RCTConvert BOOL:prop]; } - prop = config[@"preventRecognizers"]; + prop = config[@"cancelsJSResponder"]; if (prop != nil) { - _preventRecognizers = [RCTConvert BOOL:prop]; + _cancelsJSResponder = [RCTConvert BOOL:prop]; } prop = config[@"hitSlop"]; diff --git a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m index 585150d670..b7c1d017ee 100644 --- a/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m +++ b/packages/react-native-gesture-handler/apple/RNRootViewGestureRecognizer.m @@ -56,7 +56,7 @@ - (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)preventingGestu // to send an info to JS so that it cancels all JS responders, as long as the preventing // recognizer is from Gesture Handler, otherwise we might break some interactions RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:preventingGestureRecognizer]; - if (handler != nil && handler.preventRecognizers) { + if (handler != nil && handler.cancelsJSResponder) { [self.delegate gestureRecognizer:preventingGestureRecognizer didActivateInViewWithTouchHandler:self.view]; } diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts index 0fead1d69b..deccbe6a22 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts @@ -26,7 +26,7 @@ import { export const ALLOWED_PROPS = [ ...baseGestureHandlerWithDetectorProps, - 'preventRecognizers', + 'cancelsJSResponder', ...tapGestureHandlerProps, ...panGestureHandlerProps, ...panGestureHandlerCustomNativeProps, diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts index 930a28090c..37833fb296 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts @@ -23,7 +23,7 @@ const CommonConfig = new Set([ 'mouseButton', 'testID', 'cancelsTouchesInView', - 'preventRecognizers', + 'cancelsJSResponder', 'manualActivation', ]); diff --git a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts index 205bc0ddb9..5ebd5069d3 100644 --- a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts @@ -79,7 +79,7 @@ export type CommonGestureConfig = { activeCursor?: ActiveCursor | undefined; mouseButton?: MouseButton | undefined; cancelsTouchesInView?: boolean | undefined; - preventRecognizers?: boolean | undefined; + cancelsJSResponder?: boolean | undefined; manualActivation?: boolean | undefined; }, ActiveCursor | MouseButton