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.
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.
It uses AnimatedScreen
, which employs different native components for different scenarios. However, they all ultimately derive from a base Screen class.
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.
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.
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