使用 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 的组件部分定义如下:
<SafeAreaProviderCompat>
<ScreenStack style={styles.container}>
{state.routes.concat(state.preloadedRoutes).map((route, index) => {
return (
<SceneView
key={route.key}
index={index}
focused={isFocused}
descriptor={descriptor}
previousDescriptor={previousDescriptor}
nextDescriptor={nextDescriptor}
isPresentationModal={isModal}
isPreloaded={isPreloaded}
/>
)
})}
</ScreenStack>
</SafeAreaProviderCompat>
SceneView 的部分定义如下:
<NavigationContext.Provider value={navigation}>
<NavigationRouteContext.Provider value={route}>
<ScreenStackItem
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}
></ScreenStackItem>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
首先我们从 Stack 组件中,看到最外层的 SafeAreaProvider
这是由 react-native-safearea-contexts 提供的。后面的 ScreenStack
是 react-native-screens 引入的。state.routes
是当前 Navigation 中存在的 routes,每一个 routes 都被一个 SceneView
组件消费的。
SceneView
组件主要也是对 ScreenStackItem
的一层封装。ScreenStackItem
也是 react-native-screens 引入的。
根据这个原理我们已经发现了基本的使用方法。
<ScreenStack>
<ScreenStackItem></ScreenStackItem>
<ScreenStackItem></ScreenStackItem>
</ScreenStack>
当我们从一个页面跳转到另一个页面时,会创建一个新的 ScreenStackItem
组件,并将其添加到 ScreenStack
中。
现在我们来实践一下这个发现。
import { ScreenStack, ScreenStackItem } from 'react-native-screens'
const Demo = () => {
const [otherRoutes, setOtherRoutes] = useState<ReactNode[]>([])
const pushNewRoute = useEventCallback(() => {
setOtherRoutes((prev) => [
...prev,
<ScreenStackItem
style={StyleSheet.absoluteFill}
key={prev.length}
screenId={`new-route-${prev.length}`}
>
<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)}
</ScreenStack>
)
}
注意 ScreenStack
和 ScreenStackItem
都是 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: (
<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>
)
}
到此为止,我们已经大致了解了 react-native-screens 的用法。
写的太长没人看,先写到这里。下一篇继续深入 react-native-screens。
Generated by RSStT. The copyright belongs to the original author.