#import "RNSScreenStack.h" #import "RNSScreen.h" #import "RNSScreenStackHeaderConfig.h" #import #import #import #import #import #import @interface RNSScreenStackView () @end @interface RNSScreenStackAnimator : NSObject - (instancetype)initWithOperation:(UINavigationControllerOperation)operation; @end @implementation RNSScreenStackView { BOOL _needUpdate; UINavigationController *_controller; NSMutableArray *_reactSubviews; NSMutableSet *_dismissedScreens; NSMutableArray *_presentedModals; __weak UIViewController* recentPopped; __weak RNSScreenStackManager *_manager; } - (instancetype)initWithManager:(RNSScreenStackManager*)manager { if (self = [super init]) { _manager = manager; _reactSubviews = [NSMutableArray new]; _presentedModals = [NSMutableArray new]; _dismissedScreens = [NSMutableSet new]; _controller = [[UINavigationController alloc] init]; _controller.delegate = self; _needUpdate = NO; [self addSubview:_controller.view]; _controller.interactivePopGestureRecognizer.delegate = self; // 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]]]; } return self; } - (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated { UIView *view = viewController.view; RNSScreenStackHeaderConfig *config = nil; for (UIView *subview in view.reactSubviews) { if ([subview isKindOfClass:[RNSScreenStackHeaderConfig class]]) { config = (RNSScreenStackHeaderConfig*) subview; break; } } [RNSScreenStackHeaderConfig willShowViewController:viewController withConfig:config]; } - (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated { for (NSUInteger i = _reactSubviews.count; i > 0; i--) { if ([viewController isEqual:[_reactSubviews objectAtIndex:i - 1].controller]) { break; } else { [_dismissedScreens addObject:[_reactSubviews objectAtIndex:i - 1]]; } } if (recentPopped != nil) { recentPopped.view = nil; recentPopped = nil; } } - (id)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC { RNSScreenView *screen; if (operation == UINavigationControllerOperationPush) { screen = (RNSScreenView *) toVC.view; } else if (operation == UINavigationControllerOperationPop) { screen = (RNSScreenView *) fromVC.view; recentPopped = fromVC; } if (screen != nil && (screen.stackAnimation == RNSScreenStackAnimationFade || screen.stackAnimation == RNSScreenStackAnimationNone)) { return [[RNSScreenStackAnimator alloc] initWithOperation:operation]; } return nil; } - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { // 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. UIView *parent = _controller.view; while (parent != nil && ![parent isKindOfClass:[RCTRootContentView class]]) parent = parent.superview; RCTRootContentView *rootView = (RCTRootContentView *)parent; [rootView.touchHandler cancel]; RNSScreenView *topScreen = (RNSScreenView *)_controller.viewControllers.lastObject.view; return _controller.viewControllers.count > 1 && topScreen.gestureEnabled; } - (void)markChildUpdated { // do nothing } - (void)didUpdateChildren { // do nothing } - (void)insertReactSubview:(RNSScreenView *)subview atIndex:(NSInteger)atIndex { if (![subview isKindOfClass:[RNSScreenView class]]) { RCTLogError(@"ScreenStack only accepts children of type Screen"); return; } [_reactSubviews insertObject:subview atIndex:atIndex]; } - (void)removeReactSubview:(RNSScreenView *)subview { [_reactSubviews removeObject:subview]; [_dismissedScreens removeObject:subview]; } - (NSArray *)reactSubviews { return _reactSubviews; } - (void)didUpdateReactSubviews { // do nothing [self updateContainer]; } - (void)setModalViewControllers:(NSArray *)controllers { // 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; } NSMutableArray *newControllers = [NSMutableArray arrayWithArray:controllers]; [newControllers removeObjectsInArray:_presentedModals]; // find bottom-most controller that should stay on the stack for the duration of transition NSUInteger changeRootIndex = 0; UIViewController *changeRootController = _controller; 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"); } } void (^finish)(void) = ^{ UIViewController *previous = changeRootController; for (NSUInteger i = changeRootIndex; i < controllers.count; i++) { UIViewController *next = controllers[i]; [previous presentViewController:next animated:(i == controllers.count - 1) completion:nil]; previous = next; } }; if (changeRootController.presentedViewController) { [changeRootController dismissViewControllerAnimated:(changeRootIndex == controllers.count) completion:finish]; } else { finish(); } [_presentedModals setArray:controllers]; } - (void)setPushViewControllers:(NSArray *)controllers { // when there is no change we return immediately if ([_controller.viewControllers isEqualToArray:controllers]) { return; } UIViewController *top = controllers.lastObject; UIViewController *lastTop = _controller.viewControllers.lastObject; // 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 = ![lastTop isKindOfClass:[RNSScreen class]]; BOOL shouldAnimate = !firstTimePush && ((RNSScreenView *) lastTop.view).stackAnimation != RNSScreenStackAnimationNone && !_controller.presentedViewController; if (firstTimePush) { // nothing pushed yet [_controller setViewControllers:controllers animated:NO]; } else if (top != lastTop) { if (![controllers containsObject:lastTop]) { // 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:lastTop]; [_controller setViewControllers:newControllers animated:NO]; [_controller popViewControllerAnimated:shouldAnimate]; } 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:shouldAnimate]; } else { // don't really know what this case could be, but may need to handle it // somehow [_controller setViewControllers:controllers animated:shouldAnimate]; } } 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 (![_dismissedScreens containsObject:screen]) { 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]; [self reactAddControllerToClosestParent:_controller]; _controller.view.frame = self.bounds; } - (void)invalidate { for (UIViewController *controller in _presentedModals) { [controller dismissViewControllerAnimated:NO completion:nil]; } [_presentedModals removeAllObjects]; } - (void)dismissOnReload { dispatch_async(dispatch_get_main_queue(), ^{ [self invalidate]; }); } @end @implementation RNSScreenStackManager { NSPointerArray *_stacks; } RCT_EXPORT_MODULE() RCT_EXPORT_VIEW_PROPERTY(transitioning, NSInteger) RCT_EXPORT_VIEW_PROPERTY(progress, CGFloat) - (UIView *)view { RNSScreenStackView *view = [[RNSScreenStackView alloc] initWithManager:self]; if (!_stacks) { _stacks = [NSPointerArray weakObjectsPointerArray]; } [_stacks addPointer:(__bridge void *)view]; return view; } - (void)invalidate { for (RNSScreenStackView *stack in _stacks) { [stack dismissOnReload]; } _stacks = nil; } @end @implementation RNSScreenStackAnimator { UINavigationControllerOperation _operation; } - (instancetype)initWithOperation:(UINavigationControllerOperation)operation { if (self = [super init]) { _operation = operation; } return self; } - (NSTimeInterval)transitionDuration:(id )transitionContext { RNSScreenView *screen; if (_operation == UINavigationControllerOperationPush) { UIViewController* toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; screen = (RNSScreenView *)toViewController.view; } else if (_operation == UINavigationControllerOperationPop) { UIViewController* fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; screen = (RNSScreenView *)fromViewController.view; } if (screen != nil && screen.stackAnimation == RNSScreenStackAnimationNone) { return 0; } return 0.35; // default duration } - (void)animateTransition:(id)transitionContext { UIViewController* toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; UIViewController* fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; if (_operation == UINavigationControllerOperationPush) { [[transitionContext containerView] addSubview:toViewController.view]; toViewController.view.alpha = 0.0; [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{ toViewController.view.alpha = 1.0; } completion:^(BOOL finished) { [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; }]; } else if (_operation == UINavigationControllerOperationPop) { [[transitionContext containerView] insertSubview:toViewController.view belowSubview:fromViewController.view]; [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{ fromViewController.view.alpha = 0.0; } completion:^(BOOL finished) { [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; }]; } } @end