上回說到,我們已經初步了解 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>新路由</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>根路由</Text>
<Button title="推送新路由" 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