From 247c52f7394e049d0c97085cb9f051dbc1591bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Tue, 20 May 2025 22:31:37 +0200 Subject: [PATCH 1/3] fix: allow to interactively swipe down the modal --- .../Modal/RCTFabricModalHostViewController.mm | 2 -- .../ComponentViews/Modal/RCTModalHostViewComponentView.mm | 7 +++++++ packages/rn-tester/js/examples/Modal/ModalPresentation.js | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm index a1fd1eeffd4..eb100a437d5 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm @@ -22,8 +22,6 @@ - (instancetype)init } _touchHandler = [RCTSurfaceTouchHandler new]; - self.modalInPresentation = YES; - return self; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm index a1f484dbad2..abb47044e3c 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm @@ -283,6 +283,13 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)co } } +- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { + auto eventEmitter = [self modalEventEmitter]; + if (eventEmitter) { + eventEmitter->onRequestClose({}); + } +} + @end #ifdef __cplusplus diff --git a/packages/rn-tester/js/examples/Modal/ModalPresentation.js b/packages/rn-tester/js/examples/Modal/ModalPresentation.js index fe310ba7b22..ee295133a2a 100644 --- a/packages/rn-tester/js/examples/Modal/ModalPresentation.js +++ b/packages/rn-tester/js/examples/Modal/ModalPresentation.js @@ -49,6 +49,7 @@ function ModalPresentation() { const onRequestClose = useCallback(() => { console.log('onRequestClose'); + setProps(prev => ({...prev, visible: false})); }, []); const [props, setProps] = useState({ From 43e545bbf2f243a3a769e05ab1a069f7e49cc15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Wed, 21 May 2025 18:55:31 +0200 Subject: [PATCH 2/3] fix: implement old arch, add warnings, fix types --- packages/react-native/Libraries/Modal/Modal.js | 10 ++++++++-- packages/react-native/React/Views/RCTModalHostView.m | 6 ++++++ .../React/Views/RCTModalHostViewController.m | 2 -- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/react-native/Libraries/Modal/Modal.js b/packages/react-native/Libraries/Modal/Modal.js index b3690c6f1d0..8e3eed25ee9 100644 --- a/packages/react-native/Libraries/Modal/Modal.js +++ b/packages/react-native/Libraries/Modal/Modal.js @@ -86,9 +86,9 @@ export type ModalBaseProps = { */ visible?: ?boolean, /** - * The `onRequestClose` callback is called when the user taps the hardware back button on Android or the menu button on Apple TV. + * The `onRequestClose` callback is called when the user taps the hardware back button on Android, dismisses the sheet using a gesture on iOS or the menu button on Apple TV. * - * This is required on Apple TV and Android. + * This is required on iOS and Android. */ // onRequestClose?: (event: NativeSyntheticEvent) => void; onRequestClose?: ?DirectEventHandler, @@ -192,6 +192,12 @@ function confirmProps(props: ModalProps) { 'Modal with translucent navigation bar and without translucent status bar is not supported.', ); } + + if (!props.onRequestClose) { + console.warn( + 'Modal requires the onRequestClose prop. This is necessary to prevent state corruption.', + ); + } } } diff --git a/packages/react-native/React/Views/RCTModalHostView.m b/packages/react-native/React/Views/RCTModalHostView.m index 5e1c2a6d8f1..a20485bdb47 100644 --- a/packages/react-native/React/Views/RCTModalHostView.m +++ b/packages/react-native/React/Views/RCTModalHostView.m @@ -70,6 +70,12 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)co } } +- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { + if (_onRequestClose != nil) { + _onRequestClose(nil); + } +} + - (void)notifyForOrientationChange { if (!_onOrientationChange) { diff --git a/packages/react-native/React/Views/RCTModalHostViewController.m b/packages/react-native/React/Views/RCTModalHostViewController.m index 41db3b01431..f0277f8c35e 100644 --- a/packages/react-native/React/Views/RCTModalHostViewController.m +++ b/packages/react-native/React/Views/RCTModalHostViewController.m @@ -22,8 +22,6 @@ - (instancetype)init return nil; } - self.modalInPresentation = YES; - _preferredStatusBarStyle = [RCTUIStatusBarManager() statusBarStyle]; _preferredStatusBarHidden = [RCTUIStatusBarManager() isStatusBarHidden]; From c00ea414a610c157c5265c46edd021a67bcdc01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Mon, 16 Jun 2025 16:25:44 +0200 Subject: [PATCH 3/3] feat: make it a prop --- .../react-native/Libraries/Modal/Modal.d.ts | 10 ++++++++-- packages/react-native/Libraries/Modal/Modal.js | 17 ++++++++++++++--- .../Modal/RCTModalHostViewComponentView.mm | 11 ++++++++++- .../react-native/React/Views/RCTModalHostView.h | 1 + .../react-native/React/Views/RCTModalHostView.m | 10 +++++++++- .../React/Views/RCTModalHostViewManager.m | 1 + .../RCTModalHostViewNativeComponent.js | 6 ++++++ .../js/examples/Modal/ModalPresentation.js | 17 +++++++++++++++++ 8 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/react-native/Libraries/Modal/Modal.d.ts b/packages/react-native/Libraries/Modal/Modal.d.ts index 21fca65479c..9c9e1ea5722 100644 --- a/packages/react-native/Libraries/Modal/Modal.d.ts +++ b/packages/react-native/Libraries/Modal/Modal.d.ts @@ -35,9 +35,9 @@ export interface ModalBaseProps { */ visible?: boolean | undefined; /** - * The `onRequestClose` callback is called when the user taps the hardware back button on Android or the menu button on Apple TV. + * The `onRequestClose` callback is called when the user taps the hardware back button on Android, dismisses the sheet using a gesture on iOS (when `allowSwipeDismissal` is set to true) or the menu button on Apple TV. * - * This is required on Apple TV and Android. + * This is required on iOS and Android. */ onRequestClose?: ((event: NativeSyntheticEvent) => void) | undefined; /** @@ -89,6 +89,12 @@ export interface ModalPropsIOS { onOrientationChange?: | ((event: NativeSyntheticEvent) => void) | undefined; + + /** + * Controls whether the modal can be dismissed by swiping down on iOS. + * This requires you to implement the `onRequestClose` prop to handle the dismissal. + */ + allowSwipeDismissal?: boolean | undefined; } export interface ModalPropsAndroid { diff --git a/packages/react-native/Libraries/Modal/Modal.js b/packages/react-native/Libraries/Modal/Modal.js index 8e3eed25ee9..2bdcbecc4dd 100644 --- a/packages/react-native/Libraries/Modal/Modal.js +++ b/packages/react-native/Libraries/Modal/Modal.js @@ -86,7 +86,7 @@ export type ModalBaseProps = { */ visible?: ?boolean, /** - * The `onRequestClose` callback is called when the user taps the hardware back button on Android, dismisses the sheet using a gesture on iOS or the menu button on Apple TV. + * The `onRequestClose` callback is called when the user taps the hardware back button on Android, dismisses the sheet using a gesture on iOS (when `allowSwipeDismissal` is set to true) or the menu button on Apple TV. * * This is required on iOS and Android. */ @@ -147,6 +147,12 @@ export type ModalPropsIOS = { // | ((event: NativeSyntheticEvent) => void) // | undefined; onOrientationChange?: ?DirectEventHandler, + + /** + * Controls whether the modal can be dismissed by swiping down on iOS. + * This requires you to implement the `onRequestClose` prop to handle the dismissal. + */ + allowSwipeDismissal?: ?boolean, }; export type ModalPropsAndroid = { @@ -193,9 +199,13 @@ function confirmProps(props: ModalProps) { ); } - if (!props.onRequestClose) { + if ( + Platform.OS === 'ios' && + props.allowSwipeDismissal === true && + !props.onRequestClose + ) { console.warn( - 'Modal requires the onRequestClose prop. This is necessary to prevent state corruption.', + 'Modal requires the onRequestClose prop when used with `allowSwipeDismissal`. This is necessary to prevent state corruption.', ); } } @@ -333,6 +343,7 @@ class Modal extends React.Component { onStartShouldSetResponder={this._shouldSetResponder} supportedOrientations={this.props.supportedOrientations} onOrientationChange={this.props.onOrientationChange} + allowSwipeDismissal={this.props.allowSwipeDismissal} testID={this.props.testID}> diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm index abb47044e3c..2f2f223e35d 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm @@ -124,6 +124,7 @@ - (RCTFabricModalHostViewController *)viewController _viewController = [RCTFabricModalHostViewController new]; _viewController.modalTransitionStyle = UIModalTransitionStyleCoverVertical; _viewController.delegate = self; + _viewController.modalInPresentation = YES; } return _viewController; } @@ -239,6 +240,7 @@ - (void)prepareForRecycle - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps { + const auto &oldViewProps = static_cast(*_props); const auto &newProps = static_cast(*props); #if !TARGET_OS_TV @@ -250,6 +252,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & self.viewController.modalTransitionStyle = transitionStyle; self.viewController.modalPresentationStyle = presentationConfiguration(newProps); + + if (oldViewProps.allowSwipeDismissal != newProps.allowSwipeDismissal) { + self.viewController.modalInPresentation = !newProps.allowSwipeDismissal; + } + _shouldPresent = newProps.visible; [self ensurePresentedOnlyIfNeeded]; @@ -285,7 +292,9 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)co - (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { auto eventEmitter = [self modalEventEmitter]; - if (eventEmitter) { + const auto &props = static_cast(*_props); + + if (eventEmitter && props.allowSwipeDismissal) { eventEmitter->onRequestClose({}); } } diff --git a/packages/react-native/React/Views/RCTModalHostView.h b/packages/react-native/React/Views/RCTModalHostView.h index 2fcdcaea83f..c7811587c60 100644 --- a/packages/react-native/React/Views/RCTModalHostView.h +++ b/packages/react-native/React/Views/RCTModalHostView.h @@ -24,6 +24,7 @@ @property (nonatomic, copy) RCTDirectEventBlock onShow; @property (nonatomic, assign) BOOL visible; +@property (nonatomic, assign) BOOL allowSwipeDismissal; // Android only @property (nonatomic, assign) BOOL statusBarTranslucent; diff --git a/packages/react-native/React/Views/RCTModalHostView.m b/packages/react-native/React/Views/RCTModalHostView.m index a20485bdb47..2ca29d23d9a 100644 --- a/packages/react-native/React/Views/RCTModalHostView.m +++ b/packages/react-native/React/Views/RCTModalHostView.m @@ -35,6 +35,7 @@ - (instancetype)initWithBridge:(RCTBridge *)bridge if ((self = [super initWithFrame:CGRectZero])) { _bridge = bridge; _modalViewController = [RCTModalHostViewController new]; + _modalViewController.modalInPresentation = YES; UIView *containerView = [UIView new]; containerView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth; _modalViewController.view = containerView; @@ -50,6 +51,13 @@ - (instancetype)initWithBridge:(RCTBridge *)bridge return self; } +- (void)setAllowSwipeDismissal:(BOOL)allowSwipeDismissal { + if (_allowSwipeDismissal != allowSwipeDismissal) { + _allowSwipeDismissal = allowSwipeDismissal; + _modalViewController.modalInPresentation = !allowSwipeDismissal; + } +} + - (void)notifyForBoundsChange:(CGRect)newBounds { if (_reactSubview && _isPresented) { @@ -71,7 +79,7 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)co } - (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { - if (_onRequestClose != nil) { + if (_onRequestClose != nil && _allowSwipeDismissal) { _onRequestClose(nil); } } diff --git a/packages/react-native/React/Views/RCTModalHostViewManager.m b/packages/react-native/React/Views/RCTModalHostViewManager.m index eccf05f9cb6..203d0b44134 100644 --- a/packages/react-native/React/Views/RCTModalHostViewManager.m +++ b/packages/react-native/React/Views/RCTModalHostViewManager.m @@ -119,6 +119,7 @@ - (void)invalidate RCT_EXPORT_VIEW_PROPERTY(onOrientationChange, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(visible, BOOL) RCT_EXPORT_VIEW_PROPERTY(onRequestClose, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(allowSwipeDismissal, BOOL) // Fabric only RCT_EXPORT_VIEW_PROPERTY(onDismiss, RCTDirectEventBlock) diff --git a/packages/react-native/src/private/specs_DEPRECATED/components/RCTModalHostViewNativeComponent.js b/packages/react-native/src/private/specs_DEPRECATED/components/RCTModalHostViewNativeComponent.js index 2be22d99104..3eb387a4ffa 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/components/RCTModalHostViewNativeComponent.js +++ b/packages/react-native/src/private/specs_DEPRECATED/components/RCTModalHostViewNativeComponent.js @@ -112,6 +112,12 @@ type NativeProps = $ReadOnly<{ */ animated?: WithDefault, + /** + * Controls whether the modal can be dismissed by swiping down on iOS. + * This requires you to implement the `onRequestClose` prop to handle the dismissal. + */ + allowSwipeDismissal?: WithDefault, + /** * The `supportedOrientations` prop allows the modal to be rotated to any of the specified orientations. * diff --git a/packages/rn-tester/js/examples/Modal/ModalPresentation.js b/packages/rn-tester/js/examples/Modal/ModalPresentation.js index ee295133a2a..5ac8e416fed 100644 --- a/packages/rn-tester/js/examples/Modal/ModalPresentation.js +++ b/packages/rn-tester/js/examples/Modal/ModalPresentation.js @@ -62,6 +62,7 @@ function ModalPresentation() { ios: 'fullScreen', default: undefined, }), + allowSwipeDismissal: false, supportedOrientations: Platform.select({ ios: ['portrait'], default: undefined, @@ -75,6 +76,7 @@ function ModalPresentation() { const hardwareAccelerated = props.hardwareAccelerated; const statusBarTranslucent = props.statusBarTranslucent; const navigationBarTranslucent = props.navigationBarTranslucent; + const allowSwipeDismissal = props.allowSwipeDismissal; const backdropColor = props.backdropColor; const backgroundColor = useContext(RNTesterThemeContext).BackgroundColor; @@ -132,6 +134,21 @@ function ModalPresentation() { } /> + + + + Allow Swipe Dismissal ⚫️ + + + setProps(prev => ({ + ...prev, + allowSwipeDismissal: enabled, + })) + } + /> + Presentation Style ⚫️