banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

使用 React Native Screens 建構一個 Native Navigation 之內部原理

上回說到,我們已經初步了解 React Native Screens 的 ScreenStackItem 用法。這節我們探索一下這個組件的原生實現,然後寫一個簡單的 Navigation 類。

ScreenStackItem 是怎麼實現的#

源碼定位#

進入 React Native Screens 的源碼,我們找到這個組件的位置。

image

我們看到這裡使用了 Screen 組件。繼續查找。

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

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

Screen 組件使用了 InnerScreen 組件。它是對 NativeView 的進一步封裝。

image

用到了 AnimatedScreen,它針對不同場景使用的 Native 組件不同。但是最終都是一個 Screen 基類。

image

上面的 AnimatedNativeScreenAnimatedNativeModalScreen 都是實實在在的原生組件了。他們分別對應的是 RNSScreenRNSModalScreen 組件。

以 iOS 為例,找到其原生實現。通過 RSScreen.h 頭文件看到,這個組件在原生中是一個 ViewController。所以它才會有這些生命週期事件。例如 viewDidLoadviewWillAppearviewDidAppear 等。


@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 方法中,會根據 RNSScreenViewstackPresentation 屬性,來決定是 push 還是 pop。然後調用 setPushViewControllers 或者 setModalViewControllers 方法,來更新原生的視圖。

setPushViewControllers 方法中調用原生的 pushViewController 方法。

image

所以,在 Native 中,整個 Navigation Controller 都是無狀態的,他雖然存儲 Controller 陣列,但是只會比較前後得出需要過渡的頁面。

這也導致了,在 React 中如果你沒有管理 ScreenStackItem,在觸發 dismiss 之後,雖然看到頁面返回了,但是再次點擊進入之後就會推入比上一次 +1 的頁面。

image

也因為這個原因,在 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

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。