diff --git a/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreen.m b/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreen.m new file mode 100644 index 0000000000..038841b147 --- /dev/null +++ b/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreen.m @@ -0,0 +1,334 @@ +#import + +#import "RNSScreen.h" +#import "RNSScreenContainer.h" +#import "RNSScreenStackHeaderConfig.h" + +#import +#import +#import + +@interface RNSScreenView () +@end + +@implementation RNSScreenView { + __weak RCTBridge *_bridge; + RNSScreen *_controller; + RCTTouchHandler *_touchHandler; +} + +@synthesize controller = _controller; + +- (instancetype)initWithBridge:(RCTBridge *)bridge +{ + if (self = [super init]) { + _bridge = bridge; + _controller = [[RNSScreen alloc] initWithView:self]; + _stackPresentation = RNSScreenStackPresentationPush; + _stackAnimation = RNSScreenStackAnimationDefault; + _gestureEnabled = YES; + } + + return self; +} + +- (void)reactSetFrame:(CGRect)frame +{ + if (![self.reactViewController.parentViewController + 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 dimentions and we wait for the screen VC to update and then we + // pass the dimentions to ui view manager to take into account when laying out + // subviews +} + +- (void)updateBounds +{ + [_bridge.uiManager setSize:self.bounds.size forView:self]; +} + +- (void)setActive:(BOOL)active +{ + if (active != _active) { + _active = active; + [_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 +{ + _stackPresentation = 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 RNSScreenStackPresentationTransparentModal: + _controller.modalPresentationStyle = UIModalPresentationOverFullScreen; + break; + case RNSScreenStackPresentationContainedModal: + _controller.modalPresentationStyle = UIModalPresentationCurrentContext; + break; + case RNSScreenStackPresentationContainedTransparentModal: + _controller.modalPresentationStyle = UIModalPresentationOverCurrentContext; + break; + } + // `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; +} + +- (void)setStackAnimation:(RNSScreenStackAnimation)stackAnimation +{ + _stackAnimation = stackAnimation; + + switch (stackAnimation) { + case RNSScreenStackAnimationFade: + _controller.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; + break; + case RNSScreenStackAnimationFlip: +#if !TARGET_OS_TV + _controller.modalTransitionStyle = UIModalTransitionStyleFlipHorizontal; +#endif + break; + case RNSScreenStackAnimationNone: + case RNSScreenStackAnimationDefault: + // Default + break; + } +} + +- (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 +{ + if (self.onDismissed) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.onDismissed) { + self.onDismissed(nil); + } + }); + } +} + +- (void)notifyAppear +{ + if (self.onAppear) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.onAppear) { + self.onAppear(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]; + } +} + +@end + +@implementation RNSScreen { + __weak UIView *_view; + __weak id _previousFirstResponder; + CGRect _lastViewFrame; +} + +- (instancetype)initWithView:(UIView *)view +{ + if (self = [super init]) { + _view = view; + } + return self; +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + + if (!CGRectEqualToRect(_lastViewFrame, self.view.frame)) { + _lastViewFrame = self.view.frame; + [((RNSScreenView *)self.view) 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 +{ + if (parent == nil) { + id responder = [self findFirstResponder:self.view]; + if (responder != nil) { + _previousFirstResponder = responder; + } + } +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + if (self.parentViewController == nil && self.presentingViewController == nil) { + // screen dismissed, send event + [((RNSScreenView *)self.view) notifyDismissed]; + _view = self.view; + self.view = nil; + } +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + [((RNSScreenView *)self.view) notifyAppear]; +} + +- (void)notifyFinishTransitioning +{ + [_previousFirstResponder becomeFirstResponder]; + _previousFirstResponder = nil; +} + +- (void)loadView +{ + if (_view != nil) { + self.view = _view; + _view = nil; + } +} + +@end + +@implementation RNSScreenManager + +RCT_EXPORT_MODULE() + +RCT_EXPORT_VIEW_PROPERTY(active, BOOL) +RCT_EXPORT_VIEW_PROPERTY(gestureEnabled, BOOL) +RCT_EXPORT_VIEW_PROPERTY(stackPresentation, RNSScreenStackPresentation) +RCT_EXPORT_VIEW_PROPERTY(stackAnimation, RNSScreenStackAnimation) +RCT_EXPORT_VIEW_PROPERTY(onAppear, 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), + @"containedModal": @(RNSScreenStackPresentationContainedModal), + @"transparentModal": @(RNSScreenStackPresentationTransparentModal), + @"containedTransparentModal": @(RNSScreenStackPresentationContainedTransparentModal) + }), RNSScreenStackPresentationPush, integerValue) + +RCT_ENUM_CONVERTER(RNSScreenStackAnimation, (@{ + @"default": @(RNSScreenStackAnimationDefault), + @"none": @(RNSScreenStackAnimationNone), + @"fade": @(RNSScreenStackAnimationFade), + @"flip": @(RNSScreenStackAnimationFlip), + }), RNSScreenStackAnimationDefault, integerValue) + + +@end + diff --git a/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreenStack.m b/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreenStack.m new file mode 100644 index 0000000000..2a26f3b4b8 --- /dev/null +++ b/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreenStack.m @@ -0,0 +1,412 @@ +#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 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]; +#if !TARGET_OS_TV + _controller.interactivePopGestureRecognizer.delegate = self; +#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]]]; + } + 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--) { + RNSScreenView *screenView = [_reactSubviews objectAtIndex:i - 1]; + if ([viewController isEqual:screenView.controller]) { + break; + } else if (screenView.stackPresentation == RNSScreenStackPresentationPush) { + [_dismissedScreens addObject:screenView]; + } + } + if (self.onFinishTransitioning) { + self.onFinishTransitioning(nil); + } +} + +- (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). + UIView *screenView = presentationController.presentedViewController.view; + if ([screenView isKindOfClass:[RNSScreenView class]]) { + [_dismissedScreens addObject:(RNSScreenView *)screenView]; + [_presentedModals removeObject:presentationController.presentedViewController]; + 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(), ^{ + if (self.onFinishTransitioning) { + self.onFinishTransitioning(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; + } + 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; + } + subview.reactSuperview = self; + [_reactSubviews insertObject:subview atIndex:atIndex]; +} + +- (void)removeReactSubview:(RNSScreenView *)subview +{ + subview.reactSuperview = nil; + [_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"); + } + } + + __weak RNSScreenStackView *weakSelf = self; + + void (^dispatchFinishTransitioning)(void) = ^{ + if (weakSelf.onFinishTransitioning) { + weakSelf.onFinishTransitioning(nil); + } + }; + + void (^finish)(void) = ^{ + UIViewController *previous = changeRootController; + for (NSUInteger i = changeRootIndex; i < controllers.count; i++) { + UIViewController *next = controllers[i]; + BOOL animate = (i == controllers.count - 1); + [previous presentViewController:next + animated:animate + completion:animate ? dispatchFinishTransitioning : nil]; + previous = next; + } + if (changeRootIndex >= controllers.count) { + dispatchFinishTransitioning(); + } + }; + + 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(onFinishTransitioning, RCTDirectEventBlock); + +- (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 diff --git a/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreenStackHeaderConfig.m b/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreenStackHeaderConfig.m new file mode 100644 index 0000000000..baa1cf63f5 --- /dev/null +++ b/packages/rnv/pluginTemplates/react-native-screens/overrides@2.0.0-beta.8/ios/RNSScreenStackHeaderConfig.m @@ -0,0 +1,526 @@ +#import "RNSScreenStackHeaderConfig.h" +#import "RNSScreen.h" + +#import +#import +#import +#import +#import +#import +#import + +// Some RN private method hacking below. Couldn't figure out better way to access image data +// of a given RCTImageView. See more comments in the code section processing SubviewTypeBackButton +@interface RCTImageView (Private) +- (UIImage*)image; +@end + +@interface RCTImageLoader (Private) +- (id)imageCache; +@end + + +@interface RNSScreenStackHeaderSubview : UIView + +@property (nonatomic, weak) RCTBridge *bridge; +@property (nonatomic, weak) UIView *reactSuperview; +@property (nonatomic) RNSScreenStackHeaderSubviewType type; + +- (instancetype)initWithBridge:(RCTBridge*)bridge; + +@end + +@implementation RNSScreenStackHeaderSubview + +- (instancetype)initWithBridge:(RCTBridge *)bridge +{ + if (self = [super init]) { + _bridge = bridge; + } + return self; +} + +@end + +@implementation RNSScreenStackHeaderConfig { + NSMutableArray *_reactSubviews; +} + +- (instancetype)init +{ + if (self = [super init]) { + self.hidden = YES; + _translucent = YES; + _reactSubviews = [NSMutableArray new]; + } + return self; +} + +- (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex +{ + [_reactSubviews insertObject:subview atIndex:atIndex]; + subview.reactSuperview = self; +} + +- (void)removeReactSubview:(RNSScreenStackHeaderSubview *)subview +{ + [_reactSubviews removeObject:subview]; +} + +- (NSArray *)reactSubviews +{ + return _reactSubviews; +} + +- (UIView *)reactSuperview +{ + return _screenView; +} + +- (void)removeFromSuperview +{ + [super removeFromSuperview]; + _screenView = nil; +} + +- (void)updateViewControllerIfNeeded +{ + UIViewController *vc = _screenView.controller; + UINavigationController *nav = (UINavigationController*) vc.parentViewController; + UIViewController *nextVC = nav.visibleViewController; + if (nav.transitionCoordinator != nil) { + // if navigator is performing transition instead of allowing to update of `visibleConttroller` + // we look at `topController`. This is because during transitiong the `visibleController` won't + // point to the controller that is going to be revealed after transition. This check fixes the + // problem when config gets updated while the transition is ongoing. + nextVC = nav.topViewController; + } + + if (vc != nil && nextVC == vc) { + [RNSScreenStackHeaderConfig updateViewController:self.screenView.controller withConfig:self]; + } +} + +- (void)didSetProps:(NSArray *)changedProps +{ + [super didSetProps:changedProps]; + [self updateViewControllerIfNeeded]; +} + +- (void)didUpdateReactSubviews +{ + [super didUpdateReactSubviews]; + [self updateViewControllerIfNeeded]; +} + ++ (void)setAnimatedConfig:(UIViewController *)vc withConfig:(RNSScreenStackHeaderConfig *)config +{ + UINavigationBar *navbar = ((UINavigationController *)vc.parentViewController).navigationBar; + [navbar setTintColor:config.color]; + +#ifdef __IPHONE_13_0 + if (@available(iOS 13.0, *)) { + // font customized on the navigation item level, so nothing to do here + } else +#endif + { + BOOL hideShadow = config.hideShadow; + + if (config.backgroundColor && CGColorGetAlpha(config.backgroundColor.CGColor) == 0.) { + [navbar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault]; + [navbar setBarTintColor:[UIColor clearColor]]; + hideShadow = YES; + } else { + [navbar setBackgroundImage:nil forBarMetrics:UIBarMetricsDefault]; + [navbar setBarTintColor:config.backgroundColor]; + } + [navbar setTranslucent:config.translucent]; + [navbar setValue:@(hideShadow ? YES : NO) forKey:@"hidesShadow"]; + + if (config.titleFontFamily || config.titleFontSize || config.titleColor) { + NSMutableDictionary *attrs = [NSMutableDictionary new]; + + if (config.titleColor) { + attrs[NSForegroundColorAttributeName] = config.titleColor; + } + + CGFloat size = config.titleFontSize ? [config.titleFontSize floatValue] : 17; + if (config.titleFontFamily) { + attrs[NSFontAttributeName] = [UIFont fontWithName:config.titleFontFamily size:size]; + } else { + attrs[NSFontAttributeName] = [UIFont boldSystemFontOfSize:size]; + } + [navbar setTitleTextAttributes:attrs]; + } + + if (@available(iOS 11.0, *)) { + if (config.largeTitle && (config.largeTitleFontFamily || config.largeTitleFontSize || config.titleColor)) { + NSMutableDictionary *largeAttrs = [NSMutableDictionary new]; + if (config.titleColor) { + largeAttrs[NSForegroundColorAttributeName] = config.titleColor; + } + CGFloat largeSize = config.largeTitleFontSize ? [config.largeTitleFontSize floatValue] : 34; + if (config.largeTitleFontFamily) { + largeAttrs[NSFontAttributeName] = [UIFont fontWithName:config.largeTitleFontFamily size:largeSize]; + } else { + largeAttrs[NSFontAttributeName] = [UIFont boldSystemFontOfSize:largeSize]; + } +#if !TARGET_OS_TV + [navbar setLargeTitleTextAttributes:largeAttrs]; +#endif + } + } + } +} + ++ (void)setTitleAttibutes:(NSDictionary *)attrs forButton:(UIBarButtonItem *)button +{ + [button setTitleTextAttributes:attrs forState:UIControlStateNormal]; + [button setTitleTextAttributes:attrs forState:UIControlStateHighlighted]; + [button setTitleTextAttributes:attrs forState:UIControlStateDisabled]; + [button setTitleTextAttributes:attrs forState:UIControlStateSelected]; + if (@available(iOS 9.0, *)) { + [button setTitleTextAttributes:attrs forState:UIControlStateFocused]; + } +} + ++ (UIImage*)loadBackButtonImageInViewController:(UIViewController *)vc + withConfig:(RNSScreenStackHeaderConfig *)config +{ + BOOL hasBackButtonImage = NO; + for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) { + if (subview.type == RNSScreenStackHeaderSubviewTypeBackButton && subview.subviews.count > 0) { + hasBackButtonImage = YES; + RCTImageView *imageView = subview.subviews[0]; + if (imageView.image == nil) { + // This is yet another workaround for loading custom back icon. It turns out that under + // certain circumstances image attribute can be null despite the app running in production + // mode (when images are loaded from the filesystem). This can happen because image attribute + // is reset when image view is detached from window, and also in some cases initialization + // does not populate the frame of the image view before the loading start. The latter result + // in the image attribute not being updated. We manually set frame to the size of an image + // in order to trigger proper reload that'd update the image attribute. + RCTImageSource *source = imageView.imageSources[0]; + [imageView reactSetFrame:CGRectMake(imageView.frame.origin.x, + imageView.frame.origin.y, + source.size.width, + source.size.height)]; + } + UIImage *image = imageView.image; + // IMPORTANT!!! + // image can be nil in DEV MODE ONLY + // + // It is so, because in dev mode images are loaded over HTTP from the packager. In that case + // we first check if image is already loaded in cache and if it is, we take it from cache and + // display immediately. Otherwise we wait for the transition to finish and retry updating + // header config. + // Unfortunately due to some problems in UIKit we cannot update the image while the screen + // transition is ongoing. This results in the settings being reset after the transition is done + // to the state from before the transition. + if (image == nil) { + // in DEV MODE we try to load from cache (we use private API for that as it is not exposed + // publically in headers). + RCTImageSource *source = imageView.imageSources[0]; + image = [subview.bridge.imageLoader.imageCache + imageForUrl:source.request.URL.absoluteString + size:source.size + scale:source.scale + resizeMode:imageView.resizeMode]; + } + if (image == nil) { + // This will be triggered if the image is not in the cache yet. What we do is we wait until + // the end of transition and run header config updates again. We could potentially wait for + // image on load to trigger, but that would require even more private method hacking. + if (vc.transitionCoordinator) { + [vc.transitionCoordinator animateAlongsideTransition:^(id _Nonnull context) { + // nothing, we just want completion + } completion:^(id _Nonnull context) { + // in order for new back button image to be loaded we need to trigger another change + // in back button props that'd make UIKit redraw the button. Otherwise the changes are + // not reflected. Here we change back button visibility which is then immediately restored +#if !TARGET_OS_TV + vc.navigationItem.hidesBackButton = YES; +#endif + [config updateViewControllerIfNeeded]; + }]; + } + return [UIImage new]; + } else { + return image; + } + } + } + return nil; +} + ++ (void)willShowViewController:(UIViewController *)vc withConfig:(RNSScreenStackHeaderConfig *)config +{ + [self updateViewController:vc withConfig:config]; +} + ++ (void)updateViewController:(UIViewController *)vc withConfig:(RNSScreenStackHeaderConfig *)config +{ + UINavigationItem *navitem = vc.navigationItem; + UINavigationController *navctr = (UINavigationController *)vc.parentViewController; + + NSUInteger currentIndex = [navctr.viewControllers indexOfObject:vc]; + UINavigationItem *prevItem = currentIndex > 0 ? [navctr.viewControllers objectAtIndex:currentIndex - 1].navigationItem : nil; + + BOOL wasHidden = navctr.navigationBarHidden; + BOOL shouldHide = config == nil || config.hide; + + if (!shouldHide && !config.translucent) { + // when nav bar is not translucent we chage edgesForExtendedLayout to avoid system laying out + // the screen underneath navigation controllers + vc.edgesForExtendedLayout = UIRectEdgeNone; + } else { + // system default is UIRectEdgeAll + vc.edgesForExtendedLayout = UIRectEdgeAll; + } + + [navctr setNavigationBarHidden:shouldHide animated:YES]; +#ifdef __IPHONE_13_0 + if (@available(iOS 13.0, *)) { + vc.modalInPresentation = !config.screenView.gestureEnabled; + } +#endif + if (shouldHide) { + return; + } + + navitem.title = config.title; + if (config.backTitle != nil) { +#if !TARGET_OS_TV + prevItem.backBarButtonItem = [[UIBarButtonItem alloc] + initWithTitle:config.backTitle + style:UIBarButtonItemStylePlain + target:nil + action:nil]; +#endif + if (config.backTitleFontFamily || config.backTitleFontSize) { + NSMutableDictionary *attrs = [NSMutableDictionary new]; + CGFloat size = config.backTitleFontSize ? [config.backTitleFontSize floatValue] : 17; + if (config.backTitleFontFamily) { + attrs[NSFontAttributeName] = [UIFont fontWithName:config.backTitleFontFamily size:size]; + } else { + attrs[NSFontAttributeName] = [UIFont boldSystemFontOfSize:size]; + } +#if !TARGET_OS_TV + [self setTitleAttibutes:attrs forButton:prevItem.backBarButtonItem]; +#endif + } + } else { +#if !TARGET_OS_TV + prevItem.backBarButtonItem = nil; +#endif + } + + if (@available(iOS 11.0, *)) { + if (config.largeTitle) { +#if !TARGET_OS_TV + navctr.navigationBar.prefersLargeTitles = YES; +#endif + } +#if !TARGET_OS_TV + navitem.largeTitleDisplayMode = config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; +#endif + } +#ifdef __IPHONE_13_0 + if (@available(iOS 13.0, *)) { + UINavigationBarAppearance *appearance = [UINavigationBarAppearance new]; + + if (config.backgroundColor && CGColorGetAlpha(config.backgroundColor.CGColor) == 0.) { + // transparent background color + [appearance configureWithTransparentBackground]; + } else { + // non-transparent background or default background + if (config.translucent) { + [appearance configureWithDefaultBackground]; + } else { + [appearance configureWithOpaqueBackground]; + } + + // set background color if specified + if (config.backgroundColor) { + appearance.backgroundColor = config.backgroundColor; + } + } + + if (config.backgroundColor && CGColorGetAlpha(config.backgroundColor.CGColor) == 0.) { + appearance.backgroundColor = config.backgroundColor; + } + + if (config.hideShadow) { + appearance.shadowColor = nil; + } + + if (config.titleFontFamily || config.titleFontSize || config.titleColor) { + NSMutableDictionary *attrs = [NSMutableDictionary new]; + + if (config.titleColor) { + attrs[NSForegroundColorAttributeName] = config.titleColor; + } + + CGFloat size = config.titleFontSize ? [config.titleFontSize floatValue] : 17; + if (config.titleFontFamily) { + attrs[NSFontAttributeName] = [UIFont fontWithName:config.titleFontFamily size:size]; + } else { + attrs[NSFontAttributeName] = [UIFont boldSystemFontOfSize:size]; + } + appearance.titleTextAttributes = attrs; + } + + if (config.largeTitleFontFamily || config.largeTitleFontSize || config.titleColor) { + NSMutableDictionary *largeAttrs = [NSMutableDictionary new]; + + if (config.titleColor) { + largeAttrs[NSForegroundColorAttributeName] = config.titleColor; + } + + CGFloat largeSize = config.largeTitleFontSize ? [config.largeTitleFontSize floatValue] : 34; + if (config.largeTitleFontFamily) { + largeAttrs[NSFontAttributeName] = [UIFont fontWithName:config.largeTitleFontFamily size:largeSize]; + } else { + largeAttrs[NSFontAttributeName] = [UIFont boldSystemFontOfSize:largeSize]; + } + + appearance.largeTitleTextAttributes = largeAttrs; + } + + UIImage *backButtonImage = [self loadBackButtonImageInViewController:vc withConfig:config]; + if (backButtonImage) { + [appearance setBackIndicatorImage:backButtonImage transitionMaskImage:backButtonImage]; + } else if (appearance.backIndicatorImage) { + [appearance setBackIndicatorImage:nil transitionMaskImage:nil]; + } + + navitem.standardAppearance = appearance; + navitem.compactAppearance = appearance; + navitem.scrollEdgeAppearance = appearance; + } else +#endif + { + // updating backIndicatotImage does not work when called during transition. On iOS pre 13 we need + // to update it before the navigation starts. + UIImage *backButtonImage = [self loadBackButtonImageInViewController:vc withConfig:config]; +#if !TARGET_OS_TV + if (backButtonImage) { + navctr.navigationBar.backIndicatorImage = backButtonImage; + navctr.navigationBar.backIndicatorTransitionMaskImage = backButtonImage; + } else if (navctr.navigationBar.backIndicatorImage) { + navctr.navigationBar.backIndicatorImage = nil; + navctr.navigationBar.backIndicatorTransitionMaskImage = nil; + } +#endif + } +#if !TARGET_OS_TV + navitem.hidesBackButton = config.hideBackButton; +#endif + navitem.leftBarButtonItem = nil; + navitem.rightBarButtonItem = nil; + navitem.titleView = nil; + for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) { + switch (subview.type) { + case RNSScreenStackHeaderSubviewTypeLeft: { + UIBarButtonItem *buttonItem = [[UIBarButtonItem alloc] initWithCustomView:subview]; + navitem.leftBarButtonItem = buttonItem; + break; + } + case RNSScreenStackHeaderSubviewTypeRight: { + UIBarButtonItem *buttonItem = [[UIBarButtonItem alloc] initWithCustomView:subview]; + navitem.rightBarButtonItem = buttonItem; + break; + } + case RNSScreenStackHeaderSubviewTypeCenter: + case RNSScreenStackHeaderSubviewTypeTitle: { + navitem.titleView = subview; + break; + } + } + } + + if (vc.transitionCoordinator != nil + && vc.transitionCoordinator.presentationStyle == UIModalPresentationNone + && !wasHidden) { + // when there is an ongoing transition we may need to update navbar setting in animation block + // using animateAlongsideTransition. However, we only do that given the transition is not a modal + // transition (presentationStyle == UIModalPresentationNone) and that the bar was not previously + // hidden. This is because both for modal transitions and transitions from screen with hidden bar + // the transition animation block does not get triggered. This is ok, because with both of those + // types of transitions there is no "shared" navigation bar that needs to be updated in an animated + // way. + [vc.transitionCoordinator animateAlongsideTransition:^(id _Nonnull context) { + [self setAnimatedConfig:vc withConfig:config]; + } completion:^(id _Nonnull context) { + if ([context isCancelled]) { + UIViewController* fromVC = [context viewControllerForKey:UITransitionContextFromViewControllerKey]; + RNSScreenStackHeaderConfig* config = nil; + for (UIView *subview in fromVC.view.reactSubviews) { + if ([subview isKindOfClass:[RNSScreenStackHeaderConfig class]]) { + config = (RNSScreenStackHeaderConfig*) subview; + break; + } + } + [self setAnimatedConfig:fromVC withConfig:config]; + } + }]; + } else { + [self setAnimatedConfig:vc withConfig:config]; + } +} + +@end + +@implementation RNSScreenStackHeaderConfigManager + +RCT_EXPORT_MODULE() + +- (UIView *)view +{ + return [RNSScreenStackHeaderConfig new]; +} + +RCT_EXPORT_VIEW_PROPERTY(title, NSString) +RCT_EXPORT_VIEW_PROPERTY(titleFontFamily, NSString) +RCT_EXPORT_VIEW_PROPERTY(titleFontSize, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor) +RCT_EXPORT_VIEW_PROPERTY(backTitle, NSString) +RCT_EXPORT_VIEW_PROPERTY(backTitleFontFamily, NSString) +RCT_EXPORT_VIEW_PROPERTY(backTitleFontSize, NSString) +RCT_EXPORT_VIEW_PROPERTY(backgroundColor, UIColor) +RCT_EXPORT_VIEW_PROPERTY(color, UIColor) +RCT_EXPORT_VIEW_PROPERTY(largeTitle, BOOL) +RCT_EXPORT_VIEW_PROPERTY(largeTitleFontFamily, NSString) +RCT_EXPORT_VIEW_PROPERTY(largeTitleFontSize, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(hideBackButton, BOOL) +RCT_EXPORT_VIEW_PROPERTY(hideShadow, BOOL) +// `hidden` is an UIView property, we need to use different name internally +RCT_REMAP_VIEW_PROPERTY(hidden, hide, BOOL) +RCT_EXPORT_VIEW_PROPERTY(translucent, BOOL) + +@end + +@implementation RCTConvert (RNSScreenStackHeader) + +RCT_ENUM_CONVERTER(RNSScreenStackHeaderSubviewType, (@{ + @"back": @(RNSScreenStackHeaderSubviewTypeBackButton), + @"left": @(RNSScreenStackHeaderSubviewTypeLeft), + @"right": @(RNSScreenStackHeaderSubviewTypeRight), + @"title": @(RNSScreenStackHeaderSubviewTypeTitle), + @"center": @(RNSScreenStackHeaderSubviewTypeCenter), + }), RNSScreenStackHeaderSubviewTypeTitle, integerValue) + +@end + +@implementation RNSScreenStackHeaderSubviewManager + +RCT_EXPORT_MODULE() + +RCT_EXPORT_VIEW_PROPERTY(type, RNSScreenStackHeaderSubviewType) + +- (UIView *)view +{ + return [[RNSScreenStackHeaderSubview alloc] initWithBridge:self.bridge]; +} + +@end \ No newline at end of file