banner
innei

innei

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

Building a Native Navigation with React Native Screens: Internal Principles

Last time we mentioned that we have a preliminary understanding of the usage of ScreenStackItem in React Native Screens. In this section, we will explore the native implementation of this component and then write a simple Navigation class.

How ScreenStackItem is Implemented#

Source Code Location#

Entering the source code of React Native Screens, we find the location of this component.

image

We see that the Screen component is used here. Let's continue searching.

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

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

The Screen component uses the InnerScreen component. It is a further encapsulation of NativeView.

image

It uses AnimatedScreen, which employs different native components for different scenarios. However, they all ultimately derive from a base Screen class.

image

The above AnimatedNativeScreen and AnimatedNativeModalScreen are indeed native components. They correspond to the RNSScreen and RNSModalScreen components, respectively.

Taking iOS as an example, we find its native implementation. Through the RSScreen.h header file, we see that this component is a ViewController in native code. This is why it has these lifecycle events, such as viewDidLoad, viewWillAppear, viewDidAppear, etc.


@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

The RNSModalScreen inherits from RNSScreen.

@interface RNSModalScreen : RNSScreenView
@end

[!NOTE]

Why do we need to define two separate components?

In iOS, modals and ordinary views are different; modals need to detach from the Root Navigation Controller and enter a new Navigation Controller. It is an isolated view.

So, having just a ViewController is not enough. We also need a Navigation Controller to manage the VC. Remember when we used ScreenStackItem, we needed to wrap it in a ScreenStack?

We find it, which corresponds to RNSScreenStack in iOS, and it is a 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

The Implementation Principle of Page Switching#

Now that we have both the Navigation Controller and View Controller, how does React Native Screens manage the switching between pages and perform animations?

We know that in iOS, by imperatively calling the pushViewController method on the Navigation Controller, we can switch between pages. However, in the demo from the previous article, we did not call any native methods; we only updated the component state on the React side.

Do you remember? Let's review.

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>
  )
}

We achieved the switching between pages simply by updating the number of ScreenStackItem components corresponding to the React Children.

So, what actually happens during this process?

It is all handled in RNSScreenStack, which decides whether to push or pop by comparing the children array before and after the update.


- (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];
}

When the children of the React component change, the didUpdateReactSubviews method is called. It eventually enters the updateContainer method.

In the updateContainer method, it decides whether to push or pop based on the stackPresentation property of RNSScreenView, and then calls setPushViewControllers or setModalViewControllers to update the native view.

The setPushViewControllers method calls the native pushViewController method.

image

Thus, in Native, the entire Navigation Controller is stateless; although it stores an array of Controllers, it only compares before and after to determine which page needs to transition.

This also leads to the situation where, if you do not manage ScreenStackItem in React, after triggering dismiss, although you see the page return, clicking to enter again will push a page that is +1 from the last time.

image

For this reason, in the onDismissed event, Native cannot inform React Native which page was dismissed; it can only provide the dismiss count.

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

Next Steps#

Alright, this article ends here. The length has become a bit long.

In the next article, let's implement a simple Navigation class.

This article was synchronized and updated to xLog by Mix Space. The original link is https://innei.in/posts/tech/building-native-navigation-with-react-native-screens-part-2

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.