#ifdef RCT_NEW_ARCH_ENABLED #import #import #import #import #import #import #import #import #import #import #import "RCTSurfaceTouchHandler+RNSUtility.h" #else #import #import #import #import #import #import #import "RCTTouchHandler+RNSUtility.h" #endif // RCT_NEW_ARCH_ENABLED #import "RNSScreen.h" #import "RNSScreenStack.h" #import "RNSScreenStackAnimator.h" #import "RNSScreenStackHeaderConfig.h" #import "RNSScreenWindowTraits.h" #ifdef RCT_NEW_ARCH_ENABLED namespace react = facebook::react; #endif // RCT_NEW_ARCH_ENABLED @interface RNSScreenStackView () < UINavigationControllerDelegate, UIAdaptivePresentationControllerDelegate, UIGestureRecognizerDelegate, UIViewControllerTransitioningDelegate #ifdef RCT_NEW_ARCH_ENABLED , RCTMountingTransactionObserving #endif > @property (nonatomic) NSMutableArray *presentedModals; @property (nonatomic) BOOL updatingModals; @property (nonatomic) BOOL scheduleModalsUpdate; @end @implementation RNSNavigationController #if !TARGET_OS_TV - (UIViewController *)childViewControllerForStatusBarStyle { return [self topViewController]; } - (UIStatusBarAnimation)preferredStatusBarUpdateAnimation { return [self topViewController].preferredStatusBarUpdateAnimation; } - (UIViewController *)childViewControllerForStatusBarHidden { return [self topViewController]; } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; if ([self.topViewController isKindOfClass:[RNSScreen class]]) { RNSScreen *screenController = (RNSScreen *)self.topViewController; BOOL isNotDismissingModal = screenController.presentedViewController == nil || (screenController.presentedViewController != nil && ![screenController.presentedViewController isBeingDismissed]); // Calculate header height during simple transition from one screen to another. // If RNSScreen includes a navigation controller of type RNSNavigationController, it should not calculate // header height, as it could have nested stack. if (![screenController hasNestedStack] && isNotDismissingModal) { [screenController calculateAndNotifyHeaderHeightChangeIsModal:NO]; } } } - (UIInterfaceOrientationMask)supportedInterfaceOrientations { return [self topViewController].supportedInterfaceOrientations; } - (UIViewController *)childViewControllerForHomeIndicatorAutoHidden { return [self topViewController]; } #endif @end #if !TARGET_OS_TV && !TARGET_OS_VISION @interface RNSScreenEdgeGestureRecognizer : UIScreenEdgePanGestureRecognizer @end @implementation RNSScreenEdgeGestureRecognizer @end @interface RNSPanGestureRecognizer : UIPanGestureRecognizer @end @implementation RNSPanGestureRecognizer @end #endif @implementation RNSScreenStackView { UINavigationController *_controller; NSMutableArray *_reactSubviews; BOOL _invalidated; BOOL _isFullWidthSwiping; UIPercentDrivenInteractiveTransition *_interactionController; BOOL _hasLayout; __weak RNSScreenStackManager *_manager; BOOL _updateScheduled; #ifdef RCT_NEW_ARCH_ENABLED UIView *_snapshot; #endif } #ifdef RCT_NEW_ARCH_ENABLED - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { static const auto defaultProps = std::make_shared(); _props = defaultProps; [self initCommonProps]; } return self; } #endif // RCT_NEW_ARCH_ENABLED - (instancetype)initWithManager:(RNSScreenStackManager *)manager { if (self = [super init]) { _hasLayout = NO; _invalidated = NO; _manager = manager; [self initCommonProps]; } return self; } - (void)initCommonProps { _reactSubviews = [NSMutableArray new]; _presentedModals = [NSMutableArray new]; _controller = [RNSNavigationController new]; _controller.delegate = self; #if !TARGET_OS_TV && !TARGET_OS_VISION [self setupGestureHandlers]; #endif // we have to initialize viewControllers with a non empty array for // largeTitle header to render in the opened state. If it is empty // the header will render in collapsed state which is perhaps a bug // in UIKit but ¯\_(ツ)_/¯ [_controller setViewControllers:@[ [UIViewController new] ]]; } #pragma mark - helper methods - (BOOL)shouldCancelDismissFromView:(RNSScreenView *)fromView toView:(RNSScreenView *)toView { int fromIndex = (int)[_reactSubviews indexOfObject:fromView]; int toIndex = (int)[_reactSubviews indexOfObject:toView]; for (int i = fromIndex; i > toIndex; i--) { if (_reactSubviews[i].preventNativeDismiss) { return YES; break; } } return NO; } #pragma mark - Common - (void)emitOnFinishTransitioningEvent { #ifdef RCT_NEW_ARCH_ENABLED if (_eventEmitter != nullptr) { std::dynamic_pointer_cast(_eventEmitter) ->onFinishTransitioning(react::RNSScreenStackEventEmitter::OnFinishTransitioning{}); } #else if (self.onFinishTransitioning) { self.onFinishTransitioning(nil); } #endif } - (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated { UIView *view = viewController.view; #ifdef RCT_NEW_ARCH_ENABLED if (![view isKindOfClass:[RNSScreenView class]]) { // if the current view is a snapshot, config was already removed so we don't trigger the method return; } #endif RNSScreenStackHeaderConfig *config = nil; for (UIView *subview in view.reactSubviews) { if ([subview isKindOfClass:[RNSScreenStackHeaderConfig class]]) { config = (RNSScreenStackHeaderConfig *)subview; break; } } [RNSScreenStackHeaderConfig willShowViewController:viewController animated:animated withConfig:config]; } - (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { // We don't directly set presentation delegate but instead rely on the ScreenView's delegate to // forward certain calls to the container (Stack). if ([presentationController.presentedViewController isKindOfClass:[RNSScreen class]]) { // we trigger the update of status bar's appearance here because there is no other lifecycle method // that can handle it when dismissing a modal, the same for orientation [RNSScreenWindowTraits updateWindowTraits]; [_presentedModals removeObject:presentationController.presentedViewController]; _updatingModals = NO; #ifdef RCT_NEW_ARCH_ENABLED [self emitOnFinishTransitioningEvent]; #else // we double check if there are no new controllers pending to be presented since someone could // have tried to push another one during the transition. // We don't do it on Fabric since update of container will be triggered from "unmount" method afterwards [self updateContainer]; if (self.onFinishTransitioning) { // instead of directly triggering onFinishTransitioning this time we enqueue the event on the // main queue. We do that because onDismiss event is also enqueued and we want for the transition // finish event to arrive later than onDismiss (see RNSScreen#notifyDismiss) dispatch_async(dispatch_get_main_queue(), ^{ [self emitOnFinishTransitioningEvent]; }); } #endif } } - (NSArray *)reactSubviews { return _reactSubviews; } - (void)didMoveToWindow { [super didMoveToWindow]; #ifdef RCT_NEW_ARCH_ENABLED // for handling nested stacks [self maybeAddToParentAndUpdateContainer]; #else if (!_invalidated) { // We check whether the view has been invalidated before running side-effects in didMoveToWindow // This is needed because when LayoutAnimations are used it is possible for view to be re-attached // to a window despite the fact it has been removed from the React Native view hierarchy. [self maybeAddToParentAndUpdateContainer]; } #endif } - (void)maybeAddToParentAndUpdateContainer { BOOL wasScreenMounted = _controller.parentViewController != nil; #ifdef RCT_NEW_ARCH_ENABLED BOOL isScreenReadyForShowing = self.window; #else BOOL isScreenReadyForShowing = self.window && _hasLayout; #endif if (!isScreenReadyForShowing && !wasScreenMounted) { // We wait with adding to parent controller until the stack is mounted and has its initial // layout done. // If we add it before layout, some of the items (specifically items from the navigation bar), // won't be able to position properly. Also the position and size of such items, even if it // happens to change, won't be properly updated (this is perhaps some internal issue of UIKit). // If we add it when window is not attached, some of the view transitions will be bloced (i.e. // modal transitions) and the internal view controler's state will get out of sync with what's // on screen without us knowing. return; } [self updateContainer]; if (!wasScreenMounted) { // when stack hasn't been added to parent VC yet we do two things: // 1) we run updateContainer (the one above) – we do this because we want push view controllers to // be installed before the VC is mounted. If we do that after it is added to parent the push // updates operations are going to be blocked by UIKit. // 2) we add navigation VS to parent – this is needed for the VC lifecycle events to be dispatched // properly // 3) we again call updateContainer – this time we do this to open modal controllers. Modals // won't open in (1) because they require navigator to be added to parent. We handle that case // gracefully in setModalViewControllers and can retry opening at any point. [self reactAddControllerToClosestParent:_controller]; [self updateContainer]; } } - (void)reactAddControllerToClosestParent:(UIViewController *)controller { if (!controller.parentViewController) { UIView *parentView = (UIView *)self.reactSuperview; while (parentView) { if (parentView.reactViewController) { [parentView.reactViewController addChildViewController:controller]; [self addSubview:controller.view]; #if !TARGET_OS_TV _controller.interactivePopGestureRecognizer.delegate = self; #endif [controller didMoveToParentViewController:parentView.reactViewController]; // On iOS pre 12 we observed that `willShowViewController` delegate method does not always // get triggered when the navigation controller is instantiated. As the only thing we do in // that delegate method is ask nav header to update to the current state it does not hurt to // trigger that logic from here too such that we can be sure the header is properly updated. [self navigationController:_controller willShowViewController:_controller.topViewController animated:NO]; break; } parentView = (UIView *)parentView.reactSuperview; } return; } } + (UIViewController *)findTopMostPresentedViewControllerFromViewController:(UIViewController *)controller { auto presentedVc = controller; while (presentedVc.presentedViewController != nil) { presentedVc = presentedVc.presentedViewController; } return presentedVc; } - (UIViewController *)findReactRootViewController { UIView *parent = self; while (parent) { parent = parent.reactSuperview; if (parent.isReactRootView) { return parent.reactViewController; } } return nil; } - (UIViewController *)lastFromPresentedViewControllerChainStartingFrom:(UIViewController *)vc { UIViewController *lastNonNullVc = vc; UIViewController *lastVc = vc.presentedViewController; while (lastVc != nil) { lastNonNullVc = lastVc; lastVc = lastVc.presentedViewController; } return lastNonNullVc; } - (void)setModalViewControllers:(NSArray *)controllers { // prevent re-entry if (_updatingModals) { _scheduleModalsUpdate = YES; return; } // when there is no change we return immediately. This check is important because sometime we may // accidently trigger modal dismiss if we don't verify to run the below code only when an actual // change in the list of presented modal was made. if ([_presentedModals isEqualToArray:controllers]) { return; } // if view controller is not yet attached to window we skip updates now and run them when view // is attached if (self.window == nil && _presentedModals.lastObject.view.window == nil) { return; } _updatingModals = YES; NSMutableArray *newControllers = [NSMutableArray arrayWithArray:controllers]; [newControllers removeObjectsInArray:_presentedModals]; // We need to find bottom-most view controller that should stay on the stack // for the duration of transition. // There are couple of scenarios: // (1) no modals are presented or all modals were presented by this RNSNavigationController, // (2) there are modals presented by other RNSNavigationControllers (nested/outer), // (3) there are modals presented by other controllers (e.g. React Native's Modal view). // Last controller that is common for both _presentedModals & controllers __block UIViewController *changeRootController = _controller; // Last common controller index + 1 NSUInteger changeRootIndex = 0; for (NSUInteger i = 0; i < MIN(_presentedModals.count, controllers.count); i++) { if (_presentedModals[i] == controllers[i]) { changeRootController = controllers[i]; changeRootIndex = i + 1; } else { break; } } // we verify that controllers added on top of changeRootIndex are all new. Unfortunately modal // VCs cannot be reshuffled (there are some visual glitches when we try to dismiss then show as // even non-animated dismissal has delay and updates the screen several times) for (NSUInteger i = changeRootIndex; i < controllers.count; i++) { if ([_presentedModals containsObject:controllers[i]]) { RCTAssert(false, @"Modally presented controllers are being reshuffled, this is not allowed"); } } __weak RNSScreenStackView *weakSelf = self; void (^afterTransitions)(void) = ^{ [weakSelf emitOnFinishTransitioningEvent]; weakSelf.updatingModals = NO; if (weakSelf.scheduleModalsUpdate) { // if modals update was requested during setModalViewControllers we set scheduleModalsUpdate // flag in order to perform updates at a later point. Here we are done with all modals // transitions and check this flag again. If it was set, we reset the flag and execute updates. weakSelf.scheduleModalsUpdate = NO; [weakSelf updateContainer]; } // we trigger the update of orientation here because, when dismissing the modal from JS, // neither `viewWillAppear` nor `presentationControllerDidDismiss` are called, same for status bar. [RNSScreenWindowTraits updateWindowTraits]; }; void (^finish)(void) = ^{ NSUInteger oldCount = weakSelf.presentedModals.count; if (changeRootIndex < oldCount) { [weakSelf.presentedModals removeObjectsInRange:NSMakeRange(changeRootIndex, oldCount - changeRootIndex)]; } BOOL isAttached = changeRootController.parentViewController != nil || changeRootController.presentingViewController != nil; if (!isAttached || changeRootIndex >= controllers.count) { // if change controller view is not attached, presenting modals will silently fail on iOS. // In such a case we trigger controllers update from didMoveToWindow. // We also don't run any present transitions if changeRootIndex is greater or equal to the size // of new controllers array. This means that no new controllers should be presented. afterTransitions(); return; } else { UIViewController *previous = changeRootController; for (NSUInteger i = changeRootIndex; i < controllers.count; i++) { UIViewController *next = controllers[i]; BOOL lastModal = (i == controllers.count - 1); #if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && defined(__IPHONE_13_0) && \ __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0 if (@available(iOS 13.0, tvOS 13.0, *)) { // Inherit UI style from its parent - solves an issue with incorrect style being applied to some UIKit views // like date picker or segmented control. next.overrideUserInterfaceStyle = self->_controller.overrideUserInterfaceStyle; } #endif BOOL shouldAnimate = lastModal && [next isKindOfClass:[RNSScreen class]] && ((RNSScreen *)next).screenView.stackAnimation != RNSScreenStackAnimationNone; // if you want to present another modal quick enough after dismissing the previous one, // it will result in wrong changeRootController, see repro in // https://github.com/software-mansion/react-native-screens/issues/1299 We call `updateContainer` again in // `presentationControllerDidDismiss` to cover this case and present new controller if (previous.beingDismissed) { return; } [previous presentViewController:next animated:shouldAnimate completion:^{ [weakSelf.presentedModals addObject:next]; if (lastModal) { afterTransitions(); }; }]; previous = next; } } }; // changeRootController is the last controller that *is owned by this stack*, and should stay unchanged after this // batch of transitions. Therefore changeRootController.presentedViewController is the first constroller to be // dismissed (implying also all controllers above). Notice here, that firstModalToBeDismissed could have been // RNSScreen modal presented from *this* stack, another stack, or any other view controller with modal presentation // provided by third-party libraries (e.g. React Native's Modal view). In case of presence of other (not managed by // us) modal controllers, weird interactions might arise. The code below, besides handling our presentation / // dismissal logic also attempts to handle possible wide gamut of cases of interactions with third-party modal // controllers, however it's not perfect. // TODO: Find general way to manage owned and foreign modal view controllers and refactor this code. Consider building // model first (data structue, attempting to be aware of all modals in presentation and some text-like algorithm for // computing required operations). UIViewController *firstModalToBeDismissed = changeRootController.presentedViewController; if (firstModalToBeDismissed != nil) { BOOL shouldAnimate = changeRootIndex == controllers.count && [firstModalToBeDismissed isKindOfClass:[RNSScreen class]] && ((RNSScreen *)firstModalToBeDismissed).screenView.stackAnimation != RNSScreenStackAnimationNone; if ([_presentedModals containsObject:firstModalToBeDismissed] || ![firstModalToBeDismissed isKindOfClass:RNSScreen.class]) { // We dismiss every VC that was presented by changeRootController VC or its descendant. // After the series of dismissals is completed we run completion block in which // we present modals on top of changeRootController (which may be the this stack VC) // // There also might the second case, where the firstModalToBeDismissed is foreign. // See: https://github.com/software-mansion/react-native-screens/issues/2048 // For now, to mitigate the issue, we also decide to trigger its dismissal before // starting the presentation chain down below in finish() callback. [changeRootController dismissViewControllerAnimated:shouldAnimate completion:finish]; return; } UIViewController *lastModalVc = [self lastFromPresentedViewControllerChainStartingFrom:firstModalToBeDismissed]; if (lastModalVc != firstModalToBeDismissed) { [lastModalVc dismissViewControllerAnimated:shouldAnimate completion:finish]; return; } } // changeRootController does not have presentedViewController but it does not mean that no modals are in presentation; // modals could be presented by another stack (nested / outer), third-party view controller or they could be using // UIModalPresentationCurrentContext / UIModalPresentationOverCurrentContext presentation styles; in the last case // for some reason system asks top-level (react root) vc to present instead of our stack, despite the fact that // `definesPresentationContext` returns `YES` for UINavigationController. // So we first need to find top-level controller manually: UIViewController *reactRootVc = [self findReactRootViewController]; UIViewController *topMostVc = [RNSScreenStackView findTopMostPresentedViewControllerFromViewController:reactRootVc]; if (topMostVc != reactRootVc) { changeRootController = topMostVc; // Here we handle just the simplest case where the top level VC was dismissed. In any more complex // scenario we will still have problems, see: https://github.com/software-mansion/react-native-screens/issues/1813 if ([_presentedModals containsObject:topMostVc] && ![controllers containsObject:topMostVc]) { [changeRootController dismissViewControllerAnimated:YES completion:finish]; return; } } // We didn't detect any controllers for dismissal, thus we start presenting new VCs finish(); } - (void)setPushViewControllers:(NSArray *)controllers { // when there is no change we return immediately if ([_controller.viewControllers isEqualToArray:controllers]) { return; } // if view controller is not yet attached to window we skip updates now and run them when view // is attached if (self.window == nil) { return; } // when transition is ongoing, any updates made to the controller will not be reflected until the // transition is complete. In particular, when we push/pop view controllers we expect viewControllers // property to be updated immediately. Based on that property we then calculate future updates. // When the transition is ongoing the property won't be updated immediatly. We therefore avoid // making any updated when transition is ongoing and schedule updates for when the transition // is complete. if (_controller.transitionCoordinator != nil) { if (!_updateScheduled) { _updateScheduled = YES; __weak RNSScreenStackView *weakSelf = self; [_controller.transitionCoordinator animateAlongsideTransition:^(id _Nonnull context) { // do nothing here, we only want to be notified when transition is complete } completion:^(id _Nonnull context) { self->_updateScheduled = NO; [weakSelf updateContainer]; }]; } return; } UIViewController *top = controllers.lastObject; #ifdef RCT_NEW_ARCH_ENABLED UIViewController *previousTop = _controller.topViewController; #else UIViewController *previousTop = _controller.viewControllers.lastObject; #endif // At the start we set viewControllers to contain a single UIViewController // instance. This is a workaround for header height adjustment bug (see comment // in the init function). Here, we need to detect if the initial empty // controller is still there BOOL firstTimePush = ![previousTop isKindOfClass:[RNSScreen class]]; if (firstTimePush) { // nothing pushed yet [_controller setViewControllers:controllers animated:NO]; } else if (top != previousTop) { if (![controllers containsObject:previousTop]) { // if the previous top screen does not exist anymore and the new top was not on the stack before, probably replace // was called, so we check the animation if (![_controller.viewControllers containsObject:top] && ((RNSScreenView *)top.view).replaceAnimation == RNSScreenReplaceAnimationPush) { // setting new controllers with animation does `push` animation by default #ifdef RCT_NEW_ARCH_ENABLED // This is a workaround for the case, when in the app we're trying to do `replace` action on screens, when // there's already ongoing transition to some screen. In such case, we're making the snapshot, but we're trying // to add it to the wrong superview (where it should be UIViewControllerWrapperView, but it's // _UIParallaxDimmingView instead). At the moment of RN 0.74 we can't queue the unmounts for such situation // either, so we need to turn off animations, when the view is not yet mounted, but it will appear after the // transition of previous replacement. [_controller setViewControllers:controllers animated:previousTop.view.window != nil]; #else [_controller setViewControllers:controllers animated:YES]; #endif // RCT_NEW_ARCH_ENABLED } else { // last top controller is no longer on stack // in this case we set the controllers stack to the new list with // added the last top element to it and perform (animated) pop NSMutableArray *newControllers = [NSMutableArray arrayWithArray:controllers]; [newControllers addObject:previousTop]; [_controller setViewControllers:newControllers animated:NO]; [_controller popViewControllerAnimated:YES]; } } else if (![_controller.viewControllers containsObject:top]) { // new top controller is not on the stack // in such case we update the stack except from the last element with // no animation and do animated push of the last item NSMutableArray *newControllers = [NSMutableArray arrayWithArray:controllers]; [newControllers removeLastObject]; [_controller setViewControllers:newControllers animated:NO]; [_controller pushViewController:top animated:YES]; } else { // don't really know what this case could be, but may need to handle it // somehow [_controller setViewControllers:controllers animated:NO]; } } else { // change wasn't on the top of the stack. We don't need animation. [_controller setViewControllers:controllers animated:NO]; } } - (void)updateContainer { NSMutableArray *pushControllers = [NSMutableArray new]; NSMutableArray *modalControllers = [NSMutableArray new]; for (RNSScreenView *screen in _reactSubviews) { if (!screen.dismissed && screen.controller != nil) { if (pushControllers.count == 0) { // first screen on the list needs to be places as "push controller" [pushControllers addObject:screen.controller]; } else { if (screen.stackPresentation == RNSScreenStackPresentationPush) { [pushControllers addObject:screen.controller]; } else { [modalControllers addObject:screen.controller]; } } } } [self setPushViewControllers:pushControllers]; [self setModalViewControllers:modalControllers]; } - (void)layoutSubviews { [super layoutSubviews]; _controller.view.frame = self.bounds; // We need to update the bounds of the modal views here, since // for contained modals they are not updated by modals themselves. for (UIViewController *modal in _presentedModals) { // Because `layoutSubviews` method is also called on grabbing the modal, // we don't want to update the frame when modal is being dismissed. // We also want to get the frame in correct position. In the best case, it // should be modal's superview (UITransitionView), which frame is being changed correctly. // Otherwise, when superview is nil, we will fallback to the bounds of the ScreenStack. BOOL isModalBeingDismissed = [modal isKindOfClass:[RNSScreen class]] && ((RNSScreen *)modal).isBeingDismissed; CGRect correctFrame = modal.view.superview != nil ? modal.view.superview.frame : self.bounds; if (!CGRectEqualToRect(modal.view.frame, correctFrame) && !isModalBeingDismissed) { modal.view.frame = correctFrame; } } } - (void)dismissOnReload { #ifdef RCT_NEW_ARCH_ENABLED #else dispatch_async(dispatch_get_main_queue(), ^{ [self invalidate]; }); #endif // RCT_NEW_ARCH_ENABLED } #pragma mark methods connected to transitioning - (id)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC { RNSScreenView *screen; if (operation == UINavigationControllerOperationPush) { screen = ((RNSScreen *)toVC).screenView; } else if (operation == UINavigationControllerOperationPop) { screen = ((RNSScreen *)fromVC).screenView; } BOOL shouldCancelDismiss = [self shouldCancelDismissFromView:(RNSScreenView *)fromVC.view toView:(RNSScreenView *)toVC.view]; if (screen != nil && // when preventing the native dismiss with back button, we have to return the animator. // Also, we need to return the animator when full width swiping even if the animation is not custom, // otherwise the screen will be just popped immediately due to no animation ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwiping || [RNSScreenStackAnimator isCustomAnimation:screen.stackAnimation] || _customAnimation)) { return [[RNSScreenStackAnimator alloc] initWithOperation:operation]; } return nil; } - (void)cancelTouchesInParent { // cancel touches in parent, this is needed to cancel RN touch events. For example when Touchable // item is close to an edge and we start pulling from edge we want the Touchable to be cancelled. // Without the below code the Touchable will remain active (highlighted) for the duration of back // gesture and onPress may fire when we release the finger. #ifdef RCT_NEW_ARCH_ENABLED // On Fabric there is no view that exposes touchHandler above us in the view hierarchy, however it is still // utilised. `RCTSurfaceView` should be present above us, which hosts `RCTFabricSurface` instance, which in turn // hosts `RCTSurfaceTouchHandler` as a private field. When initialised, `RCTSurfaceTouchHandler` is attached to the // surface view as a gestureRecognizer <- and this is where we can lay our hands on it. UIView *parent = _controller.view; while (parent != nil && ![parent isKindOfClass:RCTSurfaceView.class]) { parent = parent.superview; } // This could be possible in modal context if (parent == nil) { return; } RCTSurfaceTouchHandler *touchHandler = nil; // Experimentation shows that RCTSurfaceTouchHandler is the only gestureRecognizer registered here, // so we should not be afraid of any performance hit here. for (UIGestureRecognizer *recognizer in parent.gestureRecognizers) { if ([recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { touchHandler = static_cast(recognizer); } } [touchHandler rnscreens_cancelTouches]; #else // On Paper we can access touchHandler hosted by `RCTRootContentView` which should be above ScreenStack // in view hierarchy. UIView *parent = _controller.view; while (parent != nil && ![parent respondsToSelector:@selector(touchHandler)]) { parent = parent.superview; } if (parent != nil) { RCTTouchHandler *touchHandler = [parent performSelector:@selector(touchHandler)]; [touchHandler rnscreens_cancelTouches]; } #endif // RCT_NEW_ARCH_ENABLED } - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { if (_disableSwipeBack) { return NO; } RNSScreenView *topScreen = _reactSubviews.lastObject; #if TARGET_OS_TV || TARGET_OS_VISION [self cancelTouchesInParent]; return YES; #else // RNSPanGestureRecognizer will receive events iff topScreen.fullScreenSwipeEnabled == YES; // Events are filtered in gestureRecognizer:shouldReceivePressOrTouchEvent: method if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) { if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) { _isFullWidthSwiping = YES; [self cancelTouchesInParent]; return YES; } return NO; } // Now we're dealing with RNSScreenEdgeGestureRecognizer (or _UIParallaxTransitionPanGestureRecognizer) if (topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation]) { if ([gestureRecognizer isKindOfClass:[RNSScreenEdgeGestureRecognizer class]]) { UIRectEdge edges = ((RNSScreenEdgeGestureRecognizer *)gestureRecognizer).edges; BOOL isRTL = _controller.view.semanticContentAttribute == UISemanticContentAttributeForceRightToLeft; BOOL isSlideFromLeft = topScreen.stackAnimation == RNSScreenStackAnimationSlideFromLeft; // if we do not set any explicit `semanticContentAttribute`, it is `UISemanticContentAttributeUnspecified` instead // of `UISemanticContentAttributeForceLeftToRight`, so we just check if it is RTL or not BOOL isCorrectEdge = (isRTL && edges == UIRectEdgeRight) || (!isRTL && isSlideFromLeft && edges == UIRectEdgeRight) || (isRTL && isSlideFromLeft && edges == UIRectEdgeLeft) || (!isRTL && edges == UIRectEdgeLeft); if (isCorrectEdge) { [self cancelTouchesInParent]; return YES; } } return NO; } else { if ([gestureRecognizer isKindOfClass:[RNSScreenEdgeGestureRecognizer class]]) { // it should only recognize with `customAnimationOnSwipe` set return NO; } // _UIParallaxTransitionPanGestureRecognizer (other...) [self cancelTouchesInParent]; return YES; } #endif // TARGET_OS_TV } #if !TARGET_OS_TV && !TARGET_OS_VISION - (void)setupGestureHandlers { // gesture recognizers for custom stack animations RNSScreenEdgeGestureRecognizer *leftEdgeSwipeGestureRecognizer = [[RNSScreenEdgeGestureRecognizer alloc] initWithTarget:self action:@selector(handleSwipe:)]; leftEdgeSwipeGestureRecognizer.edges = UIRectEdgeLeft; leftEdgeSwipeGestureRecognizer.delegate = self; [self addGestureRecognizer:leftEdgeSwipeGestureRecognizer]; RNSScreenEdgeGestureRecognizer *rightEdgeSwipeGestureRecognizer = [[RNSScreenEdgeGestureRecognizer alloc] initWithTarget:self action:@selector(handleSwipe:)]; rightEdgeSwipeGestureRecognizer.edges = UIRectEdgeRight; rightEdgeSwipeGestureRecognizer.delegate = self; [self addGestureRecognizer:rightEdgeSwipeGestureRecognizer]; // gesture recognizer for full width swipe gesture RNSPanGestureRecognizer *panRecognizer = [[RNSPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleSwipe:)]; panRecognizer.delegate = self; [self addGestureRecognizer:panRecognizer]; } - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer { RNSScreenView *topScreen = _reactSubviews.lastObject; float translation; float velocity; float distance; if (topScreen.swipeDirection == RNSScreenSwipeDirectionVertical) { translation = [gestureRecognizer translationInView:gestureRecognizer.view].y; velocity = [gestureRecognizer velocityInView:gestureRecognizer.view].y; distance = gestureRecognizer.view.bounds.size.height; } else { translation = [gestureRecognizer translationInView:gestureRecognizer.view].x; velocity = [gestureRecognizer velocityInView:gestureRecognizer.view].x; distance = gestureRecognizer.view.bounds.size.width; BOOL isRTL = _controller.view.semanticContentAttribute == UISemanticContentAttributeForceRightToLeft; if (isRTL) { translation = -translation; velocity = -velocity; } } bool isInverted = topScreen.stackAnimation == RNSScreenStackAnimationSlideFromLeft; float transitionProgress = (translation / distance); transitionProgress = isInverted ? transitionProgress * -1 : transitionProgress; switch (gestureRecognizer.state) { case UIGestureRecognizerStateBegan: { _interactionController = [UIPercentDrivenInteractiveTransition new]; [_controller popViewControllerAnimated:YES]; break; } case UIGestureRecognizerStateChanged: { [_interactionController updateInteractiveTransition:transitionProgress]; break; } case UIGestureRecognizerStateCancelled: { [_interactionController cancelInteractiveTransition]; break; } case UIGestureRecognizerStateEnded: { // values taken from // https://github.com/react-navigation/react-navigation/blob/54739828598d7072c1bf7b369659e3682db3edc5/packages/stack/src/views/Stack/Card.tsx#L316 float snapPoint = distance / 2; float gestureDistance = translation + velocity * 0.3; gestureDistance = isInverted ? gestureDistance * -1 : gestureDistance; BOOL shouldFinishTransition = gestureDistance > snapPoint; if (shouldFinishTransition) { [_interactionController finishInteractiveTransition]; } else { [_interactionController cancelInteractiveTransition]; } _interactionController = nil; } default: { break; } } } - (id)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController: (id)animationController { RNSScreenView *fromView = [_controller.transitionCoordinator viewForKey:UITransitionContextFromViewKey]; RNSScreenView *toView = [_controller.transitionCoordinator viewForKey:UITransitionContextToViewKey]; // we can intercept clicking back button here, we check reactSuperview since this method also fires when // navigating back from JS if (_interactionController == nil && fromView.reactSuperview) { BOOL shouldCancelDismiss = [self shouldCancelDismissFromView:fromView toView:toView]; if (shouldCancelDismiss) { _interactionController = [UIPercentDrivenInteractiveTransition new]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self->_interactionController cancelInteractiveTransition]; self->_interactionController = nil; int fromIndex = (int)[self->_reactSubviews indexOfObject:fromView]; int toIndex = (int)[self->_reactSubviews indexOfObject:toView]; int indexDiff = fromIndex - toIndex; int dismissCount = indexDiff > 0 ? indexDiff : 1; [self updateContainer]; [fromView notifyDismissCancelledWithDismissCount:dismissCount]; }); } } return _interactionController; } - (id)interactionControllerForDismissal: (id)animator { return _interactionController; } - (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated { [self emitOnFinishTransitioningEvent]; [RNSScreenWindowTraits updateWindowTraits]; // Because of the bug that caused view to have incorrect dimensions while changing the orientation, // we need to signalize that it needs to be layouted. // see https://github.com/software-mansion/react-native-screens/pull/1970 [_controller.view setNeedsLayout]; } #endif - (void)markChildUpdated { // do nothing } - (void)didUpdateChildren { // do nothing } - (UIViewController *)reactViewController { return _controller; } - (BOOL)isInGestureResponseDistance:(UIGestureRecognizer *)gestureRecognizer topScreen:(RNSScreenView *)topScreen { NSDictionary *gestureResponseDistanceValues = topScreen.gestureResponseDistance; float x = [gestureRecognizer locationInView:gestureRecognizer.view].x; float y = [gestureRecognizer locationInView:gestureRecognizer.view].y; BOOL isRTL = _controller.view.semanticContentAttribute == UISemanticContentAttributeForceRightToLeft; if (isRTL) { x = _controller.view.frame.size.width - x; } // see: // https://github.com/software-mansion/react-native-screens/pull/1442/commits/74d4bae321875d8305ad021b3d448ebf713e7d56 // this prop is always default initialized so we do not expect any nils float start = [gestureResponseDistanceValues[@"start"] floatValue]; float end = [gestureResponseDistanceValues[@"end"] floatValue]; float top = [gestureResponseDistanceValues[@"top"] floatValue]; float bottom = [gestureResponseDistanceValues[@"bottom"] floatValue]; // we check if any of the constraints are violated and return NO if so return !( (start != -1 && x < start) || (end != -1 && x > end) || (top != -1 && y < top) || (bottom != -1 && y > bottom)); } // By default, the header buttons that are not inside the native hit area // cannot be clicked, so we check it by ourselves - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { if (CGRectContainsPoint(_controller.navigationBar.frame, point)) { RNSScreenView *topMostScreen = (RNSScreenView *)_reactSubviews.lastObject; UIView *headerConfig = topMostScreen.findHeaderConfig; if ([headerConfig isKindOfClass:[RNSScreenStackHeaderConfig class]]) { UIView *headerHitTestResult = [headerConfig hitTest:point withEvent:event]; if (headerHitTestResult != nil) { return headerHitTestResult; } } } return [super hitTest:point withEvent:event]; } #if !TARGET_OS_TV && !TARGET_OS_VISION - (BOOL)isScrollViewPanGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer { // NOTE: This hack is required to restore native behavior of edge swipe (interactive pop gesture) // without this, on a screen with a scroll view, it's only possible to pop view by panning horizontally // if even slightly diagonal (or if in motion), scroll view will scroll, and edge swipe will be cancelled if (![[gestureRecognizer view] isKindOfClass:[UIScrollView class]]) { return NO; } UIScrollView *scrollView = (UIScrollView *)gestureRecognizer.view; return scrollView.panGestureRecognizer == gestureRecognizer; } // Custom method for compatibility with iOS < 13.4 // RNSScreenStackView is a UIGestureRecognizerDelegate for three types of gesture recognizers: // RNSPanGestureRecognizer, RNSScreenEdgeGestureRecognizer, _UIParallaxTransitionPanGestureRecognizer // Be careful when adding another type of gesture recognizer. - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePressOrTouchEvent:(NSObject *)event { RNSScreenView *topScreen = _reactSubviews.lastObject; if (![topScreen isKindOfClass:[RNSScreenView class]] || !topScreen.gestureEnabled || _controller.viewControllers.count < 2 || [topScreen isModal]) { return NO; } // We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled. if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) { return topScreen.fullScreenSwipeEnabled; } // RNSScreenEdgeGestureRecognizer || _UIParallaxTransitionPanGestureRecognizer return YES; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePress:(UIPress *)press; { return [self gestureRecognizer:gestureRecognizer shouldReceivePressOrTouchEvent:press]; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch; { return [self gestureRecognizer:gestureRecognizer shouldReceivePressOrTouchEvent:touch]; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]] && [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) { RNSPanGestureRecognizer *panGestureRecognizer = (RNSPanGestureRecognizer *)gestureRecognizer; BOOL isBackGesture = [panGestureRecognizer translationInView:panGestureRecognizer.view].x > 0 && _controller.viewControllers.count > 1; if (gestureRecognizer.state == UIGestureRecognizerStateBegan || isBackGesture) { return NO; } return YES; } return NO; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { return ( [gestureRecognizer isKindOfClass:[UIScreenEdgePanGestureRecognizer class]] && [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]); } #endif // !TARGET_OS_TV - (void)insertReactSubview:(RNSScreenView *)subview atIndex:(NSInteger)atIndex { if (![subview isKindOfClass:[RNSScreenView class]]) { RCTLogError(@"ScreenStack only accepts children of type Screen"); return; } subview.reactSuperview = self; [_reactSubviews insertObject:subview atIndex:atIndex]; } - (void)removeReactSubview:(RNSScreenView *)subview { subview.reactSuperview = nil; [_reactSubviews removeObject:subview]; } - (void)didUpdateReactSubviews { // we need to wait until children have their layout set. At this point they don't have the layout // set yet, however the layout call is already enqueued on ui thread. Enqueuing update call on the // ui queue will guarantee that the update will run after layout. dispatch_async(dispatch_get_main_queue(), ^{ self->_hasLayout = YES; [self maybeAddToParentAndUpdateContainer]; }); } - (void)startScreenTransition { if (_interactionController == nil) { _customAnimation = YES; _interactionController = [UIPercentDrivenInteractiveTransition new]; [_controller popViewControllerAnimated:YES]; } } - (void)updateScreenTransition:(double)progress { [_interactionController updateInteractiveTransition:progress]; } - (void)finishScreenTransition:(BOOL)canceled { _customAnimation = NO; if (canceled) { [_interactionController updateInteractiveTransition:0.0]; [_interactionController cancelInteractiveTransition]; } else { [_interactionController updateInteractiveTransition:1.0]; [_interactionController finishInteractiveTransition]; } _interactionController = nil; } #ifdef RCT_NEW_ARCH_ENABLED #pragma mark - Fabric specific - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { if (![childComponentView isKindOfClass:[RNSScreenView class]]) { RCTLogError(@"ScreenStack only accepts children of type Screen"); return; } RCTAssert( childComponentView.reactSuperview == nil, @"Attempt to mount already mounted component view. (parent: %@, child: %@, index: %@, existing parent: %@)", self, childComponentView, @(index), @([childComponentView.superview tag])); [_reactSubviews insertObject:(RNSScreenView *)childComponentView atIndex:index]; ((RNSScreenView *)childComponentView).reactSuperview = self; dispatch_async(dispatch_get_main_queue(), ^{ [self maybeAddToParentAndUpdateContainer]; }); } - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { RNSScreenView *screenChildComponent = (RNSScreenView *)childComponentView; // We should only do a snapshot of a screen that is on the top. // We also check `_presentedModals` since if you push 2 modals, second one is not a "child" of _controller. // Also, when dissmised with a gesture, the screen already is not under the window, so we don't need to apply // snapshot. if (screenChildComponent.window != nil && ((screenChildComponent == _controller.visibleViewController.view && _presentedModals.count < 2) || screenChildComponent == [_presentedModals.lastObject view])) { [screenChildComponent.controller setViewToSnapshot:_snapshot]; } RCTAssert( screenChildComponent.reactSuperview == self, @"Attempt to unmount a view which is mounted inside different view. (parent: %@, child: %@, index: %@)", self, screenChildComponent, @(index)); RCTAssert( (_reactSubviews.count > index) && [_reactSubviews objectAtIndex:index] == childComponentView, @"Attempt to unmount a view which has a different index. (parent: %@, child: %@, index: %@, actual index: %@, tag at index: %@)", self, screenChildComponent, @(index), @([_reactSubviews indexOfObject:screenChildComponent]), @([[_reactSubviews objectAtIndex:index] tag])); screenChildComponent.reactSuperview = nil; [_reactSubviews removeObject:screenChildComponent]; [screenChildComponent removeFromSuperview]; dispatch_async(dispatch_get_main_queue(), ^{ [self maybeAddToParentAndUpdateContainer]; }); } - (void)takeSnapshot { if (_presentedModals.count < 2) { _snapshot = [_controller.visibleViewController.view snapshotViewAfterScreenUpdates:NO]; } else { _snapshot = [[_presentedModals.lastObject view] snapshotViewAfterScreenUpdates:NO]; } } - (void)mountingTransactionWillMount:(react::MountingTransaction const &)transaction withSurfaceTelemetry:(react::SurfaceTelemetry const &)surfaceTelemetry { for (auto &mutation : transaction.getMutations()) { if (mutation.type == react::ShadowViewMutation::Type::Remove && mutation.parentShadowView.componentName != nil && strcmp(mutation.parentShadowView.componentName, "RNSScreenStack") == 0) { [self takeSnapshot]; return; } } } - (void)prepareForRecycle { [super prepareForRecycle]; _reactSubviews = [NSMutableArray new]; for (UIViewController *controller in _presentedModals) { [controller dismissViewControllerAnimated:NO completion:nil]; } [_presentedModals removeAllObjects]; [_controller willMoveToParentViewController:nil]; [_controller removeFromParentViewController]; [_controller setViewControllers:@[ [UIViewController new] ]]; } + (react::ComponentDescriptorProvider)componentDescriptorProvider { return react::concreteComponentDescriptorProvider(); } #else #pragma mark - Paper specific - (void)invalidate { _invalidated = YES; for (UIViewController *controller in _presentedModals) { [controller dismissViewControllerAnimated:NO completion:nil]; } [_presentedModals removeAllObjects]; [_controller willMoveToParentViewController:nil]; [_controller removeFromParentViewController]; } #endif // RCT_NEW_ARCH_ENABLED @end #ifdef RCT_NEW_ARCH_ENABLED Class RNSScreenStackCls(void) { return RNSScreenStackView.class; } #endif @implementation RNSScreenStackManager { NSPointerArray *_stacks; } RCT_EXPORT_MODULE() RCT_EXPORT_VIEW_PROPERTY(onFinishTransitioning, RCTDirectEventBlock); #ifdef RCT_NEW_ARCH_ENABLED #else - (UIView *)view { RNSScreenStackView *view = [[RNSScreenStackView alloc] initWithManager:self]; if (!_stacks) { _stacks = [NSPointerArray weakObjectsPointerArray]; } [_stacks addPointer:(__bridge void *)view]; return view; } #endif // RCT_NEW_ARCH_ENABLED - (void)invalidate { for (RNSScreenStackView *stack in _stacks) { [stack dismissOnReload]; } _stacks = nil; } @end