上回说到,我们已经初步了解 React Native Screens 的 ScreenStackItem
用法。这节我们探索一下这个组件的原生实现,然后写一个简单的 Navigation 类。
ScreenStackItem
是怎么实现的#
源码定位#
进入 React Native Screens 的源码,我们找到这个组件的位置。
我们看到这里使用了 Screen
组件。继续查找。
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
等。
@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
的。
@interface RNSModalScreen : RNSScreenView
@end
[!NOTE]
这个为什么需要分别定义两个组件?
在 iOS 中,modal 和普通的 view 有所区别,modal 需要脱离 Root Navigation Controller 进入一个新的 Navigation Controller 中。它是一个孤立的视图。
那么,光有 ViewController 肯定是不行的。我们还缺少一个管理 VC 的 Navigation Controller。还记得之前我们在使用 ScreenStackItem
的时候,需要包一个 ScreenStack
吗?
我们找到它,在 iOS 中对应 RNSScreenStack
,它是一个 Navigation Controller。
@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 这边的组件状态发生了更新。
还记得吗,回顾一下。
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>
<Button title="Push New Route" onPress={pushNewRoute} />
</View>
</ScreenStackItem>
{otherRoutes.map((route) => route.route)}
</ScreenStack>
)
}
我们只是通过更新 React Children 对应有几个 ScreenStackItem
组件,就实现了页面之间的切换。
那么,这个过程到底发生了什么呢?
其实都是在 RNSScreenStack
中处理的,通过比较更新前后的 children 数组,来决定是 push 还是 pop。
- (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 的数量。
onDismissed?: (e: NativeSyntheticEvent<{ dismissCount: number }>) => void;
下一步计划#
好了,这篇文章就到这里了。篇幅已经有点长了。
那么,下一篇文章,我们再来实现一个简单的 Navigation 类吧。
此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/tech/building-native-navigation-with-react-native-screens-part-2