Skip to content
Draft
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
54 changes: 34 additions & 20 deletions apps/src/screens/Modals.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { View, StyleSheet, Text } from 'react-native';
import {
createNativeStackNavigator,
NativeStackNavigationProp,
} from '@react-navigation/native-stack';
import { Button, Alert } from '../shared';
import { Modal } from 'react-native-screens';

type StackParamList = {
Main: undefined;
Expand All @@ -19,25 +20,38 @@ interface MainScreenProps {
navigation: NativeStackNavigationProp<StackParamList, 'Main'>;
}

const MainScreen = ({ navigation }: MainScreenProps): React.JSX.Element => (
<View style={{ ...styles.container, backgroundColor: 'bisque' }}>
<Button title="Open modal" onPress={() => navigation.navigate('Modal')} />
<Button
title="Open fullscreen modal"
onPress={() => navigation.navigate('FullscreenModal')}
/>
<Button title="Open alert" onPress={() => navigation.navigate('Alert')} />
<Button
title="Open contained modal"
onPress={() => navigation.navigate('ContainedModal')}
/>
<Button
title="Open pageSheet"
onPress={() => navigation.navigate('PageSheet')}
/>
<Button onPress={() => navigation.pop()} title="🔙 Back to Examples" />
</View>
);
const MainScreen = ({ navigation }: MainScreenProps): React.JSX.Element => {
const [showModal, setShowModal] = React.useState(false);

return (
<View style={{ ...styles.container, backgroundColor: 'bisque' }}>
<Button title="Open modal" onPress={() => navigation.navigate('Modal')} />
<Button
title="Open fullscreen modal"
onPress={() => navigation.navigate('FullscreenModal')}
/>
<Button title="Open alert" onPress={() => navigation.navigate('Alert')} />
<Button
title="Open contained modal"
onPress={() => navigation.navigate('ContainedModal')}
/>
<Button
title="Open pageSheet"
onPress={() => navigation.navigate('PageSheet')}
/>
<Button
title="Open new modal component"
onPress={() => setShowModal(true)}
/>
<Button onPress={() => navigation.pop()} title="🔙 Back to Examples" />

<Modal presented={showModal} onDismiss={() => setShowModal(false)} presentation='pageSheet' sheetAllowedDetents={[0.2, 0.7]}>
<Text>Modal content</Text>
<Button title="Close modal" onPress={() => setShowModal(false)} />
</Modal>
</View>
);
};

interface ModalScreenProps {
navigation: NativeStackNavigationProp<StackParamList, 'Modal'>;
Expand Down
11 changes: 11 additions & 0 deletions ios/RNSModal.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#import "RNSReactBaseView.h"

NS_ASSUME_NONNULL_BEGIN

@interface RNSModal : RNSReactBaseView

@property (nonatomic) NSArray<NSNumber *> *sheetAllowedDetents;

@end

NS_ASSUME_NONNULL_END
240 changes: 240 additions & 0 deletions ios/RNSModal.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
#import "RNSModal.h"
#import "RNSConvert.h"
#import <React/RCTConversions.h>
#import <React/RCTSurfaceTouchHandler.h>
#import <react/renderer/components/rnscreens/ComponentDescriptors.h>
#import <react/renderer/components/rnscreens/EventEmitters.h>
#import <react/renderer/components/rnscreens/Props.h>

namespace react = facebook::react;

static UIModalPresentationStyle RNSUIModalPresentationStyleFromCpp(react::RNSModalPresentation presentation)
{
switch (presentation) {
case react::RNSModalPresentation::Automatic:
return UIModalPresentationAutomatic;
case react::RNSModalPresentation::FullScreen:
return UIModalPresentationFullScreen;
case react::RNSModalPresentation::PageSheet:
return UIModalPresentationPageSheet;

Check failure on line 19 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'UIModalPresentationPageSheet' is unavailable: not available on tvOS
case react::RNSModalPresentation::FormSheet:
return UIModalPresentationFormSheet;
case react::RNSModalPresentation::CurrentContext:
return UIModalPresentationCurrentContext;
case react::RNSModalPresentation::Custom:
return UIModalPresentationCustom;
case react::RNSModalPresentation::OverFullScreen:
return UIModalPresentationOverFullScreen;
case react::RNSModalPresentation::OverCurrentContext:
return UIModalPresentationOverCurrentContext;
case react::RNSModalPresentation::Popover:
return UIModalPresentationPopover;

Check failure on line 31 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'UIModalPresentationPopover' is unavailable: not available on tvOS
}
}

@interface RNSModal () <UIAdaptivePresentationControllerDelegate>
@end

@implementation RNSModal {
UIViewController *_sheetViewController;
RCTSurfaceTouchHandler *_touchHandler;
NSMutableArray<UIView *> *_reactSubviews;
NSArray<NSNumber *> *_sheetAllowedDetents;
}

- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
static const auto defaultProps = std::make_shared<const react::RNSModalProps>();
_props = defaultProps;
_reactSubviews = [NSMutableArray new];
_sheetViewController = [[UIViewController alloc] init];

_touchHandler = [RCTSurfaceTouchHandler new];
[_touchHandler attachToView:_sheetViewController.view];

// This view is a logical container only; the content lives inside the presented sheet.
self.hidden = YES;
}
return self;
}

- (void)dealloc
{
if (_sheetViewController.presentingViewController) {
[_sheetViewController dismissViewControllerAnimated:NO completion:nil];
}
}

#pragma mark - Children

- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
{
[_sheetViewController.view insertSubview:childComponentView atIndex:index];
[_reactSubviews insertObject:childComponentView atIndex:index];
}

- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
{
[childComponentView removeFromSuperview];
[_reactSubviews removeObjectAtIndex:index];
}

#pragma mark - Props

- (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::Props::Shared &)oldProps
{
const auto &oldModalProps = static_cast<const react::RNSModalProps &>(*_props);
const auto &newModalProps = static_cast<const react::RNSModalProps &>(*props);

_sheetViewController.modalPresentationStyle = RNSUIModalPresentationStyleFromCpp(newModalProps.presentation);

if (newModalProps.sheetAllowedDetents != oldModalProps.sheetAllowedDetents) {
_sheetAllowedDetents = [RNSConvert detentFractionsArrayFromVector:newModalProps.sheetAllowedDetents];
if (_sheetViewController.presentingViewController) {
[self applyDetentsToSheet:_sheetViewController.sheetPresentationController animate:YES];

Check failure on line 95 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'sheetPresentationController' is unavailable: not available on tvOS
}
}

if (oldModalProps.presented != newModalProps.presented) {
if (newModalProps.presented) {
[self presentSheet];
} else {
[self dismissSheet];
}
}

[super updateProps:props oldProps:oldProps];
}

- (void)presentSheet
{
if (_sheetViewController.presentingViewController) {
return;
}

UIViewController *presentingVC = [self findPresentingViewController];
if (!presentingVC) {
return;
}

_sheetViewController.presentationController.delegate = self;

UISheetPresentationController *sheet = _sheetViewController.sheetPresentationController;

Check failure on line 123 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'sheetPresentationController' is unavailable: not available on tvOS

Check failure on line 123 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'UISheetPresentationController' is unavailable: not available on tvOS
if (sheet != nil) {
[self applyDetentsToSheet:sheet animate:NO];
sheet.prefersGrabberVisible = YES;
sheet.prefersScrollingExpandsWhenScrolledToEdge = YES;
}

[presentingVC presentViewController:_sheetViewController animated:YES completion:nil];
}

- (void)applyDetentsToSheet:(UISheetPresentationController *)sheet animate:(BOOL)animate

Check failure on line 133 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'UISheetPresentationController' is unavailable: not available on tvOS
{
if (sheet == nil) {
return;
}

void (^applyBlock)(void) = ^{
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 160000
if (@available(iOS 16.0, *)) {
if (_sheetAllowedDetents.count > 0) {
sheet.detents = [self detentsFromMaxHeightFractions:_sheetAllowedDetents];
return;
}
}
#endif
// Fallback to system detents for iOS < 16 or when no custom detents specified.
if (_sheetAllowedDetents.count == 1 && _sheetAllowedDetents[0].floatValue < 1.0) {
sheet.detents = @[ [UISheetPresentationControllerDetent mediumDetent] ];

Check failure on line 150 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'UISheetPresentationControllerDetent' is unavailable: not available on tvOS
} else if (_sheetAllowedDetents.count == 1 && _sheetAllowedDetents[0].floatValue >= 1.0) {
sheet.detents = @[ [UISheetPresentationControllerDetent largeDetent] ];

Check failure on line 152 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'UISheetPresentationControllerDetent' is unavailable: not available on tvOS
} else {
// Default: both medium and large detents (matches previous behaviour and the [1.0] default).
sheet.detents = @[
[UISheetPresentationControllerDetent mediumDetent],

Check failure on line 156 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'UISheetPresentationControllerDetent' is unavailable: not available on tvOS
[UISheetPresentationControllerDetent largeDetent],
];
}
};

if (animate) {
[sheet animateChanges:applyBlock];
} else {
applyBlock();
}
}

#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 160000
- (NSArray<UISheetPresentationControllerDetent *> *)detentsFromMaxHeightFractions:(NSArray<NSNumber *> *)fractions

Check failure on line 170 in ios/RNSModal.mm

View workflow job for this annotation

GitHub Actions / build

'UISheetPresentationControllerDetent' is unavailable: not available on tvOS
API_AVAILABLE(ios(16.0))
{
NSMutableArray<UISheetPresentationControllerDetent *> *customDetents =
[NSMutableArray arrayWithCapacity:fractions.count];
[fractions enumerateObjectsUsingBlock:^(NSNumber *fraction, NSUInteger index, BOOL *stop) {
UISheetPresentationControllerDetentIdentifier ident = [[NSNumber numberWithUnsignedInteger:index] stringValue];
[customDetents addObject:[UISheetPresentationControllerDetent
customDetentWithIdentifier:ident
resolver:^CGFloat(
id<UISheetPresentationControllerDetentResolutionContext> ctx) {
return MIN(ctx.maximumDetentValue,
ctx.maximumDetentValue * fraction.floatValue);
}]];
}];
return customDetents;
}
#endif

- (void)dismissSheet
{
if (_sheetViewController.presentingViewController) {
[_sheetViewController dismissViewControllerAnimated:YES completion:nil];
}
}

- (UIViewController *)findPresentingViewController
{
UIResponder *responder = self;
while (responder != nil) {
if ([responder isKindOfClass:[UIViewController class]]) {
return (UIViewController *)responder;
}
responder = responder.nextResponder;
}
return self.window.rootViewController;
}

#pragma mark - UIAdaptivePresentationControllerDelegate

- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController
{
if (_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const react::RNSModalEventEmitter>(_eventEmitter)
->onDismiss(react::RNSModalEventEmitter::OnDismiss{});
}
}

- (NSArray<UIView *> *)reactSubviews
{
return _reactSubviews;
}

#pragma mark - RCTComponentViewProtocol

+ (BOOL)shouldBeRecycled
{
return NO;
}

+ (react::ComponentDescriptorProvider)componentDescriptorProvider
{
return react::concreteComponentDescriptorProvider<react::RNSModalComponentDescriptor>();
}

@end

Class<RCTComponentViewProtocol> RNSModalCls(void)
{
return RNSModal.class;
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@
},
"RNSScrollViewMarker": {
"className": "RNSScrollViewMarkerComponentView"
},
"RNSModal": {
"className": "RNSModal"
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react';
import ModalNativeComponent from '../fabric/ModalNativeComponent';
import { ModalProps } from './Modal.types';
import { resolveSheetAllowedDetents } from './helpers/sheet';

export default function Modal({ sheetAllowedDetents, ...props }: ModalProps) {
const resolvedSheetAllowedDetents = resolveSheetAllowedDetents(sheetAllowedDetents);
return <ModalNativeComponent {...props} sheetAllowedDetents={resolvedSheetAllowedDetents} />;
}
14 changes: 14 additions & 0 deletions src/components/Modal.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ViewProps } from "react-native";
import type { ModalPresentation } from '../fabric/ModalNativeComponent';
import type { ScreenProps } from '../types';

export type { ModalPresentation };

export interface ModalProps {
children: ViewProps['children'];
style?: ViewProps['style'];
presented?: boolean;
presentation?: ModalPresentation;
onDismiss?: () => void;
sheetAllowedDetents?: ScreenProps['sheetAllowedDetents'];
}
25 changes: 25 additions & 0 deletions src/fabric/ModalNativeComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { codegenNativeComponent } from 'react-native';
import type { CodegenTypes as CT, ViewProps } from 'react-native';

export type ModalPresentation =
| 'automatic'
| 'fullScreen'
| 'pageSheet'
| 'formSheet'
| 'currentContext'
| 'custom'
| 'overFullScreen'
| 'overCurrentContext'
| 'popover';

// eslint-disable-next-line @typescript-eslint/ban-types
type ModalEvent = Readonly<{}>;

interface NativeProps extends ViewProps {
presented?: boolean;
presentation?: CT.WithDefault<ModalPresentation, 'pageSheet'>;
onDismiss?: CT.DirectEventHandler<ModalEvent>;
sheetAllowedDetents?: number[];
}

export default codegenNativeComponent<NativeProps>('RNSModal', {});
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export { default as ScreenStackItem } from './components/ScreenStackItem';
export { default as FullWindowOverlay } from './components/FullWindowOverlay';
export { default as ScreenFooter } from './components/ScreenFooter';
export { default as ScreenContentWrapper } from './components/ScreenContentWrapper';
export { default as Modal } from './components/Modal';

/**
* Utils
Expand Down
Loading