Skip to content

Commit 5b70c4b

Browse files
authored
[iOS] Fix js responder cancelation in modals (#4306)
## Description On iOS, when a gesture activates inside a `react-native-screens` route presented as `formSheet` or `modal`, the in-flight touch of a core RN `Pressable`/`Touchable` underneath is not cancelled — the press completes and `onPress` fires on release, alongside the gesture. In this PR registration walk-up now also stops at a modally-presented `RNSScreenView`, so the root recognizer is attached to the screen view and travels with it when `UIKit` reparents it. When the walk-up dead-ends at `nil`, registration is retried once on the next run loop turn, when the mounting transaction has finished and the hierarchy is connected. Fixes #4305 ## Test plan <details> <summary>Tested on the following code:</summary> ```tsx import { useNavigation } from '@react-navigation/native'; import { createNativeStackNavigator, type NativeStackNavigationProp, } from '@react-navigation/native-stack'; import React, { useState } from 'react'; import { Button, Pressable, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; // Repro for #4305 // // Ported to the v3 API (usePanGesture) with explicit `cancelsJSResponder`. // // iOS: when a Pan gesture activates inside a react-native-screens native-stack // route presented as `formSheet` (or `modal`), the in-flight JS-responder touch // of a core RN Pressable underneath is NOT cancelled — on release `onPress` // fires alongside the gesture. On a push route the pan activation cancels the // press as expected. // // How to test (iOS): // 1. Swipe the row horizontally on the home screen — the press counter must // NOT increment (control). // 2. Open the push route, swipe the row — counter must NOT increment. // 3. Open the formSheet / modal route, swipe the row — BUG: the counter // increments on release. function Row({ label }: { label: string }) { const [pressCount, setPressCount] = useState(0); const translateX = useSharedValue(0); const pan = usePanGesture({ activeOffsetX: [-12, 12], failOffsetY: [-12, 12], cancelsJSResponder: true, onUpdate: (e) => { 'worklet'; translateX.value = Math.min(0, e.translationX); }, onFinalize: () => { 'worklet'; translateX.value = withSpring(0); }, }); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], })); return ( <View style={styles.rowContainer}> <Text style={styles.caption}>{label}</Text> <GestureDetector gesture={pan}> <Animated.View style={animatedStyle}> <Pressable style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} onPress={() => { console.log(`onPress fired (${label})`); setPressCount((c) => c + 1); }}> <Text style={styles.rowText}>Swipe me left</Text> <Text style={styles.counter}> onPress fired: {pressCount} {pressCount > 0 ? '❌' : ''} </Text> </Pressable> </Animated.View> </GestureDetector> </View> ); } type StackParamList = { home: undefined; push: undefined; sheet: undefined; modal: undefined; }; function HomeScreen() { const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>(); return ( <View style={styles.screen}> <Row label="home screen (control — swipe must not press)" /> <Button title="Open push route" onPress={() => navigation.navigate('push')} /> <Button title="Open formSheet route" onPress={() => navigation.navigate('sheet')} /> <Button title="Open modal route" onPress={() => navigation.navigate('modal')} /> </View> ); } function PushScreen() { return ( <View style={styles.screen}> <Row label="push route (expected: swipe must not press)" /> </View> ); } function SheetScreen() { return ( <View style={styles.sheet}> <Row label="formSheet (bug: onPress fires after swipe)" /> <Text style={styles.hint}>Swipe down to dismiss</Text> </View> ); } function ModalScreen() { return ( <View style={styles.sheet}> <Row label="modal (bug: onPress fires after swipe)" /> <Text style={styles.hint}>Swipe down to dismiss</Text> </View> ); } const Stack = createNativeStackNavigator<StackParamList>(); export default function EmptyExample() { return ( <Stack.Navigator> <Stack.Screen name="home" component={HomeScreen} options={{ headerShown: false }} /> <Stack.Screen name="push" component={PushScreen} options={{ title: 'Push route' }} /> <Stack.Screen name="sheet" component={SheetScreen} options={{ presentation: 'formSheet', sheetAllowedDetents: [0.5], headerShown: false, }} /> <Stack.Screen name="modal" component={ModalScreen} options={{ presentation: 'modal', headerShown: false }} /> </Stack.Navigator> ); } const styles = StyleSheet.create({ screen: { flex: 1, justifyContent: 'center', gap: 16, padding: 20, backgroundColor: '#f5f5f7', }, sheet: { flex: 1, justifyContent: 'center', gap: 16, padding: 20, }, rowContainer: { gap: 6, }, caption: { fontSize: 13, color: '#555', }, row: { backgroundColor: '#a78bfa', borderRadius: 12, padding: 20, gap: 4, }, rowPressed: { backgroundColor: '#7c5cd6', }, rowText: { fontSize: 17, fontWeight: '600', color: '#1a1a2e', }, counter: { fontSize: 14, color: '#1a1a2e', }, hint: { textAlign: 'center', color: '#888', }, }); ``` </details>
1 parent c8d66b4 commit 5b70c4b

1 file changed

Lines changed: 57 additions & 5 deletions

File tree

packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,19 +276,47 @@ - (void)reattachHandlersIfNeeded
276276

277277
#pragma mark Root Views Management
278278

279+
#if !TARGET_OS_OSX
280+
static BOOL RNGHIsScreensTouchHandlerHost(RNGHUIView *view)
281+
{
282+
static Class fullWindowOverlayContainerClass;
283+
static Class screenViewClass;
284+
static SEL isModalSelector;
285+
static dispatch_once_t onceToken;
286+
dispatch_once(&onceToken, ^{
287+
fullWindowOverlayContainerClass = NSClassFromString(@"RNSFullWindowOverlayContainer");
288+
screenViewClass = NSClassFromString(@"RNSScreenView");
289+
isModalSelector = NSSelectorFromString(@"isModal");
290+
});
291+
292+
if (fullWindowOverlayContainerClass != nil && [view isKindOfClass:fullWindowOverlayContainerClass]) {
293+
return YES;
294+
}
295+
296+
if (screenViewClass != nil && [view isKindOfClass:screenViewClass]) {
297+
// For now we consider only modals
298+
return [view respondsToSelector:isModalSelector] && [[view valueForKey:@"isModal"] boolValue];
299+
}
300+
301+
return NO;
302+
}
303+
#endif // !TARGET_OS_OSX
304+
279305
- (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childView
306+
{
307+
[self registerViewWithGestureRecognizerAttachedIfNeeded:childView isRetry:NO];
308+
}
309+
310+
- (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childView isRetry:(BOOL)isRetry
280311
{
281312
RNGHUIView *touchHandlerView = childView;
282313

283314
#if !TARGET_OS_OSX
284-
Class fullWindowOverlayContainerClass = NSClassFromString(@"RNSFullWindowOverlayContainer");
285-
286315
if ([[childView reactViewController] isKindOfClass:[RCTFabricModalHostViewController class]]) {
287316
touchHandlerView = [childView reactViewController].view;
288317
} else {
289-
while (
290-
touchHandlerView != nil && ![touchHandlerView isKindOfClass:[RCTSurfaceView class]] &&
291-
(fullWindowOverlayContainerClass == nil || ![touchHandlerView isKindOfClass:fullWindowOverlayContainerClass])) {
318+
while (touchHandlerView != nil && ![touchHandlerView isKindOfClass:[RCTSurfaceView class]] &&
319+
!RNGHIsScreensTouchHandlerHost(touchHandlerView)) {
292320
touchHandlerView = touchHandlerView.superview;
293321
}
294322
}
@@ -299,6 +327,17 @@ - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childVie
299327
#endif // !TARGET_OS_OSX
300328

301329
if (touchHandlerView == nil) {
330+
#if !TARGET_OS_OSX
331+
// Handlers are attached while the mounting transaction is still in progress — the view's
332+
// ancestor chain may not be assembled yet (Fabric connects subtrees children-first), in
333+
// which case the walk above dead-ends before reaching any touch-handling root. Retry once
334+
// on the next run loop turn, when mounting has finished and the hierarchy is connected.
335+
if (!isRetry) {
336+
dispatch_async(dispatch_get_main_queue(), ^{
337+
[self registerViewWithGestureRecognizerAttachedIfNeeded:childView isRetry:YES];
338+
});
339+
}
340+
#endif
302341
return;
303342
}
304343

@@ -347,6 +386,19 @@ - (void)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
347386
break;
348387
}
349388
}
389+
390+
#if !TARGET_OS_OSX
391+
if (touchHandler == nil && [viewWithTouchHandler respondsToSelector:NSSelectorFromString(@"touchHandler")]) {
392+
// An RNSScreenView may not have the touch handler attached directly (e.g. touches on it are
393+
// still driven by an ancestor's touch handler) — ask react-native-screens for the one
394+
// responsible for this screen.
395+
id screenTouchHandler = [viewWithTouchHandler valueForKey:@"touchHandler"];
396+
if ([screenTouchHandler isKindOfClass:[RCTSurfaceTouchHandler class]]) {
397+
touchHandler = screenTouchHandler;
398+
}
399+
}
400+
#endif // !TARGET_OS_OSX
401+
350402
[touchHandler setEnabled:NO];
351403
[touchHandler setEnabled:YES];
352404
}

0 commit comments

Comments
 (0)