使用 React Native Screens 构建一个 Native Navigation(一)

使用 React Native Screens 构建一个 Native Navigation(一)

静かな森 (Innei)
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/building-native-navigation-with-react-native-screens-part-1

React Navigation 与 React Native Screens

在使用 React Native 编写 app 时,通常都会选择使用 React Navigation或者 Expo Router 作为 Navigation。Expo Router 的本质只是对 React Navigation 上层封装。在使用 React Navigation 时也有两种选择。使用完全由 React Native 模拟的类原生 Navigation 样式和使用 Native Navigation 的容器实现。前者是 UI 模仿的和原生几乎一致但是并不是原生的 Navigation 而是由 JS 实现(@react-navigation/elements);后者这是原生 Navigation 实现,并和 React Native 桥接而成(@react-navigation/native-stack)。

这里我们不讨论前者。后者的 Native Navigation 背后的实现其实并不是由 React Navigation 提供的,在 react-navigation 的 repo 中并没有任何 native code。它背后的实现都是由 react-native-screens 提供的。但不幸的是,react-native-screens 作为下层依赖,官方并没有给出使用文档,只能我们去探索。

我们分析 @react-navigation/native-stack 的实现原理就能知道 react-native-screens 的具体用法,然后编写一个更加简易的 Navigation 了。

深入 React Navigation Native Stack

@react-navigation/native-stack 是 React Navigation repo 中的一个子包,位于 packages/native-stack 。其中 NativeStackView.native.tsx 是与 react-native-screens 建立联系的关键组件。

我们看到 NativeStackView 的组件部分定义如下:

&LTSafeAreaProviderCompat>
&LTScreenStack style={styles.container}>
{state.routes.concat(state.preloadedRoutes).map((route, index) => {
return (
&LTSceneView
key={route.key}
index={index}
focused={isFocused}
descriptor={descriptor}
previousDescriptor={previousDescriptor}
nextDescriptor={nextDescriptor}
isPresentationModal={isModal}
isPreloaded={isPreloaded}
/>
)
})}
&LT/ScreenStack>
&LT/SafeAreaProviderCompat>

SceneView 的部分定义如下:

&LTNavigationContext.Provider value={navigation}>
&LTNavigationRouteContext.Provider value={route}>
&LTScreenStackItem
key={route.key}
screenId={route.key}
activityState={isPreloaded ? 0 : 2}
style={StyleSheet.absoluteFill}
accessibilityElementsHidden={!focused}
importantForAccessibility={focused ? 'auto' : 'no-hide-descendants'}
customAnimationOnSwipe={animationMatchesGesture}
fullScreenSwipeEnabled={fullScreenGestureEnabled}
fullScreenSwipeShadowEnabled={fullScreenGestureShadowEnabled}
freezeOnBlur={freezeOnBlur}
gestureEnabled={
Platform.OS === 'android'
? // This prop enables handling of system back gestures on Android
// Since we handle them in JS side, we disable this
false
: gestureEnabled
}
homeIndicatorHidden={autoHideHomeIndicator}
hideKeyboardOnSwipe={keyboardHandlingEnabled}
navigationBarColor={navigationBarColor}
navigationBarTranslucent={navigationBarTranslucent}
navigationBarHidden={navigationBarHidden}
replaceAnimation={animationTypeForReplace}
stackPresentation={presentation === 'card' ? 'push' : presentation}
stackAnimation={animation}
screenOrientation={orientation}
sheetAllowedDetents={sheetAllowedDetents}
sheetLargestUndimmedDetentIndex={sheetLargestUndimmedDetentIndex}
sheetGrabberVisible={sheetGrabberVisible}
sheetInitialDetentIndex={sheetInitialDetentIndex}
sheetCornerRadius={sheetCornerRadius}
sheetElevation={sheetElevation}
sheetExpandsWhenScrolledToEdge={sheetExpandsWhenScrolledToEdge}
statusBarAnimation={statusBarAnimation}
statusBarHidden={statusBarHidden}
statusBarStyle={statusBarStyle}
statusBarColor={statusBarBackgroundColor}
statusBarTranslucent={statusBarTranslucent}
swipeDirection={gestureDirectionOverride}
transitionDuration={animationDuration}
onWillAppear={onWillAppear}
onWillDisappear={onWillDisappear}
onAppear={onAppear}
onDisappear={onDisappear}
onDismissed={onDismissed}
onGestureCancel={onGestureCancel}
onSheetDetentChanged={onSheetDetentChanged}
gestureResponseDistance={gestureResponseDistance}
nativeBackButtonDismissalEnabled={false} // on Android
onHeaderBackButtonClicked={onHeaderBackButtonClicked}
preventNativeDismiss={isRemovePrevented} // on iOS
onNativeDismissCancelled={onNativeDismissCancelled}
onHeaderHeightChange={Animated.event(
[
{
nativeEvent: {
headerHeight: rawAnimatedHeaderHeight,
},
},
],
{
useNativeDriver,
listener: (e) => {
if (
Platform.OS === 'android' &&
(options.headerBackground != null || options.headerTransparent)
) {
// FIXME: On Android, we get 0 if the header is translucent
// So we set a default height in that case
setHeaderHeight(ANDROID_DEFAULT_HEADER_HEIGHT + topInset)
return
}

if (
e.nativeEvent &&
typeof e.nativeEvent === 'object' &&
'headerHeight' in e.nativeEvent &&
typeof e.nativeEvent.headerHeight === 'number'
) {
const headerHeight =
e.nativeEvent.headerHeight + headerHeightCorrectionOffset

// Only debounce if header has large title or search bar
// As it's the only case where the header height can change frequently
const doesHeaderAnimate =
Platform.OS === 'ios' &&
(options.headerLargeTitle || options.headerSearchBarOptions)

if (doesHeaderAnimate) {
setHeaderHeightDebounced(headerHeight)
} else {
setHeaderHeight(headerHeight)
}
}
},
},
)}
contentStyle={[
presentation !== 'transparentModal' &&
presentation !== 'containedTransparentModal' && {
backgroundColor: colors.background,
},
contentStyle,
]}
headerConfig={headerConfig}
unstable_sheetFooter={unstable_sheetFooter}
>&LT/ScreenStackItem>
&LT/NavigationRouteContext.Provider>
&LT/NavigationContext.Provider>

首先我们从 Stack 组件中,看到最外层的 SafeAreaProvider 这是由 react-native-safearea-contexts 提供的。后面的 ScreenStack 是 react-native-screens 引入的。state.routes 是当前 Navigation 中存在的 routes,每一个 routes 都被一个 SceneView 组件消费的。

SceneView 组件主要也是对 ScreenStackItem 的一层封装。ScreenStackItem 也是 react-native-screens 引入的。

根据这个原理我们已经发现了基本的使用方法。

&LTScreenStack>
&LTScreenStackItem>&LT/ScreenStackItem>
&LTScreenStackItem>&LT/ScreenStackItem>
&LT/ScreenStack>

当我们从一个页面跳转到另一个页面时,会创建一个新的 ScreenStackItem 组件,并将其添加到 ScreenStack 中。

现在我们来实践一下这个发现。

import { ScreenStack, ScreenStackItem } from 'react-native-screens'

const Demo = () => {
const [otherRoutes, setOtherRoutes] = useState&LTReactNode[]>([])
const pushNewRoute = useEventCallback(() => {
setOtherRoutes((prev) => [
...prev,
&LTScreenStackItem
style={StyleSheet.absoluteFill}
key={prev.length}
screenId={`new-route-${prev.length}`}
>
&LTView className="flex-1 items-center justify-center bg-white">
&LTText>New Route&LT/Text>
&LT/View>
&LT/ScreenStackItem>,
])
})
return (
&LTScreenStack style={StyleSheet.absoluteFill}>
&LTScreenStackItem screenId="root" style={StyleSheet.absoluteFill}>
&LTView className="flex-1 items-center justify-center bg-white">
&LTText>Root Route&LT/Text>
&LTButton title="Push New Route" onPress={pushNewRoute} />
&LT/View>
&LT/ScreenStackItem>
{otherRoutes.map((route) => route)}
&LT/ScreenStack>
)
}

注意 ScreenStackScreenStackItem 都是 Native 组件,React Native 并不感知其组件尺寸,所以你需要使用 style 属性来指定其尺寸,对于整个容器,一般使用 StyleSheet.absoluteFill 来指定。

从上面视频可以看到,我们点击按钮后,新的页面出现了,并且和原生的行为一致。

但是,我们很快注意到,当我们返回新页面之后,虽然在 UI 上已经返回到了上级页面,但是在 React 中的状态没有更新,导致下次再点击按钮时,会创建多次新页面。

我们很快注意到 ScreenStackItem 提供了众多生命周期回调。

  • onWillAppear: 原生 ViewController viewWillAppear 回调
  • onWillDisappear: 原生 ViewController viewWillDisappear 回调
  • onAppear: 原生 ViewController didAppear 回调
  • onDisappear: 原生 ViewController didDisappear 回调
  • onDismissed: ViewController 被销毁时回调

这里我们使用 onDismissed 来更新 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: (
&LTScreenStackItem
style={StyleSheet.absoluteFill}
key={prev.length}
screenId={screenId}
onDismissed={() => {
setOtherRoutes((prev) => prev.filter((route) => route.screenId !== screenId))
}}
>
&LTView className="flex-1 items-center justify-center bg-white">
&LTText>New Route&LT/Text>
&LT/View>
&LT/ScreenStackItem>
),
},
])
})
return (
&LTScreenStack style={StyleSheet.absoluteFill}>
&LTScreenStackItem screenId="root" style={StyleSheet.absoluteFill}>
&LTView className="flex-1 items-center justify-center bg-white">
&LTText>Root Route&LT/Text>
&LTButton title="Push New Route" onPress={pushNewRoute} />
&LT/View>
&LT/ScreenStackItem>
{otherRoutes.map((route) => route.route)}
&LT/ScreenStack>
)
}

到此为止,我们已经大致了解了 react-native-screens 的用法。

写的太长没人看,先写到这里。下一篇继续深入 react-native-screens。


看完了?说点什么呢


Generated by RSStT. The copyright belongs to the original author.

Source

Report Page