react-native/ReactKit/Views/RCTNavigator.m

561 lines
22 KiB
Objective-C

// Copyright 2004-present Facebook. All Rights Reserved.
#import "RCTNavigator.h"
#import "RCTAssert.h"
#import "RCTConvert.h"
#import "RCTEventDispatcher.h"
#import "RCTLog.h"
#import "RCTNavItem.h"
#import "RCTUtils.h"
#import "RCTView.h"
#import "RCTWrapperViewController.h"
#import "UIView+ReactKit.h"
typedef NS_ENUM(NSUInteger, RCTNavigationLock) {
RCTNavigationLockNone,
RCTNavigationLockNative,
RCTNavigationLockJavaScript
};
NSInteger kNeverRequested = -1;
NSInteger kNeverProgressed = -10000;
@interface UINavigationController ()
// need to declare this since `UINavigationController` doesnt publicly declare the fact that it implements
// UINavigationBarDelegate :(
- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item;
@end
// http://stackoverflow.com/questions/5115135/uinavigationcontroller-how-to-cancel-the-back-button-event
// There's no other way to do this unfortunately :(
@interface RCTNavigationController : UINavigationController <UINavigationBarDelegate>
{
dispatch_block_t _scrollCallback;
}
@property (nonatomic, assign) RCTNavigationLock navigationLock;
@end
/**
* In general, `RCTNavigator` examines `_currentViews` (which are React child
* views), and compares them to `_navigationController.viewControllers` (which
* are controlled by UIKit).
*
* It is possible for JavaScript (`_currentViews`) to "get ahead" of native
* (`navigationController.viewControllers`) and vice versa. JavaScript gets
* ahead by adding/removing React subviews. Native gets ahead by swiping back,
* or tapping the back button. In both cases, the other system is initially
* unaware. And in both cases, `RCTNavigator` helps the other side "catch up".
*
* If `RCTNavigator` sees the number of react children have changed, it
* pushes/pops accordingly. If `RCTNavigator` sees a `UIKit` driven push/pop, it
* notifies JavaScript that this has happened, and expects that JavaScript will
* eventually render more children to match `UIKit`. There's no rush for
* JavaScript to catch up. But if it does rener anything, it must catch up to
* UIKit. It cannot deviate.
*
* To implement this, we need a lock, which we store on the native thread. This
* lock allows one of the systems to push/pop views. Whoever wishes to
* "get ahead" must obtain the lock. Whoever wishes to "catch up" must obtain
* the lock. One thread may not "get ahead" or "catch up" when the other has
* the lock. Once a thread has the lock, it can only do the following:
*
* 1. If it is behind, it may only catch up.
* 2. If it is caught up or ahead, it may push or pop.
*
*
* ========= Acquiring The Lock ==========
*
* JavaScript asynchronously acquires the lock using a native hook. It might be
* rejected and receive the return value `false`.
*
* We acquire the native lock in `shouldPopItem`, which is called right before
* native tries to push/pop, but only if JavaScript doesn't already have the
* lock.
*
* ======== While JavaScript Has Lock ====
*
* When JavaScript has the lock, we have to block all `UIKit` driven pops:
*
* 1. Block back button navigation:
* - Back button will invoke `shouldPopItem`, from which we return `NO` if
* JavaScript has the lock.
* - Back button will respect the return value `NO` and not permit
* navigation.
*
* 2. Block swipe-to-go-back navigation:
* - Swipe will trigger `shouldPopItem`, but swipe won't respect our `NO`
* return value so we must disable the gesture recognizer while JavaScript
* has the lock.
*
* ======== While Native Has Lock =======
*
* We simply deny JavaScript the right to acquire the lock.
*
*
* ======== Releasing The Lock ===========
*
* Recall that the lock represents who has the right to either push/pop (or
* catch up). As soon as we recognize that the side that has locked has carried
* out what it scheduled to do, we can release the lock, but only after any
* possible animations are completed.
*
* *IF* a scheduled operation results in a push/pop (not all do), then we can
* only release the lock after the push/pop animation is complete because
* UIKit. `didMoveToNavigationController` is invoked when the view is done
* pushing/popping/animating. Native swipe-to-go-back interactions can be
* aborted, however, and you'll never see that method invoked. So just to cover
* that case, we also put an animation complete hook in
* `animateAlongsideTransition` to make sure we free the lock, in case the
* scheduled native push/pop never actually happened.
*
* For JavaScript:
* - When we see that JavaScript has "caught up" to `UIKit`, and no pushes/pops
* were needed, we can release the lock.
* - When we see that JavaScript requires *some* push/pop, it's not yet done
* carrying out what it scheduled to do. Just like with `UIKit` push/pops, we
* still have to wait for it to be done animating
* (`didMoveToNavigationController` is a suitable hook).
*
*/
@implementation RCTNavigationController
/**
* @param callback Callback that is invoked when a "scroll" interaction begins
* so that `RCTNavigator` can notify `JavaScript`.
*/
- (instancetype)initWithScrollCallback:(dispatch_block_t)callback
{
if ((self = [super initWithNibName:nil bundle:nil])) {
_scrollCallback = callback;
}
return self;
}
/**
* Invoked when either a navigation item has been popped off, or when a
* swipe-back gesture has began. The swipe-back gesture doesn't respect the
* return value of this method. The back button does. That's why we have to
* completely disable the gesture recognizer for swipe-back while JS has the
* lock.
*/
- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item
{
if (self.interactivePopGestureRecognizer.state == UIGestureRecognizerStateBegan) {
if (self.navigationLock == RCTNavigationLockNone) {
self.navigationLock = RCTNavigationLockNative;
if (_scrollCallback) {
_scrollCallback();
}
} else if (self.navigationLock == RCTNavigationLockJavaScript) {
// This should never happen because we disable/enable the gesture
// recognizer when we lock the navigation.
RCTAssert(NO, @"Should never receive gesture start while JS locks navigator");
}
} else {
if (self.navigationLock == RCTNavigationLockNone) {
// Must be coming from native interaction, lock it - it will be unlocked
// in `didMoveToNavigationController`
self.navigationLock = RCTNavigationLockNative;
if (_scrollCallback) {
_scrollCallback();
}
} else if (self.navigationLock == RCTNavigationLockJavaScript) {
// This should only occur when JS has the lock, and
// - JS is driving the pop
// - Or the back button was pressed
// TODO: We actually want to disable the backbutton while JS has the
// lock, but it's not so easy. Even returning `NO` wont' work because it
// will also block JS driven pops. We simply need to disallow a standard
// back button, and instead use a custom one that tells JS to pop to
// length (`currentReactCount` - 1).
return [super navigationBar:navigationBar shouldPopItem:item];
}
}
return [super navigationBar:navigationBar shouldPopItem:item];
}
@end
@interface RCTNavigator() <RCTWrapperViewControllerNavigationListener, UINavigationControllerDelegate>
{
RCTEventDispatcher *_eventDispatcher;
NSInteger _numberOfViewControllerMovesToIgnore;
}
@property (nonatomic, assign) NSInteger previousRequestedTopOfStack;
// Previous views are only mainted in order to detect incorrect
// addition/removal of views below the `requestedTopOfStack`
@property (nonatomic, copy, readwrite) NSArray *previousViews;
@property (nonatomic, readwrite, strong) NSMutableArray *currentViews;
@property (nonatomic, readwrite, strong) RCTNavigationController *navigationController;
/**
* Display link is used to get high frequency sample rate during
* interaction/animation of view controller push/pop.
*
* - The run loop retains the displayLink.
* - `displayLink` retains its target.
* - We use `invalidate` to remove the `RCTNavigator`'s reference to the
* `displayLink` and remove the `displayLink` from the run loop.
*
*
* `displayLink`:
* --------------
*
* - Even though we could implement the `displayLink` cleanup without the
* `invalidate` hook by adding and removing it from the run loop at the
* right times (begin/end animation), we need to account for the possibility
* that the view itself is destroyed mid-interaction. So we always keep it
* added to the run loop, but start/stop it with interactions/animations. We
* remove it from the run loop when the view will be destroyed by React.
*
* +----------+ +--------------+
* | run loop o----strong--->| displayLink |
* +----------+ +--o-----------+
* | ^
* | |
* strong strong
* | |
* v |
* +---------o---+
* | RCTNavigator |
* +-------------+
*
* `dummyView`:
* ------------
* There's no easy way to get a callback that fires when the position of a
* navigation item changes. The actual layers that are moved around during the
* navigation transition are private. Our only hope is to use
* `animateAlongsideTransition`, to set a dummy view's position to transition
* anywhere from -1.0 to 1.0. We later set up a `CADisplayLink` to poll the
* `presentationLayer` of that dummy view and report the value as a "progress"
* percentage.
*
* It was critical that we added the dummy view as a subview of the
* transitionCoordinator's `containerView`, otherwise the animations would not
* work correctly when reversing the gesture direction etc. This seems to be
* undocumented behavior/requirement.
*
*/
@property (nonatomic, readonly, assign) CGFloat mostRecentProgress;
@property (nonatomic, readwrite, strong) CADisplayLink *displayLink;
@property (nonatomic, readonly, strong) NSTimer *runTimer;
@property (nonatomic, readonly, assign) NSInteger currentlyTransitioningFrom;
@property (nonatomic, readonly, assign) NSInteger currentlyTransitioningTo;
// Dummy view that we make animate with the same curve/interaction as the
// navigation animation/interaction.
@property (nonatomic, readonly, strong) UIView *dummyView;
@end
@implementation RCTNavigator
- (id)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
{
if ((self = [super initWithFrame:CGRectZero])) {
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(reportNavigationProgress:)];
_mostRecentProgress = kNeverProgressed;
_dummyView = [[UIView alloc] initWithFrame:CGRectZero];
if (_displayLink) {
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
_displayLink.paused = YES;
} else {
// It's okay to leak this on a build bot.
RCTLogWarn(@"Failed to create a display link (probably on automated build system) - using an NSTimer for AppEngine instead.");
_runTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 60.0) target:self selector:@selector(reportNavigationProgress:) userInfo:nil repeats:YES];
}
_eventDispatcher = eventDispatcher;
_previousRequestedTopOfStack = kNeverRequested; // So that we initialize with a push.
_previousViews = @[];
_currentViews = [[NSMutableArray alloc] initWithCapacity:0];
__weak RCTNavigator *weakSelf = self;
_navigationController = [[RCTNavigationController alloc] initWithScrollCallback:^{
[weakSelf dispatchFakeScrollEvent];
}];
_navigationController.delegate = self;
RCTAssert([self requestSchedulingJavaScriptNavigation], @"Could not acquire JS navigation lock on init");
[self addSubview:_navigationController.view];
[_navigationController.view addSubview:_dummyView];
}
return self;
}
- (void)reportNavigationProgress:(CADisplayLink *)sender
{
if (_currentlyTransitioningFrom != _currentlyTransitioningTo) {
UIView *topView = _dummyView;
id presentationLayer = [topView.layer presentationLayer];
CGRect frame = [presentationLayer frame];
CGFloat nextProgress = ABS(frame.origin.x);
// Don't want to spam the bridge, when the user holds their finger still mid-navigation.
if (nextProgress == _mostRecentProgress) {
return;
}
_mostRecentProgress = nextProgress;
[_eventDispatcher sendInputEventWithName:@"topNavigationProgress" body:@{
@"fromIndex": @(_currentlyTransitioningFrom),
@"toIndex": @(_currentlyTransitioningTo),
@"progress": @(nextProgress),
@"target": self.reactTag
}];
}
}
- (void)dealloc
{
_navigationController.delegate = nil;
}
- (UIViewController *)backingViewController
{
return _navigationController;
}
/**
* See documentation about lock lifecycle. This is only here to clean up
* swipe-back abort interaction, which leaves us *no* other way to clean up
* locks aside from the animation complete hook.
*/
- (void)navigationController:(UINavigationController *)navigationController
willShowViewController:(UIViewController *)viewController
animated:(BOOL)animated
{
id<UIViewControllerTransitionCoordinator> tc =
navigationController.topViewController.transitionCoordinator;
__weak RCTNavigator *weakSelf = self;
[tc.containerView addSubview: _dummyView];
[tc animateAlongsideTransition: ^(id<UIViewControllerTransitionCoordinatorContext> context) {
RCTWrapperViewController *fromController =
(RCTWrapperViewController *)[context viewControllerForKey:UITransitionContextFromViewControllerKey];
RCTWrapperViewController *toController =
(RCTWrapperViewController *)[context viewControllerForKey:UITransitionContextToViewControllerKey];
NSUInteger indexOfFrom = [_currentViews indexOfObject:fromController.navItem];
NSUInteger indexOfTo = [_currentViews indexOfObject:toController.navItem];
CGFloat destination = indexOfFrom < indexOfTo ? 1.0 : -1.0;
_dummyView.frame = (CGRect){{destination}};
_currentlyTransitioningFrom = indexOfFrom;
_currentlyTransitioningTo = indexOfTo;
if (indexOfFrom != indexOfTo) {
_displayLink.paused = NO;
}
}
completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
[weakSelf freeLock];
_currentlyTransitioningFrom = 0;
_currentlyTransitioningTo = 0;
_dummyView.frame = CGRectZero;
_displayLink.paused = YES;
// Reset the parallel position tracker
}];
}
- (BOOL)requestSchedulingJavaScriptNavigation
{
if (_navigationController.navigationLock == RCTNavigationLockNone) {
_navigationController.navigationLock = RCTNavigationLockJavaScript;
_navigationController.interactivePopGestureRecognizer.enabled = NO;
return YES;
}
return NO;
}
- (void)freeLock
{
_navigationController.navigationLock = RCTNavigationLockNone;
_navigationController.interactivePopGestureRecognizer.enabled = YES;
}
/**
* A React subview can be inserted/removed at any time, however if the
* `requestedTopOfStack` changes, there had better be enough subviews present
* to satisfy the push/pop.
*/
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
RCTAssert([view isKindOfClass:[RCTNavItem class]], @"RCTNavigator only accepts RCTNavItem subviews");
RCTAssert(
_navigationController.navigationLock == RCTNavigationLockJavaScript,
@"Cannot change subviews from JS without first locking."
);
[_currentViews insertObject:view atIndex:atIndex];
}
- (NSArray *)reactSubviews
{
return _currentViews;
}
- (BOOL)isValid
{
return _displayLink != nil;
}
- (void)invalidate
{
// Prevent displayLink from retaining the navigator indefinitely
[_displayLink invalidate];
_displayLink = nil;
_runTimer = nil;
}
- (void)layoutSubviews
{
[super layoutSubviews];
_navigationController.view.frame = self.bounds;
}
- (void)removeReactSubview:(UIView *)subview
{
if (_currentViews.count <= 0 || subview == _currentViews[0]) {
RCTLogError(@"Attempting to remove invalid RCT subview of RCTNavigator");
return;
}
[_currentViews removeObject:subview];
}
- (void)handleTopOfStackChanged
{
[_eventDispatcher sendInputEventWithName:@"topNavigateBack" body:@{
@"target":self.reactTag,
@"stackLength":@(_navigationController.viewControllers.count)
}];
}
- (void)dispatchFakeScrollEvent
{
[_eventDispatcher sendScrollEventWithType:RCTScrollEventTypeMove
reactTag:self.reactTag
scrollView:nil
userData:nil];
}
/**
* Must be overridden because UIKit removes the view's superview when used
* as a navigator - it's considered outside the view hierarchy.
*/
- (UIView *)reactSuperview
{
RCTAssert(self.superview != nil, @"put reactNavSuperviewLink back");
return self.superview ? self.superview : self.reactNavSuperviewLink;
}
- (void)reactBridgeDidFinishTransaction
{
// we can't hook up the VC hierarchy in 'init' because the subviews aren't
// hooked up yet, so we do it on demand here
[self addControllerToClosestParent:_navigationController];
NSInteger viewControllerCount = _navigationController.viewControllers.count;
// The "react count" is the count of views that are visible on the navigation
// stack. There may be more beyond this - that aren't visible, and may be
// deleted/purged soon.
NSInteger previousReactCount =
_previousRequestedTopOfStack == kNeverRequested ? 0 : _previousRequestedTopOfStack + 1;
NSInteger currentReactCount = _requestedTopOfStack + 1;
BOOL jsGettingAhead =
// ----- previously caught up ------ ------ no longer caught up -------
viewControllerCount == previousReactCount && currentReactCount != viewControllerCount;
BOOL jsCatchingUp =
// --- previously not caught up ---- --------- now caught up ----------
viewControllerCount != previousReactCount && currentReactCount == viewControllerCount;
BOOL jsMakingNoProgressButNeedsToCatchUp =
// --- previously not caught up ---- ------- still the same -----------
viewControllerCount != previousReactCount && currentReactCount == previousReactCount;
BOOL jsMakingNoProgressAndDoesntNeedTo =
// --- previously caught up -------- ------- still caught up ----------
viewControllerCount == previousReactCount && currentReactCount == previousReactCount;
BOOL reactPushOne = jsGettingAhead && currentReactCount == previousReactCount + 1;
BOOL reactPopN = jsGettingAhead && currentReactCount < previousReactCount;
// We can actually recover from this situation, but it would be nice to know
// when this error happens. This simply means that JS hasn't caught up to a
// back navigation before progressing. It's likely a bug in the JS code that
// catches up/schedules navigations. Eventually, let's recover from this
// error state, but in the mean time, let's get notified about any JS bugs.
RCTAssert(
jsGettingAhead ||
jsCatchingUp ||
jsMakingNoProgressButNeedsToCatchUp ||
jsMakingNoProgressAndDoesntNeedTo,
@"JS has only made partial progress to catch up to UIKit"
);
NSAssert(
currentReactCount <= _currentViews.count,
@"Cannot adjust current top of stack beyond available views"
);
// Views before the previous react count must not have changed. Views greater than previousReactCount
// up to currentReactCount may have changed.
for (NSInteger i = 0; i < MIN(_currentViews.count, MIN(_previousViews.count, previousReactCount)); i++) {
NSAssert(_currentViews[i] == _previousViews[i], @"current view should equal previous view");
}
RCTAssert(currentReactCount >= 1, @"should be at least one current view");
if (jsGettingAhead) {
if (reactPushOne) {
UIView *lastView = [_currentViews lastObject];
RCTWrapperViewController *vc = [[RCTWrapperViewController alloc] initWithNavItem:(RCTNavItem *)lastView eventDispatcher:_eventDispatcher];
vc.navigationListener = self;
_numberOfViewControllerMovesToIgnore = 1;
[_navigationController pushViewController:vc animated:(currentReactCount > 1)];
} else if (reactPopN) {
UIViewController *viewControllerToPopTo = [[_navigationController viewControllers] objectAtIndex:(currentReactCount - 1)];
_numberOfViewControllerMovesToIgnore = viewControllerCount - currentReactCount;
[_navigationController popToViewController:viewControllerToPopTo animated:YES];
} else {
RCTAssert(NO, @"Pushing or popping more than one view at a time from JS");
}
} else if (jsCatchingUp) {
[self freeLock]; // Nothing to push/pop
} else {
// Else, JS making no progress, could have been unrelated to anything nav.
return;
}
_previousViews = [_currentViews copy];
_previousRequestedTopOfStack = _requestedTopOfStack;
}
// TODO: This will likely fail when performing multiple pushes/pops. We must
// free the lock only after the *last* push/pop.
- (void)wrapperViewController:(RCTWrapperViewController *)wrapperViewController
didMoveToNavigationController:(UINavigationController *)navigationController
{
if (self.superview == nil) {
// If superview is nil, then a JS reload (Cmd+R) happened
// while a push/pop is in progress.
return;
}
RCTAssert(
(navigationController == nil || [_navigationController.viewControllers containsObject:wrapperViewController]),
@"if navigation controller is not nil, it should container the wrapper view controller"
);
RCTAssert(_navigationController.navigationLock == RCTNavigationLockJavaScript ||
_numberOfViewControllerMovesToIgnore == 0,
@"If JS doesn't have the lock there should never be any pending transitions");
/**
* When JS has the lock we want to keep track of when the request completes
* the pending transition count hitting 0 signifies this, and should always
* remain at 0 when JS does not have the lock
*/
if (_numberOfViewControllerMovesToIgnore > 0) {
_numberOfViewControllerMovesToIgnore -= 1;
}
if (_numberOfViewControllerMovesToIgnore == 0) {
[self handleTopOfStackChanged];
[self freeLock];
}
}
@end