Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4897ae0
initial
coado Apr 8, 2026
8fd0c11
Add on Android
coado Apr 9, 2026
1f123b1
pass preventRecognizers per Android root view
coado Apr 10, 2026
b939be1
preventRecognizers per gesture detector
coado Apr 10, 2026
dc9270f
prevent recognizers Android
coado Apr 14, 2026
aae857b
rename
coado Apr 14, 2026
986a56a
remove smth
coado Apr 14, 2026
1197ee7
testing v3
coado Apr 15, 2026
da47e89
better example
coado Apr 15, 2026
a97742e
set preventRecognizers on gesture handlers
coado Apr 15, 2026
fca5a98
fix docx
coado Apr 15, 2026
f9b2835
fix docs again
coado Apr 16, 2026
ea95543
fix
coado Apr 16, 2026
2e97530
block recognizers after the state is checked
coado Apr 16, 2026
c4b49ec
fix typo
coado Apr 16, 2026
68c31bb
docs
coado Apr 16, 2026
ca4c6d3
better example
coado Apr 17, 2026
fd57422
rename to cancelsJsResponder
coado Apr 17, 2026
fff3181
merge
coado Apr 24, 2026
758cea7
Revert "merge"
coado Apr 24, 2026
803ee7c
Update packages/react-native-gesture-handler/src/v3/detectors/NativeD…
coado Apr 24, 2026
92fecd7
removed from allowed props list
coado Apr 24, 2026
770b410
better comment
coado Apr 24, 2026
eea0192
better docs
coado Apr 24, 2026
d8ff02f
pass onJSResponderCancelListener to the orchestrator, move shouldInte…
coado Apr 24, 2026
9f24c36
changing example
coado Apr 24, 2026
4d509d3
Merge branch 'main' into prevent-recognizers
coado Apr 24, 2026
0c13b44
empty
coado Apr 24, 2026
bc8f207
merge
coado Apr 24, 2026
7795b1a
Merge branch 'prevent-recognizers' of github.com:software-mansion/rea…
coado Apr 24, 2026
403a750
use badges
coado Apr 24, 2026
3f7c16e
fix types
coado Apr 24, 2026
d419464
change description
coado Apr 24, 2026
6eac6c7
pass non-null callback
coado Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/common-app/src/new_api/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
},
],
},
];
159 changes: 159 additions & 0 deletions apps/common-app/src/new_api/tests/rnResponderCancellation/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import React, { useCallback, useRef, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { GestureDetector, usePanGesture } from 'react-native-gesture-handler';
import {
COLORS,
Feedback,
FeedbackHandle,
commonStyles,
} from '../../../common';

const MAX_EVENTS = 8;

export default function RNResponderCancellationExample() {
const feedbackRef = useRef<FeedbackHandle>(null);
const sequenceRef = useRef(0);
const [events, setEvents] = useState<string[]>([]);
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 = usePanGesture({
minDistance: 12,
runOnJS: true,
preventRecognizers,
onActivate: () => {
pushEvent('GH pan ACTIVE');
},
onFinalize: (_event, success) => {
pushEvent(`GH pan finalize (${success ? 'success' : 'cancel/fail'})`);
},
});

return (
<View style={styles.container}>
<Text style={commonStyles.header}>RN responder cancellation</Text>
<Text style={commonStyles.instructions}>
Toggle preventRecognizers and drag inside the box to compare behavior.
</Text>
<View style={styles.settingsRow}>
<Text style={styles.settingsLabel}>preventRecognizers</Text>
<Switch
value={preventRecognizers}
onValueChange={setPreventRecognizers}
/>
</View>

<GestureDetector gesture={panGesture}>
<View
style={styles.touchArea}
onStartShouldSetResponder={() => {
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;
}}>
<Text style={styles.touchAreaLabel}>Drag me</Text>
</View>
</GestureDetector>

<Feedback ref={feedbackRef} duration={1300} />
<View style={styles.logContainer}>
{events.map((item) => (
<Text
key={item}
style={[
styles.logLine,
item.includes('GH pan ACTIVE') && styles.logLineActive,
]}>
{item}
</Text>
))}
</View>
</View>
);
}

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',
},
touchAreaLabel: {
color: COLORS.NAVY,
fontWeight: '700',
fontSize: 18,
},
settingsRow: {
width: '100%',
maxWidth: 340,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
settingsLabel: {
color: COLORS.NAVY,
fontSize: 14,
fontWeight: '600',
},
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',
},
logLineActive: {
color: '#1565c0',
fontWeight: 'bold',
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,16 @@ 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`.

<HeaderWithBadges platforms={['ios', 'android']}>
### preventRecognizers
</HeaderWithBadges>

```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.
Comment thread
coado marked this conversation as resolved.
Outdated
4 changes: 2 additions & 2 deletions packages/docs-gesture-handler/docs/fundamentals/root-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<T>
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ class GestureHandlerOrchestrator(
private var finishedHandlersCleanupScheduled = false
private var activationIndex = 0

var onPreventRecognizersRequested: ((GestureHandler) -> Unit)? = null
var onPreventRecognizersReleased: ((GestureHandler) -> Unit)? = null

/**
* Should be called from the view wrapper
*/
Expand Down Expand Up @@ -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 }) {
onPreventRecognizersReleased?.invoke(handler)
}
}

if (isFinished(newState)) {
// We have to loop through copy in order to avoid modifying collection
// while iterating over its elements
Expand Down Expand Up @@ -228,6 +239,10 @@ class GestureHandlerOrchestrator(
}
cleanupAwaitingHandlers()

if (handler.preventRecognizers) {
onPreventRecognizersRequested?.invoke(handler)
}

Comment thread
coado marked this conversation as resolved.
Outdated
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:
rootView,
).apply {
minimumAlphaForTraversal = MIN_ALPHA_FOR_TOUCH
onPreventRecognizersRequested = { _ ->
shouldIntercept = true
Comment thread
coado marked this conversation as resolved.
Outdated
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()
}
onPreventRecognizersReleased = { _ ->
shouldIntercept = false
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to reset shouldIntercept here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the last active handler with cancelsJSReponder=true deactivates, this method is called so that RN touch events are no longer intercepted.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check how this behaves with iOS system recognizers? i.e., two simultaneous gestures: A with cancelsJSReponder=true, B with cancelsJSReponder=false, and a JS responder/native view. Both A and B activate, then A finishes while B stays active. Does the JS responder/native view work in this scenario?

I think this is the case where this would matter on Android, but it would also allow native components to start handling touch in addition to the JS responder. Ideally, both platforms would be aligned on this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JS responder doesn't work when the A is activated and starts working when A deactivates (regardless if B is activated or not).

}
}
jsGestureHandler = RootViewGestureHandler(handlerTag = -wrappedViewTag)
registry.registerHandler(jsGestureHandler)
Expand Down Expand Up @@ -92,18 +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() {
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 requestDisallowInterceptTouchEvent() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ - (void)resetConfig
self.testID = nil;
self.manualActivation = NO;
_shouldCancelWhenOutside = NO;
_preventRecognizers = YES;
_hitSlop = RNGHHitSlopEmpty;
_needsPointerData = NO;
_dispatchesAnimatedEvents = NO;
Expand Down Expand Up @@ -164,6 +165,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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,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
Comment thread
coado marked this conversation as resolved.
Outdated
// recognizer is from Gesture Handler, otherwise we might break some interactions
RNGestureHandler *handler = [RNGestureHandler findGestureHandlerByRecognizer:preventingGestureRecognizer];
if (handler != nil) {
if (handler != nil && handler.preventRecognizers) {
[self.delegate gestureRecognizer:preventingGestureRecognizer didActivateInViewWithTouchHandler:self.view];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {

export const ALLOWED_PROPS = [
...baseGestureHandlerWithDetectorProps,
'preventRecognizers',
...tapGestureHandlerProps,
...panGestureHandlerProps,
...panGestureHandlerCustomNativeProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function NativeDetector<
: HostGestureDetector;

ensureNativeDetectorComponent(NativeDetectorComponent);

Comment thread
coado marked this conversation as resolved.
Outdated
configureRelations(gesture);

const handlerTags = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const CommonConfig = new Set<keyof CommonGestureConfig>([
'mouseButton',
'testID',
'cancelsTouchesInView',
'preventRecognizers',
'manualActivation',
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export type CommonGestureConfig = {
activeCursor?: ActiveCursor | undefined;
mouseButton?: MouseButton | undefined;
cancelsTouchesInView?: boolean | undefined;
preventRecognizers?: boolean | undefined;
manualActivation?: boolean | undefined;
},
ActiveCursor | MouseButton
Expand Down