mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-02-07 17:28:56 +08:00
This change adds new event that gets dispatched from native stack when screen transitioning is finished. The new event is used by react-navigation to update the library internal state that the triggered action has been finished. Previously we were relying solely on onAppear and onDisappear events, however those does not get triggered when new iOS 13 modals are used or when transparent modals are displayed. This is because with transparent modals the view below is still visible. We therefore needed another way of notifying the library that screen transition have finished despite the fact that disappear event couldn't be triggered. As a part of this change I also refactored invalid ref cycle-break code on iOS which was ought to remove reference cycle between view and view controller. This code have been moved to viewWillDisappear callback. Also on Android part small refactoring has been done and we removed the necessity to keep mActiveScreens array which was occasionally getting out of sync with the list of active fragments.
411 lines
15 KiB
Objective-C
411 lines
15 KiB
Objective-C
#import "RNSScreenStack.h"
|
|
#import "RNSScreen.h"
|
|
#import "RNSScreenStackHeaderConfig.h"
|
|
|
|
#import <React/RCTBridge.h>
|
|
#import <React/RCTUIManager.h>
|
|
#import <React/RCTUIManagerUtils.h>
|
|
#import <React/RCTShadowView.h>
|
|
#import <React/RCTRootContentView.h>
|
|
#import <React/RCTTouchHandler.h>
|
|
|
|
@interface RNSScreenStackView () <UINavigationControllerDelegate, UIAdaptivePresentationControllerDelegate, UIGestureRecognizerDelegate>
|
|
@end
|
|
|
|
@interface RNSScreenStackAnimator : NSObject <UIViewControllerAnimatedTransitioning>
|
|
- (instancetype)initWithOperation:(UINavigationControllerOperation)operation;
|
|
@end
|
|
|
|
@implementation RNSScreenStackView {
|
|
BOOL _needUpdate;
|
|
UINavigationController *_controller;
|
|
NSMutableArray<RNSScreenView *> *_reactSubviews;
|
|
NSMutableSet<RNSScreenView *> *_dismissedScreens;
|
|
NSMutableArray<UIViewController *> *_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];
|
|
_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--) {
|
|
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<UIViewControllerAnimatedTransitioning>)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<UIView *> *)reactSubviews
|
|
{
|
|
return _reactSubviews;
|
|
}
|
|
|
|
- (void)didUpdateReactSubviews
|
|
{
|
|
// do nothing
|
|
[self updateContainer];
|
|
}
|
|
|
|
- (void)setModalViewControllers:(NSArray<UIViewController *> *)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<UIViewController *> *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<UIViewController *> *)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<UIViewController *> *pushControllers = [NSMutableArray new];
|
|
NSMutableArray<UIViewController *> *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 <UIViewControllerContextTransitioning>)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<UIViewControllerContextTransitioning>)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
|