#import #import "RNSScreen.h" #import "RNSScreenStackHeaderConfig.h" #import "RNSScreenContainer.h" #import #import #import @interface RNSScreenView () @end @implementation RNSScreenView { __weak RCTBridge *_bridge; RNSScreen *_controller; RCTTouchHandler *_touchHandler; CGRect _reactFrame; } @synthesize controller = _controller; - (instancetype)initWithBridge:(RCTBridge *)bridge { if (self = [super init]) { _bridge = bridge; _controller = [[RNSScreen alloc] initWithView:self]; _stackPresentation = RNSScreenStackPresentationPush; _stackAnimation = RNSScreenStackAnimationDefault; _gestureEnabled = YES; _replaceAnimation = RNSScreenReplaceAnimationPop; _dismissed = NO; } return self; } - (void)reactSetFrame:(CGRect)frame { _reactFrame = frame; UIViewController *parentVC = self.reactViewController.parentViewController; if (parentVC != nil && ![parentVC isKindOfClass:[UINavigationController class]]) { [super reactSetFrame:frame]; } // when screen is mounted under UINavigationController it's size is controller // by the navigation controller itself. That is, it is set to fill space of // the controller. In that case we ignore react layout system from managing // the screen dimensions and we wait for the screen VC to update and then we // pass the dimensions to ui view manager to take into account when laying out // subviews } - (UIViewController *)reactViewController { return _controller; } - (void)updateBounds { [_bridge.uiManager setSize:self.bounds.size forView:self]; } // Nil will be provided when activityState is set as an animated value and we change // it from JS to be a plain value (non animated). // In case when nil is received, we want to ignore such value and not make // any updates as the actual non-nil value will follow immediately. - (void)setActivityStateOrNil:(NSNumber *)activityStateOrNil { int activityState = [activityStateOrNil intValue]; if (activityStateOrNil != nil && activityState != _activityState) { _activityState = activityState; [_reactSuperview markChildUpdated]; } } - (void)setPointerEvents:(RCTPointerEvents)pointerEvents { // pointer events settings are managed by the parent screen container, we ignore // any attempt of setting that via React props } - (void)setStackPresentation:(RNSScreenStackPresentation)stackPresentation { switch (stackPresentation) { case RNSScreenStackPresentationModal: #ifdef __IPHONE_13_0 if (@available(iOS 13.0, *)) { _controller.modalPresentationStyle = UIModalPresentationAutomatic; } else { _controller.modalPresentationStyle = UIModalPresentationFullScreen; } #else _controller.modalPresentationStyle = UIModalPresentationFullScreen; #endif break; case RNSScreenStackPresentationFullScreenModal: _controller.modalPresentationStyle = UIModalPresentationFullScreen; break; #if (TARGET_OS_IOS) case RNSScreenStackPresentationFormSheet: _controller.modalPresentationStyle = UIModalPresentationFormSheet; break; #endif case RNSScreenStackPresentationTransparentModal: _controller.modalPresentationStyle = UIModalPresentationOverFullScreen; break; case RNSScreenStackPresentationContainedModal: _controller.modalPresentationStyle = UIModalPresentationCurrentContext; break; case RNSScreenStackPresentationContainedTransparentModal: _controller.modalPresentationStyle = UIModalPresentationOverCurrentContext; break; case RNSScreenStackPresentationPush: // ignored, we only need to keep in mind not to set presentation delegate break; } // There is a bug in UIKit which causes retain loop when presentationController is accessed for a // controller that is not going to be presented modally. We therefore need to avoid setting the // delegate for screens presented using push. This also means that when controller is updated from // modal to push type, this may cause memory leak, we warn about that as well. if (stackPresentation != RNSScreenStackPresentationPush) { // `modalPresentationStyle` must be set before accessing `presentationController` // otherwise a default controller will be created and cannot be changed after. // Documented here: https://developer.apple.com/documentation/uikit/uiviewcontroller/1621426-presentationcontroller?language=objc _controller.presentationController.delegate = self; } else if (_stackPresentation != RNSScreenStackPresentationPush) { RCTLogError(@"Screen presentation updated from modal to push, this may likely result in a screen object leakage. If you need to change presentation style create a new screen object instead"); } _stackPresentation = stackPresentation; } - (void)setStackAnimation:(RNSScreenStackAnimation)stackAnimation { _stackAnimation = stackAnimation; switch (stackAnimation) { case RNSScreenStackAnimationFade: _controller.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; break; #if (TARGET_OS_IOS) case RNSScreenStackAnimationFlip: _controller.modalTransitionStyle = UIModalTransitionStyleFlipHorizontal; break; #endif case RNSScreenStackAnimationNone: case RNSScreenStackAnimationDefault: // Default break; } } - (void)setGestureEnabled:(BOOL)gestureEnabled { #ifdef __IPHONE_13_0 if (@available(iOS 13.0, *)) { _controller.modalInPresentation = !gestureEnabled; } #endif _gestureEnabled = gestureEnabled; } - (void)setReplaceAnimation:(RNSScreenReplaceAnimation)replaceAnimation { _replaceAnimation = replaceAnimation; } - (UIView *)reactSuperview { return _reactSuperview; } - (void)addSubview:(UIView *)view { if (![view isKindOfClass:[RNSScreenStackHeaderConfig class]]) { [super addSubview:view]; } else { ((RNSScreenStackHeaderConfig*) view).screenView = self; } } - (void)notifyFinishTransitioning { [_controller notifyFinishTransitioning]; } - (void)notifyDismissed { _dismissed = YES; if (self.onDismissed) { dispatch_async(dispatch_get_main_queue(), ^{ if (self.onDismissed) { self.onDismissed(nil); } }); } } - (void)notifyWillAppear { if (self.onWillAppear) { self.onWillAppear(nil); } // we do it here too because at this moment the `parentViewController` is already not nil, // so if the parent is not UINavCtr, the frame will be updated to the correct one. [self reactSetFrame:_reactFrame]; } - (void)notifyWillDisappear { if (self.onWillDisappear) { self.onWillDisappear(nil); } } - (void)notifyAppear { if (self.onAppear) { dispatch_async(dispatch_get_main_queue(), ^{ if (self.onAppear) { self.onAppear(nil); } }); } } - (void)notifyDisappear { if (self.onDisappear) { self.onDisappear(nil); } } - (BOOL)isMountedUnderScreenOrReactRoot { for (UIView *parent = self.superview; parent != nil; parent = parent.superview) { if ([parent isKindOfClass:[RCTRootView class]] || [parent isKindOfClass:[RNSScreenView class]]) { return YES; } } return NO; } - (void)didMoveToWindow { // For RN touches to work we need to instantiate and connect RCTTouchHandler. This only applies // for screens that aren't mounted under RCTRootView e.g., modals that are mounted directly to // root application window. if (self.window != nil && ![self isMountedUnderScreenOrReactRoot]) { if (_touchHandler == nil) { _touchHandler = [[RCTTouchHandler alloc] initWithBridge:_bridge]; } [_touchHandler attachToView:self]; } else { [_touchHandler detachFromView:self]; } } - (void)presentationControllerWillDismiss:(UIPresentationController *)presentationController { // We need to call both "cancel" and "reset" here because RN's gesture recognizer // does not handle the scenario when it gets cancelled by other top // level gesture recognizer. In this case by the modal dismiss gesture. // Because of that, at the moment when this method gets called the React's // gesture recognizer is already in FAILED state but cancel events never gets // send to JS. Calling "reset" forces RCTTouchHanler to dispatch cancel event. // To test this behavior one need to open a dismissable modal and start // pulling down starting at some touchable item. Without "reset" the touchable // will never go back from highlighted state even when the modal start sliding // down. [_touchHandler cancel]; [_touchHandler reset]; } - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController { return _gestureEnabled; } - (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { if ([_reactSuperview respondsToSelector:@selector(presentationControllerDidDismiss:)]) { [_reactSuperview performSelector:@selector(presentationControllerDidDismiss:) withObject:presentationController]; } } - (void)invalidate { _controller = nil; } @end @implementation RNSScreen { __weak id _previousFirstResponder; CGRect _lastViewFrame; } - (instancetype)initWithView:(UIView *)view { if (self = [super init]) { self.view = view; } return self; } - (UIViewController *)childViewControllerForStatusBarStyle { UIViewController *vc = [self findChildVCForConfig]; return vc == self ? nil : vc; } - (UIStatusBarStyle)preferredStatusBarStyle { RNSScreenStackHeaderConfig *config = [self findScreenConfig]; return [RNSScreenStackHeaderConfig statusBarStyleForRNSStatusBarStyle:config && config.statusBarStyle ? config.statusBarStyle : RNSStatusBarStyleAuto]; } - (UIViewController *)childViewControllerForStatusBarHidden { UIViewController *vc = [self findChildVCForConfig]; return vc == self ? nil : vc; } - (BOOL)prefersStatusBarHidden { RNSScreenStackHeaderConfig *config = [self findScreenConfig]; return config && config.statusBarHidden ? config.statusBarHidden : NO; } - (UIStatusBarAnimation)preferredStatusBarUpdateAnimation { UIViewController *vc = [self findChildVCForConfig]; if (vc != self && vc != nil) { return vc.preferredStatusBarUpdateAnimation; } RNSScreenStackHeaderConfig *config = [self findScreenConfig]; return config && config.statusBarAnimation ? config.statusBarAnimation : UIStatusBarAnimationFade; } // if the returned vc is a child, it means that it can provide config; // if the returned vc is self, it means that there is no child for config and self has config to provide, // so we return self which results in asking self for preferredStatusBarStyle; // if the returned vc is nil, it means none of children could provide config and self does not have config either, // so if it was asked by parent, it will fallback to parent's option, or use default option if it is the top Screen - (UIViewController *)findChildVCForConfig { UIViewController *lastViewController = [[self childViewControllers] lastObject]; if (self.presentedViewController != nil) { lastViewController = self.presentedViewController; // setting this makes the modal vc being asked for appearance, // so it doesn't matter what we return here since the modal's root screen will be asked lastViewController.modalPresentationCapturesStatusBarAppearance = YES; return nil; } UIViewController *selfOrNil = [self findScreenConfig] != nil ? self : nil; if (lastViewController == nil) { return selfOrNil; } else { if ([lastViewController conformsToProtocol:@protocol(RNScreensViewControllerDelegate)]) { // If there is a child (should be VC of ScreenContainer or ScreenStack), that has a child that could provide config, // we recursively go into its findChildVCForConfig, and if one of the children has the config, we return it, // otherwise we return self if this VC has config, and nil if it doesn't // we use `childViewControllerForStatusBarStyle` for all options since the behavior is the same for all of them UIViewController *childScreen = [lastViewController childViewControllerForStatusBarStyle]; if ([childScreen isKindOfClass:[RNSScreen class]]) { return [(RNSScreen *)childScreen findChildVCForConfig] ?: selfOrNil; } else { return selfOrNil; } } else { // child vc is not from this library, so we don't ask it return selfOrNil; } } } - (RNSScreenStackHeaderConfig *)findScreenConfig { for (UIView *subview in self.view.reactSubviews) { if ([subview isKindOfClass:[RNSScreenStackHeaderConfig class]]) { return (RNSScreenStackHeaderConfig *)subview; } } return nil; } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; // The below code makes the screen view adapt dimensions provided by the system. We take these // into account only when the view is mounted under UINavigationController in which case system // provides additional padding to account for possible header, and in the case when screen is // shown as a native modal, as the final dimensions of the modal on iOS 12+ are shorter than the // screen size BOOL isDisplayedWithinUINavController = [self.parentViewController isKindOfClass:[UINavigationController class]]; BOOL isPresentedAsNativeModal = self.parentViewController == nil && self.presentingViewController != nil; if ((isDisplayedWithinUINavController || isPresentedAsNativeModal) && !CGRectEqualToRect(_lastViewFrame, self.view.frame)) { _lastViewFrame = self.view.frame; [((RNSScreenView *)self.viewIfLoaded) updateBounds]; } } - (id)findFirstResponder:(UIView*)parent { if (parent.isFirstResponder) { return parent; } for (UIView *subView in parent.subviews) { id responder = [self findFirstResponder:subView]; if (responder != nil) { return responder; } } return nil; } - (void)willMoveToParentViewController:(UIViewController *)parent { [super willMoveToParentViewController:parent]; if (parent == nil) { id responder = [self findFirstResponder:self.view]; if (responder != nil) { _previousFirstResponder = responder; } } } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [RNSScreenStackHeaderConfig updateStatusBarAppearance]; [((RNSScreenView *)self.view) notifyWillAppear]; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; [((RNSScreenView *)self.view) notifyWillDisappear]; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; [((RNSScreenView *)self.view) notifyAppear]; } - (void)viewDidDisappear:(BOOL)animated { [super viewDidDisappear:animated]; [((RNSScreenView *)self.view) notifyDisappear]; if (self.parentViewController == nil && self.presentingViewController == nil) { // screen dismissed, send event [((RNSScreenView *)self.view) notifyDismissed]; } [self traverseForScrollView:self.view]; } - (void)traverseForScrollView:(UIView*)view { if([view isKindOfClass:[UIScrollView class]] && ([[(UIScrollView*)view delegate] respondsToSelector:@selector(scrollViewDidEndDecelerating:)]) ) { [[(UIScrollView*)view delegate] scrollViewDidEndDecelerating:(id)view]; } [view.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { [self traverseForScrollView:obj]; }]; } - (void)notifyFinishTransitioning { [_previousFirstResponder becomeFirstResponder]; _previousFirstResponder = nil; // the correct Screen for appearance is set after the transition [RNSScreenStackHeaderConfig updateStatusBarAppearance]; } @end @implementation RNSScreenManager RCT_EXPORT_MODULE() // we want to handle the case when activityState is nil RCT_REMAP_VIEW_PROPERTY(activityState, activityStateOrNil, NSNumber) RCT_EXPORT_VIEW_PROPERTY(gestureEnabled, BOOL) RCT_EXPORT_VIEW_PROPERTY(replaceAnimation, RNSScreenReplaceAnimation) RCT_EXPORT_VIEW_PROPERTY(stackPresentation, RNSScreenStackPresentation) RCT_EXPORT_VIEW_PROPERTY(stackAnimation, RNSScreenStackAnimation) RCT_EXPORT_VIEW_PROPERTY(onWillAppear, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onWillDisappear, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onAppear, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onDisappear, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onDismissed, RCTDirectEventBlock); - (UIView *)view { return [[RNSScreenView alloc] initWithBridge:self.bridge]; } @end @implementation RCTConvert (RNSScreen) RCT_ENUM_CONVERTER(RNSScreenStackPresentation, (@{ @"push": @(RNSScreenStackPresentationPush), @"modal": @(RNSScreenStackPresentationModal), @"fullScreenModal": @(RNSScreenStackPresentationFullScreenModal), @"formSheet": @(RNSScreenStackPresentationFormSheet), @"containedModal": @(RNSScreenStackPresentationContainedModal), @"transparentModal": @(RNSScreenStackPresentationTransparentModal), @"containedTransparentModal": @(RNSScreenStackPresentationContainedTransparentModal) }), RNSScreenStackPresentationPush, integerValue) RCT_ENUM_CONVERTER(RNSScreenStackAnimation, (@{ @"default": @(RNSScreenStackAnimationDefault), @"none": @(RNSScreenStackAnimationNone), @"fade": @(RNSScreenStackAnimationFade), @"flip": @(RNSScreenStackAnimationFlip), }), RNSScreenStackAnimationDefault, integerValue) RCT_ENUM_CONVERTER(RNSScreenReplaceAnimation, (@{ @"push": @(RNSScreenReplaceAnimationPush), @"pop": @(RNSScreenReplaceAnimationPop), }), RNSScreenReplaceAnimationPop, integerValue) @end