使用 React Native Screens 构建一个 Native Navigation 之内部原理

上回说到，我们已经初步了解 React Native Screens 的 ScreenStackItem 用法。这节我们探索一下这个组件的原生实现，然后写一个简单的 Navigation 类。

ScreenStackItem 是怎么实现的

源码定位

进入 React Native Screens 的源码，我们找到这个组件的位置。

我们看到这里使用了 Screen 组件。继续查找。

Copy
const Screen = React.forwardRef<View, ScreenProps>((props, ref) => {
const ScreenWrapper = React.useContext(ScreenContext) || InnerScreen

return <ScreenWrapper {...props} ref={ref} />
})

Screen 组件使用了 InnerScreen 组件。它是对 NativeView 的进一步封装。

用到了 AnimatedScreen，它针对不同场景使用的 Native 组件不同。但是最终都是一个 Screen 基类。

上面的 AnimatedNativeScreen 和 AnimatedNativeModalScreen 都是实实在在的原生组件了。他们分别对应的是 RNSScreen 和 RNSModalScreen 组件。

以 iOS 为例，找到其原生实现。通过 RSScreen.h 头文件看到，这个组件在原生中是一个 ViewController。所以它才会有这些生命周期事件。例如 viewDidLoad、viewWillAppear、viewDidAppear 等。

Copy

@interface RNSScreen : UIViewController <RNSViewControllerDelegate>

- (instancetype)initWithView:(UIView *)view;
- (UIViewController *)findChildVCForConfigAndTrait:(RNSWindowTrait)trait includingModals:(BOOL)includingModals;
- (BOOL)hasNestedStack;
- (void)calculateAndNotifyHeaderHeightChangeIsModal:(BOOL)isModal;
- (void)notifyFinishTransitioning;
- (RNSScreenView *)screenView;
#ifdef RCT_NEW_ARCH_ENABLED
- (void)setViewToSnapshot;
- (CGFloat)calculateHeaderHeightIsModal:(BOOL)isModal;
#endif

@end

而 RNSModalScreen 则是继承于 RNSScreen 的。

Copy
@interface RNSModalScreen : RNSScreenView
@end

[!NOTE]

这个为什么需要分别定义两个组件？

在 iOS 中，modal 和普通的 view 有所区别，modal 需要脱离 Root Navigation Controller 进入一个新的 Navigation Controller 中。它是一个孤立的视图。

那么，光有 ViewController 肯定是不行的。我们还缺少一个管理 VC 的 Navigation Controller。还记得之前我们在使用 ScreenStackItem 的时候，需要包一个 ScreenStack 吗？

我们找到它，在 iOS 中对应 RNSScreenStack，它是一个 Navigation Controller。

Copy
@interface RNSNavigationController : UINavigationController <RNSViewControllerDelegate, UIGestureRecognizerDelegate>

@end

@interface RNSScreenStackView :
#ifdef RCT_NEW_ARCH_ENABLED
RCTViewComponentView <RNSScreenContainerDelegate>
#else
UIView <RNSScreenContainerDelegate, RCTInvalidating>
#endif

- (void)markChildUpdated;
- (void)didUpdateChildren;
- (void)startScreenTransition;
- (void)updateScreenTransition:(double)progress;
- (void)finishScreenTransition:(BOOL)canceled;

@property (nonatomic) BOOL customAnimation;
@property (nonatomic) BOOL disableSwipeBack;

#ifdef RCT_NEW_ARCH_ENABLED
#else
@property (nonatomic, copy) RCTDirectEventBlock onFinishTransitioning;
#endif // RCT_NEW_ARCH_ENABLED

@end

@interface RNSScreenStackManager : RCTViewManager <RCTInvalidating>

@end

页面切换的实现原理

现在 Navigation Controller 和 View Controller 都有了，那么 React Native Screens 是如果管理页面之间的切换并做出动画的呢。

我们知道在 iOS 中，使用在 Navigation Controller 上命令式调用 pushViewController 方法，就可以实现页面之间的切换。但是上一篇文章中的 demo 中，我们并没有调用任何原生方法，只是 React 这边的组件状态发生了更新。

还记得吗，回顾一下。

Copy
const Demo = () => {
const [otherRoutes, setOtherRoutes] = useState<
{
screenId: string
route: ReactNode
}[]
>([])
const cnt = useRef(0)
const pushNewRoute = useEventCallback(() => {
const screenId = `new-route-${cnt.current}`
cnt.current++
setOtherRoutes((prev) => [
...prev,
{
screenId,
route: (
<ScreenStackItem
style={StyleSheet.absoluteFill}
key={prev.length}
screenId={screenId}
onDismissed={() => {
setOtherRoutes((prev) => prev.filter((route) => route.screenId !== screenId))
}}
>
<View className="flex-1 items-center justify-center bg-white">
<Text>New Route</Text>
</View>
</ScreenStackItem>
),
},
])
})
return (
<ScreenStack style={StyleSheet.absoluteFill}>
<ScreenStackItem screenId="root" style={StyleSheet.absoluteFill}>
<View className="flex-1 items-center justify-center bg-white">
<Text>Root Route</Text>

</View>
</ScreenStackItem>
{otherRoutes.map((route) => route.route)}
</ScreenStack>
)
}

我们只是通过更新 React Children 对应有几个 ScreenStackItem 组件，就实现了页面之间的切换。

那么，这个过程到底发生了什么呢？

其实都是在 RNSScreenStack 中处理的，通过比较更新前后的 children 数组，来决定是 push 还是 pop。

Copy

- (void)didUpdateReactSubviews
{
// we need to wait until children have their layout set. At this point they don't have the layout
// set yet, however the layout call is already enqueued on ui thread. Enqueuing update call on the
// ui queue will guarantee that the update will run after layout.
dispatch_async(dispatch_get_main_queue(), ^{
[self maybeAddToParentAndUpdateContainer];
});
}

- (void)maybeAddToParentAndUpdateContainer
{
BOOL wasScreenMounted = _controller.parentViewController != nil;
if (!self.window && !wasScreenMounted) {
// We wait with adding to parent controller until the stack is mounted.
// If we add it when window is not attached, some of the view transitions will be blocked (i.e.
// modal transitions) and the internal view controler's state will get out of sync with what's
// on screen without us knowing.
return;
}
[self updateContainer];
if (!wasScreenMounted) {
// when stack hasn't been added to parent VC yet we do two things:
// 1) we run updateContainer (the one above) – we do this because we want push view controllers to
// be installed before the VC is mounted. If we do that after it is added to parent the push
// updates operations are going to be blocked by UIKit.
// 2) we add navigation VS to parent – this is needed for the VC lifecycle events to be dispatched
// properly
// 3) we again call updateContainer – this time we do this to open modal controllers. Modals
// won't open in (1) because they require navigator to be added to parent. We handle that case
// gracefully in setModalViewControllers and can retry opening at any point.
[self reactAddControllerToClosestParent:_controller];
[self updateContainer];
}
}

- (void)updateContainer
{
NSMutableArray<UIViewController *> *pushControllers = [NSMutableArray new];
NSMutableArray<UIViewController *> *modalControllers = [NSMutableArray new];
for (RNSScreenView *screen in _reactSubviews) {
if (!screen.dismissed && screen.controller != nil && screen.activityState != RNSActivityStateInactive) {
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];
}

当 React 组件的 children 发生变化会调用 didUpdateReactSubviews 方法。然后最后进入到 updateContainer 方法中。

在 updateContainer 方法中，会根据 RNSScreenView 的 stackPresentation 属性，来决定是 push 还是 pop。然后调用 setPushViewControllers 或者 setModalViewControllers 方法，来更新原生的视图。

在 setPushViewControllers 方法中调用原生的 pushViewController 方法。

所以，在 Native 中，整个 Navigation Controller 都是无状态的，他虽然存储 Controller 数组，但是只会比较前后得出需要过度的页面。

这也导致了，在 React 中如果你没有管理 ScreenStackItem，在触发 dismiss 之后，虽然看到页面返回了，但是再次点击进入之后就会推入比上一次 +1 的页面。

也因为这个原因，在 onDismissed 事件中，Native 无法告诉 React Native 这边被 dismiss 的页面是哪个，而是只能提供 dismiss 的数量。

Copy
onDismissed?: (e: NativeSyntheticEvent<{ dismissCount: number }>) => void;

下一步计划

好了，这篇文章就到这里了。篇幅已经有点长了。

那么，下一篇文章，我们再来实现一个简单的 Navigation 类吧。

此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/tech/building-native-navigation-with-react-native-screens-part-2