Skip to content

Implement interactive dismiss for iOS modals #38641

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions packages/react-native/Libraries/Modal/Modal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface ModalPropsIOS {
>
| undefined;

preventNativeDismiss?: boolean | undefined;

/**
* The `onDismiss` prop allows passing a function that will be called once the modal has been dismissed.
*/
Expand Down
16 changes: 16 additions & 0 deletions packages/react-native/Libraries/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ export type Props = $ReadOnly<{|
*/
hardwareAccelerated?: ?boolean,

/**
* A drag-to-dismiss gesture is always present with `presentationStyle` set to
* either `formSheet` or `pageSheet`. If `preventNativeDismiss` is set to
* `true` (the default), the modal will drag down a very short distance, and
* then stop. When the user releases, `onRequestClose` is called.
*
* When this prop is set to `false`, the user will be able to drag the modal
* all the way off the bottom of the screen. Now when the user releases,
* `onDismiss` is called. You **must** set `visible` to `false` in response
* to this.
*
* This prop only works on iOS.
*/
preventNativeDismiss?: ?boolean,

/**
* The `visible` prop determines whether your modal is visible.
*
Expand Down Expand Up @@ -250,6 +265,7 @@ class Modal extends React.Component<Props> {
presentationStyle={presentationStyle}
transparent={this.props.transparent}
hardwareAccelerated={this.props.hardwareAccelerated}
preventNativeDismiss={this.props.preventNativeDismiss}
onRequestClose={this.props.onRequestClose}
onShow={this.props.onShow}
onDismiss={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ type NativeProps = $ReadOnly<{|
*/
hardwareAccelerated?: WithDefault<boolean, false>,

/**
* A drag-to-dismiss gesture is always present with `presentationStyle` set to
* either `formSheet` or `pageSheet`. If `preventNativeDismiss` is set to
* `true` (the default), the modal will drag down a very short distance, and
* then stop. When the user releases, `onRequestClose` is called.
*
* When this prop is set to `false`, the user will be able to drag the modal
* all the way off the bottom of the screen. Now when the user releases,
* `onDismiss` is called. You **must** set `visible` to `false` in response
* to this.
*
* This prop only works on iOS.
*/
preventNativeDismiss?: WithDefault<boolean, true>,

/**
* The `onRequestClose` callback is called when the user taps the hardware
* back button on Android or the menu button on Apple TV.
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native/React/Views/RCTModalHostView.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

@property (nonatomic, copy) RCTDirectEventBlock onShow;
@property (nonatomic, assign) BOOL visible;
@property (nonatomic, assign) BOOL preventNativeDismiss;

// Android only
@property (nonatomic, assign) BOOL statusBarTranslucent;
Expand All @@ -37,7 +38,7 @@
@property (nonatomic, copy) NSArray<NSString *> *supportedOrientations;
@property (nonatomic, copy) RCTDirectEventBlock onOrientationChange;

// Fabric only
@property (nonatomic, copy) RCTDirectEventBlock onRequestClose;
@property (nonatomic, copy) RCTDirectEventBlock onDismiss;

- (instancetype)initWithBridge:(RCTBridge *)bridge NS_DESIGNATED_INITIALIZER;
Expand Down
15 changes: 12 additions & 3 deletions packages/react-native/React/Views/RCTModalHostView.m
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ @implementation RCTModalHostView {
RCTTouchHandler *_touchHandler;
UIView *_reactSubview;
UIInterfaceOrientation _lastKnownOrientation;
RCTDirectEventBlock _onRequestClose;
}

RCT_NOT_IMPLEMENTED(-(instancetype)initWithFrame : (CGRect)frame)
Expand All @@ -33,6 +32,8 @@ @implementation RCTModalHostView {
- (instancetype)initWithBridge:(RCTBridge *)bridge
{
if ((self = [super initWithFrame:CGRectZero])) {
_preventNativeDismiss = YES;

_bridge = bridge;
_modalViewController = [RCTModalHostViewController new];
UIView *containerView = [UIView new];
Expand All @@ -58,9 +59,9 @@ - (void)notifyForBoundsChange:(CGRect)newBounds
}
}

- (void)setOnRequestClose:(RCTDirectEventBlock)onRequestClose
- (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController
{
_onRequestClose = onRequestClose;
return !_preventNativeDismiss;
}

- (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)controller
Expand All @@ -70,6 +71,13 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)co
}
}

- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController
{
if (_onDismiss) {
_onDismiss(nil);
}
}

- (void)notifyForOrientationChange
{
if (!_onOrientationChange) {
Expand Down Expand Up @@ -173,6 +181,7 @@ - (void)ensurePresentedOnlyIfNeeded
RCTAssert(self.reactViewController, @"Can't present modal view controller without a presenting view controller");

_modalViewController.supportedInterfaceOrientations = [self supportedOrientationsMask];
_modalViewController.modalInPresentation = self.preventNativeDismiss;

if ([self.animationType isEqualToString:@"fade"]) {
_modalViewController.modalTransitionStyle = UIModalTransitionStyleCrossDissolve;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ - (void)invalidate
RCT_EXPORT_VIEW_PROPERTY(transparent, BOOL)
RCT_EXPORT_VIEW_PROPERTY(statusBarTranslucent, BOOL)
RCT_EXPORT_VIEW_PROPERTY(hardwareAccelerated, BOOL)
RCT_EXPORT_VIEW_PROPERTY(preventNativeDismiss, BOOL)
RCT_EXPORT_VIEW_PROPERTY(animated, BOOL)
RCT_EXPORT_VIEW_PROPERTY(onShow, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(identifier, NSNumber)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ public void setHardwareAccelerated(ReactModalHostView view, boolean hardwareAcce
view.setHardwareAccelerated(hardwareAccelerated);
}

@Override
@ReactProp(name = "preventNativeDismiss")
public void setPreventNativeDismiss(ReactModalHostView view, boolean preventNativeDismiss) {
// iOS only
}

@Override
@ReactProp(name = "visible")
public void setVisible(ReactModalHostView view, boolean visible) {
Expand Down
33 changes: 28 additions & 5 deletions packages/rn-tester/js/examples/Modal/ModalPresentation.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ const presentationStyles = [
'formSheet',
'overFullScreen',
];
const iOSActions = ['None', 'On Dismiss', 'On Show'];
const iOSActions = ['None', 'On Dismiss', 'On Request Close', 'On Show'];
const noniOSActions = ['None', 'On Show'];

function ModalPresentation() {
const [animationType, setAnimationType] = React.useState('none');
const [transparent, setTransparent] = React.useState(false);
const [visible, setVisible] = React.useState(false);
const [hardwareAccelerated, setHardwareAccelerated] = React.useState(false);
const [preventNativeDismiss, setPreventNativeDismiss] = React.useState(true);
const [statusBarTranslucent, setStatusBarTranslucent] = React.useState(false);
const [presentationStyle, setPresentationStyle] =
React.useState('fullScreen');
Expand All @@ -48,15 +49,23 @@ function ModalPresentation() {
const [currentOrientation, setCurrentOrientation] = React.useState('unknown');
const [action, setAction] = React.useState('None');
const actions = Platform.OS === 'ios' ? iOSActions : noniOSActions;

const onRequestClose = () => {
setVisible(false);
if (action === 'On Request Close') {
alert('onRequestClose');
}
};

const onDismiss = () => {
setVisible(false);
if (action === 'onDismiss') {
if (action === 'On Dismiss') {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm pretty sure these were always broken. I've fixed them anyway

alert('onDismiss');
}
};

const onShow = () => {
if (action === 'onShow') {
if (action === 'On Show') {
alert('onShow');
}
};
Expand All @@ -80,9 +89,10 @@ function ModalPresentation() {
presentationStyle={presentationStyle}
transparent={transparent}
hardwareAccelerated={hardwareAccelerated}
preventNativeDismiss={preventNativeDismiss}
statusBarTranslucent={statusBarTranslucent}
visible={visible}
onRequestClose={onDismiss}
onRequestClose={onRequestClose}
supportedOrientations={supportedOrientations[supportedOrientationKey]}
onOrientationChange={onOrientationChange}
onDismiss={onDismiss}
Expand All @@ -101,7 +111,9 @@ function ModalPresentation() {
It is currently displayed in {currentOrientation} mode.
</Text>
) : null}
<RNTesterButton onPress={onDismiss}>Close</RNTesterButton>
<RNTesterButton onPress={() => setVisible(false)}>
Close
</RNTesterButton>
</View>
</View>
</Modal>
Expand Down Expand Up @@ -178,6 +190,17 @@ function ModalPresentation() {
</Text>
) : null}
</View>
<View style={styles.block}>
<View style={styles.rowWithSpaceBetween}>
<Text style={styles.title}>Prevent Native Dismiss</Text>
<Switch
value={preventNativeDismiss}
onValueChange={() =>
setPreventNativeDismiss(!preventNativeDismiss)
}
/>
</View>
</View>
<View style={styles.block}>
<Text style={styles.title}>Supported Orientation</Text>
<View style={styles.row}>
Expand Down