上回说到,我们已经初步了解 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 では、モーダルと通常のビューには違いがあり、モーダルは 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
メソッドを呼び出すことでページ間の切り替えが実現できます。しかし、前回の記事のデモでは、ネイティブメソッドを呼び出すことなく、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
{
// 子供のレイアウトが設定されるまで待つ必要があります。この時点ではまだレイアウトが設定されていませんが、レイアウト呼び出しはすでに UI スレッドにキューイングされています。UI キューに更新呼び出しをキューイングすることで、更新がレイアウトの後に実行されることが保証されます。
dispatch_async(dispatch_get_main_queue(), ^{
[self maybeAddToParentAndUpdateContainer];
});
}
- (void)maybeAddToParentAndUpdateContainer
{
BOOL wasScreenMounted = _controller.parentViewController != nil;
if (!self.window && !wasScreenMounted) {
// スタックがマウントされるまで親コントローラーに追加するのを待ちます。
// ウィンドウがアタッチされていないときに追加すると、一部のビュー遷移がブロックされることがあります(つまり、モーダル遷移)し、内部ビューコントローラーの状態が画面上のものと同期しなくなります。
return;
}
[self updateContainer];
if (!wasScreenMounted) {
// スタックがまだ親 VC に追加されていない場合、2つのことを行います:
// 1) updateContainer(上記のもの)を実行します - これは、VC がマウントされる前にプッシュビューコントローラーをインストールしたいからです。親に追加された後にそれを行うと、プッシュ更新操作が UIKit によってブロックされます。
// 2) 親にナビゲーション VS を追加します - これは VC ライフサイクルイベントが適切にディスパッチされるために必要です。
// 3) 再度 updateContainer を呼び出します - 今回はモーダルコントローラーを開くためにこれを行います。モーダルは(1)では開かれません。なぜなら、ナビゲーターが親に追加される必要があるからです。このケースは setModalViewControllers で優雅に処理され、いつでも開くことが再試行できます。
[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) {
// リストの最初の画面は「プッシュコントローラー」として配置する必要があります
[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
メソッドが呼び出されます。
したがって、ネイティブでは、全体の Navigation Controller は無状態です。コントローラーの配列を保存していますが、前後を比較して遷移する必要のあるページを決定するだけです。
これにより、React で ScreenStackItem を管理しない場合、dismiss をトリガーした後にページが戻ったように見えますが、再度クリックして入ると、前回よりも +1 のページがプッシュされることになります。
この理由から、onDismissed イベント内で、ネイティブは 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