Skip to content

Commit 22fe56e

Browse files
authored
fix(iOS, Stack v4): Fix usePreventRemove callback not called when tapping formSheet backdrop (#3771)
## Description When using a `FormSheet` with `preventNativeDismiss` enabled, swiping down the sheet correctly triggers the event for dismiss cancellation to JS. However, tapping the dimming backdrop fails to trigger it. When `presentationControllerShouldDismiss:` returns `NO`, UIKit cancels the dismissal. For swipe gestures, it still calls the `presentationControllerDidAttemptToDismiss` method. For backdrop taps, it simply drops the touch event entirely without any notification. We can fix this by attaching a custom `UITapGestureRecognizer` directly to the `containerView` (covering the whole Screen) and simply ignoring taps that are inside the `FormSheet` or its content, which ensures the gesture only recognizes taps on the dimming view. Then, we can send an event back to JS via `notifyDismissCancelledWithDismissCount` if `preventNativeDismiss` is enabled. Closes: #3568 ## Changes - Added `backdropTapGestureRecognizer` and logic for sending proper event to the JS when backdrop was tapped under certain conditions. ## Before & after - visual documentation | Before | After | | --- | --- | | <video src="https://github.com/user-attachments/assets/aca68f50-f4a1-42b2-90ef-6658a074ec2c" /> | <video src="https://github.com/user-attachments/assets/b23f01e6-7a5f-499d-9299-ff945c850785" /> | ## Test plan Added Test3568, verified that there's no regression in #2129 ## Checklist - [x] Included code example that can be used to test this change. - [ ] Updated / created local changelog entries in relevant test files. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [ ] Ensured that CI passes
1 parent 63b3baa commit 22fe56e

3 files changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React from 'react';
2+
import { View, Text, Button, StyleSheet, Alert } from 'react-native';
3+
import {
4+
NavigationContainer,
5+
usePreventRemove,
6+
useNavigation,
7+
} from '@react-navigation/native';
8+
import {
9+
createNativeStackNavigator,
10+
NativeStackScreenProps,
11+
} from '@react-navigation/native-stack';
12+
import Colors from '../../shared/styling/Colors';
13+
14+
type StackParamList = {
15+
Home: undefined;
16+
FormSheet: undefined;
17+
};
18+
19+
const Stack = createNativeStackNavigator<StackParamList>();
20+
21+
function HomeScreen({
22+
navigation,
23+
}: NativeStackScreenProps<StackParamList, 'Home'>) {
24+
return (
25+
<View style={styles.container}>
26+
<Text style={styles.title}>
27+
FormSheet iOS usePreventRemove callback bug
28+
</Text>
29+
<Button
30+
title="Open sheet"
31+
onPress={() => navigation.navigate('FormSheet')}
32+
/>
33+
</View>
34+
);
35+
}
36+
37+
function FormSheetContent() {
38+
const [preventRemove, setPreventRemove] = React.useState(false);
39+
const navigation = useNavigation();
40+
41+
usePreventRemove(preventRemove, ({ data }) => {
42+
Alert.alert(
43+
'Discard changes?',
44+
'You have unsaved changes. Are you sure you want to leave?',
45+
[
46+
{
47+
text: 'Keep editing',
48+
style: 'cancel',
49+
onPress: () => {},
50+
},
51+
{
52+
text: 'Discard',
53+
style: 'destructive',
54+
onPress: () => navigation.dispatch(data.action),
55+
},
56+
],
57+
);
58+
});
59+
60+
return (
61+
<View style={styles.sheetContainer}>
62+
<Button
63+
title={preventRemove ? 'Prevent Remove: ON' : 'Prevent Remove: OFF'}
64+
onPress={() => setPreventRemove(existing => !existing)}
65+
/>
66+
<Text style={styles.sheetTitle}>Sheet Content</Text>
67+
<Text style={styles.sheetText}>
68+
Toggle the button above, then try to swipe down or close the sheet to
69+
see the Alert in action.
70+
</Text>
71+
</View>
72+
);
73+
}
74+
75+
export default function App() {
76+
return (
77+
<NavigationContainer>
78+
<Stack.Navigator>
79+
<Stack.Screen name="Home" component={HomeScreen} />
80+
<Stack.Screen
81+
name="FormSheet"
82+
component={FormSheetContent}
83+
options={{
84+
presentation: 'formSheet',
85+
headerShown: false,
86+
sheetAllowedDetents: 'fitToContents',
87+
sheetCornerRadius: 20,
88+
sheetGrabberVisible: true,
89+
contentStyle: {
90+
backgroundColor: Colors.White,
91+
},
92+
}}
93+
/>
94+
</Stack.Navigator>
95+
</NavigationContainer>
96+
);
97+
}
98+
99+
const styles = StyleSheet.create({
100+
container: {
101+
flex: 1,
102+
justifyContent: 'center',
103+
alignItems: 'center',
104+
},
105+
title: {
106+
fontSize: 20,
107+
fontWeight: 'bold',
108+
textAlign: 'center',
109+
},
110+
sheetContainer: {
111+
padding: 20,
112+
},
113+
sheetTitle: {
114+
fontSize: 24,
115+
fontWeight: 'bold',
116+
textAlign: 'center',
117+
marginVertical: 20,
118+
},
119+
sheetText: {
120+
fontSize: 16,
121+
textAlign: 'center',
122+
marginVertical: 10,
123+
},
124+
});

apps/src/tests/issue-tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export { default as Test3521 } from './Test3521';
176176
export { default as Test3522 } from './Test3522';
177177
export { default as Test3564 } from './Test3564';
178178
export { default as Test3566 } from './Test3566';
179+
export { default as Test3568 } from './Test3568';
179180
export { default as Test3576 } from './Test3576';
180181
export { default as Test3596 } from './Test3596';
181182
export { default as Test3611 } from './Test3611';

ios/RNSScreen.mm

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656

5757
@interface RNSScreenView () <
5858
UIAdaptivePresentationControllerDelegate,
59+
UIGestureRecognizerDelegate,
5960
#if !TARGET_OS_TV
6061
UISheetPresentationControllerDelegate,
6162
#endif
@@ -76,6 +77,7 @@ @implementation RNSScreenView {
7677
ContentWrapperBox _contentWrapperBox;
7778
bool _sheetHasInitialDetentSet;
7879
BOOL _shouldUpdateScrollEdgeEffects;
80+
UITapGestureRecognizer *_backdropTapGestureRecognizer;
7981
#ifdef RCT_NEW_ARCH_ENABLED
8082
RCTSurfaceTouchHandler *_touchHandler;
8183
react::RNSScreenShadowNode::ConcreteState::Shared _state;
@@ -608,6 +610,7 @@ - (void)notifyDismissCancelledWithDismissCount:(int)dismissCount
608610

609611
- (void)notifyWillAppear
610612
{
613+
[self setupBackdropTapGestureRecognizer];
611614
#ifdef RCT_NEW_ARCH_ENABLED
612615
// If screen is already unmounted then there will be no event emitter
613616
if (_eventEmitter != nullptr) {
@@ -880,6 +883,34 @@ - (void)presentationControllerDidDismiss:(UIPresentationController *)presentatio
880883
}
881884
}
882885

886+
- (void)setupBackdropTapGestureRecognizer
887+
{
888+
if (self.stackPresentation != RNSScreenStackPresentationFormSheet &&
889+
self.stackPresentation != RNSScreenStackPresentationPageSheet &&
890+
self.stackPresentation != RNSScreenStackPresentationModal) {
891+
return;
892+
}
893+
894+
UIPresentationController *presentationController = _controller.presentationController;
895+
896+
if (presentationController && presentationController.containerView && !_backdropTapGestureRecognizer) {
897+
_backdropTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
898+
action:@selector(handleBackdropTap:)];
899+
_backdropTapGestureRecognizer.delegate = self;
900+
_backdropTapGestureRecognizer.cancelsTouchesInView = NO;
901+
[presentationController.containerView addGestureRecognizer:_backdropTapGestureRecognizer];
902+
}
903+
}
904+
905+
- (void)handleBackdropTap:(UITapGestureRecognizer *)gesture
906+
{
907+
if (gesture.state == UIGestureRecognizerStateRecognized) {
908+
if (_preventNativeDismiss) {
909+
[self notifyDismissCancelledWithDismissCount:1];
910+
}
911+
}
912+
}
913+
883914
- (nullable RNSScreenStackHeaderConfig *)findHeaderConfig
884915
{
885916
// Fast path
@@ -1336,6 +1367,38 @@ - (void)safeAreaInsetsDidChange
13361367
[self dispatchSafeAreaDidChangeNotification];
13371368
}
13381369

1370+
#pragma mark - UIGestureRecognizerDelegate
1371+
1372+
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
1373+
{
1374+
if (gestureRecognizer == _backdropTapGestureRecognizer) {
1375+
// When native dismissal is not being prevented, this recognizer should not
1376+
// participate in handling touches to avoid interfering with UIKit.
1377+
if (!_preventNativeDismiss) {
1378+
return NO;
1379+
}
1380+
1381+
UIPresentationController *presentationController = _controller.presentationController;
1382+
1383+
// Ignore any touches that land inside the actual sheet content.
1384+
if (presentationController && presentationController.presentedView &&
1385+
[touch.view isDescendantOfView:presentationController.presentedView]) {
1386+
return NO;
1387+
}
1388+
return YES;
1389+
}
1390+
return YES;
1391+
}
1392+
1393+
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
1394+
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
1395+
{
1396+
if (gestureRecognizer == _backdropTapGestureRecognizer) {
1397+
return YES;
1398+
}
1399+
return NO;
1400+
}
1401+
13391402
#pragma mark - Fabric specific
13401403
#ifdef RCT_NEW_ARCH_ENABLED
13411404

0 commit comments

Comments
 (0)