使用 React Native Screens 构建一个 Simple Navigation

使用 React Native Screens 构建一个 Simple Navigation

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

上回说到,我们已经大概了解了 React Native Screen 内部是如何工作的。这篇文章将综合前面的内容,实现一个简单的 React Native Navigation。

构想

一个 Navigation 最基础的就是要实现一个 navigate 方法。

navigate 方法需要实现前进后退的基本行为。

模拟在 iOS Native 中,我们可以使用 pushController 和 presentController 两个方法去实现前进的行为。

那么我们可以命名为:

  • pushControllerView 推入 Stack Navigation
  • presentControllerView 推入 Modal

然后是后退的行为,命名为:

  • back 回退 Stack Navigation
  • dismiss 关闭 Modal

然后我们需要集中式管理 push 和 present 的 navigation 的数据,然后在 UI 中呈现和反馈。

实现

Navigation 框架

顺着上面的构想,我们首先需要一个 Navigation 的类。

class Navigation {}

我们需要把 navigation 的数据保存在这个类中,所以我还需要定义数据的类型。

export interface Route {
id: string

Component?: NavigationControllerView&LTany>
element?: React.ReactElement

type: NavigationControllerViewType
props?: unknown
screenOptions?: NavigationControllerViewExtraProps
}

export type NavigationControllerViewExtraProps = {
/**
* Unique identifier for the view.
*/
id?: string

/**
* Title for the view.
*/
title?: string

/**
* Whether the view is transparent.
*/
transparent?: boolean
} & Pick<
ScreenProps,
| 'sheetAllowedDetents'
| 'sheetCornerRadius'
| 'sheetExpandsWhenScrolledToEdge'
| 'sheetElevation'
| 'sheetGrabberVisible'
| 'sheetInitialDetentIndex'
| 'sheetLargestUndimmedDetentIndex'
>

export type NavigationControllerView&LTP = {}> = FC&LTP> &
NavigationControllerViewExtraProps

上面我们定义 NavigationControllerView 的类型,和 Route 的类型。NavigationControllerView 用于定义 NavigationView 的组件类型,Route 用于在 Navigation 类中保存 navigation 的数据。

为了实现在 UI 中的响应式,我们使用 Jotai 去管理这个数据。

export type ChainNavigationContextType = {
routesAtom: PrimitiveAtom&LTRoute[]>
}

在 Navigation 类中初始化数据:

export class Navigation {
private ctxValue: ChainNavigationContextType
constructor(ctxValue: ChainNavigationContextType) {
this.ctxValue = ctxValue
}

static readonly rootNavigation: Navigation = new Navigation({
routesAtom: atom&LTRoute[]>([]),
})
}

Navigation 数据管理

上面已经定义了 Navigation 的类型,然后我们通过对数据的控制来实现 push/back 的操作。

class Navigation {
private viewIdCounter = 0
private __push(route: Route) {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const hasRoute = routes.some((r) => r.id === route.id)
if (hasRoute && routes.at(-1)?.id === route.id) {
console.warn(`Top of stack is already ${route.id}`)
return
} else if (hasRoute) {
route.id = `${route.id}-${this.viewIdCounter++}`
}
jotaiStore.set(this.ctxValue.routesAtom, [...routes, route])
}

private resolveScreenOptions&LTT>(
view: NavigationControllerView&LTT>,
): Required&LTNavigationControllerViewExtraProps> {
return {
transparent: view.transparent ?? false,
id: view.id ?? view.name ?? `view-${this.viewIdCounter++}`,
title: view.title ?? '',
// Form Sheet
sheetAllowedDetents: view.sheetAllowedDetents ?? 'fitToContents',
sheetCornerRadius: view.sheetCornerRadius ?? 16,
sheetExpandsWhenScrolledToEdge:
view.sheetExpandsWhenScrolledToEdge ?? true,
sheetElevation: view.sheetElevation ?? 24,
sheetGrabberVisible: view.sheetGrabberVisible ?? true,
sheetInitialDetentIndex: view.sheetInitialDetentIndex ?? 0,
sheetLargestUndimmedDetentIndex:
view.sheetLargestUndimmedDetentIndex ?? 'medium',
}
}

pushControllerView&LTT>(view: NavigationControllerView&LTT>, props?: T) {
const screenOptions = this.resolveScreenOptions(view)
this.__push({
id: screenOptions.id,
type: 'push',
Component: view,
props,
screenOptions,
})
}

presentControllerView&LTT>(
view: NavigationControllerView&LTT>,
props?: T,
type: Exclude&LTNavigationControllerViewType, 'push'> = 'modal',
) {
const screenOptions = this.resolveScreenOptions(view)
this.__push({
id: screenOptions.id,
type,
Component: view,
props,
screenOptions,
})
}
}

之后,back 的操作也非常简单。

class Navigation {
private __pop() {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const lastRoute = routes.at(-1)
if (!lastRoute) {
return
}
jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, -1))
}

/**
* Dismiss the current modal.
*/
dismiss() {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const lastModalIndex = routes.findLastIndex((r) => r.type !== 'push')
if (lastModalIndex === -1) {
return
}
jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex))
}

back() {
return this.__pop()
}
}

从上面的代码不难看出,其实我们只是通过对数据的操作实现 Navigation 的逻辑。而真正要在 UI 中呈现 Navigation 的效果还是需要通过 React Native Screens 来实现。

Navigation UI 框架

在上面的文章中,我们已经知道了我们只需要通过传入不同 React Children 到 React Native Screens 的 &LTScreenStack /> 中就能实现原生的 navigate 的效果。

那我们现在只需要透过 Navigation 类中管理的数据,通过一些转换就能实现了。

首先我们在 React 中定义一个 Navigation 上下文对象,确保得到正确的 Navigation 实例(如有多个)。

export const NavigationInstanceContext = createContext&LTNavigation>(null!)

然后,编写一个 RootStackNavigation 组件。

import { SafeAreaProvider } from 'react-native-safe-area-context'
import type { ScreenStackHeaderConfigProps } from 'react-native-screens'
import { ScreenStack } from 'react-native-screens'

interface RootStackNavigationProps {
children: React.ReactNode

headerConfig?: ScreenStackHeaderConfigProps
}

export const RootStackNavigation = ({
children,
headerConfig,
}: RootStackNavigationProps) => {
return (
&LTSafeAreaProvider>
&LTNavigationInstanceContext value={Navigation.rootNavigation}>
&LTScreenStack style={StyleSheet.absoluteFill}>
&LTScreenStackItem headerConfig={headerConfig} screenId="root">
{children}
&LT/ScreenStackItem>
&LT/ScreenStack>
&LT/NavigationInstanceContext>
&LT/SafeAreaProvider>
)
}

在 App 的入口文件中,我们使用 RootStackNavigation 组件包裹整个应用。

export default function App() {
return (
&LTRootStackNavigation>
&LTHomeScreen>
&LT/RootStackNavigation>
)
}

const HomeScreen = () => {
return (
&LTView>
&LTText>Home&LT/Text>
&LT/View>
)
}

RootStackNavigation 组件的 Children 为首屏,也是 Navigation 的根组件,不参与整体的 navigate 行为,即不能被 pop。

Navigation 数据在 UI 中呈现

接下来我们需要把这些数据转换到 React 元素传入到 React Native Screens 的 &LTScreenStackItem /> 中。

const ScreenItemsMapper = () => {
const chainCtxValue = use(ChainNavigationContext)
const routes = useAtomValue(chainCtxValue.routesAtom)

const routeGroups = useMemo(() => {
const groups: Route[][] = []
let currentGroup: Route[] = []

routes.forEach((route, index) => {
// Start a new group if this is the first route or if it's a modal (non-push)
if (index === 0 || route.type !== 'push') {
// Save the previous group if it's not empty
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
// Start a new group with this route
currentGroup = [route]
} else {
// Add to the current group if it's a push route
currentGroup.push(route)
}
})

// Add the last group if it's not empty
if (currentGroup.length > 0) {
groups.push(currentGroup)
}

return groups
}, [routes])

return (
&LTGroupedNavigationRouteContext value={routeGroups}>
{routeGroups.map((group) => {
const isPushGroup = group.at(0)?.type === 'push'
if (!isPushGroup) {
return &LTModalScreenStackItems key={group.at(0)?.id} routes={group} />
}
return &LTMapScreenStackItems key={group.at(0)?.id} routes={group} />
})}
&LT/GroupedNavigationRouteContext>
)
}

const MapScreenStackItems: FC<{
routes: Route[]
}> = memo(({ routes }) => {
return routes.map((route) => {
return (
&LTScreenStackItem
stackPresentation={'push'}
key={route.id}
screenId={route.id}
screenOptions={route.screenOptions}
>
&LTResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
&LT/ScreenStackItem>
)
})
})

const ModalScreenStackItems: FC<{
routes: Route[]
}> = memo(({ routes }) => {
const rootModalRoute = routes.at(0)
const modalScreenOptionsCtxValue = useMemo<
PrimitiveAtom&LTScreenOptionsContextType>
>(() => atom({}), [])

const modalScreenOptions = useAtomValue(modalScreenOptionsCtxValue)

if (!rootModalRoute) {
return null
}
const isFormSheet = rootModalRoute.type === 'formSheet'
const isStackModal = !isFormSheet

// Modal screens are always full screen on Android
const isFullScreen =
isAndroid ||
(rootModalRoute.type !== 'modal' && rootModalRoute.type !== 'formSheet')

if (isStackModal) {
return (
&LTModalScreenItemOptionsContext value={modalScreenOptionsCtxValue}>
&LTWrappedScreenItem
stackPresentation={rootModalRoute?.type}
key={rootModalRoute.id}
screenId={rootModalRoute.id}
screenOptions={rootModalRoute.screenOptions}
{...modalScreenOptions}
>
&LTModalSafeAreaInsetsContext hasTopInset={isFullScreen}>
&LTScreenStack style={StyleSheet.absoluteFill}>
&LTWrappedScreenItem
screenId={rootModalRoute.id}
screenOptions={rootModalRoute.screenOptions}
>
&LTResolveView
comp={rootModalRoute.Component}
element={rootModalRoute.element}
props={rootModalRoute.props}
/>
&LT/WrappedScreenItem>
{routes.slice(1).map((route) => {
return (
&LTWrappedScreenItem
stackPresentation={'push'}
key={route.id}
screenId={route.id}
screenOptions={route.screenOptions}
>
&LTResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
&LT/WrappedScreenItem>
)
})}
&LT/ScreenStack>
&LT/ModalSafeAreaInsetsContext>
&LT/WrappedScreenItem>
&LT/ModalScreenItemOptionsContext>
)
}

return routes.map((route) => {
return (
&LTModalScreenItemOptionsContext
value={modalScreenOptionsCtxValue}
key={route.id}
>
&LTModalSafeAreaInsetsContext hasTopInset={!isFormSheet}>
&LTWrappedScreenItem
screenId={route.id}
stackPresentation={route.type}
screenOptions={route.screenOptions}
>
&LTResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
&LT/WrappedScreenItem>
&LT/ModalSafeAreaInsetsContext>
&LT/ModalScreenItemOptionsContext>
)
})
})

const ResolveView: FC<{
comp?: NavigationControllerView&LTany>
element?: React.ReactElement
props?: unknown
}> = ({ comp: Component, element, props }) => {
if (Component && typeof Component === 'function') {
return &LTComponent {...(props as any)} />
}
if (element) {
return element
}
throw new Error('No component or element provided')
}

const ModalSafeAreaInsetsContext: FC<{
children: React.ReactNode
hasTopInset?: boolean
}> = ({ children, hasTopInset = true }) => {
const rootInsets = useSafeAreaInsets()
const rootFrame = useSafeAreaFrame()

return (
&LTSafeAreaFrameContext value={rootFrame}>
&LTSafeAreaInsetsContext
value={useMemo(
() => ({
...rootInsets,
top: hasTopInset ? rootInsets.top : 0,
}),
[hasTopInset, rootInsets],
)}
>
{children}
&LT/SafeAreaInsetsContext>
&LT/SafeAreaFrameContext>
)
}

这里需要判断的逻辑可能会有点复杂,需要区分 Stack 和 Modal 的类型,在 ModalStack 中又需要区分 formSheet 等等。同时每个 Modal 中有需要再包裹一层 StackScreen 等等。

从简单来说,就是需要根据 Navigation 的数据,生成对应的 &LTScreenStackItem />,然后传入到 &LTScreenStack /> 中。

这里的详细的代码均可在下面的链接中查看:

https://github.com/RSSNext/Follow/blob/efc2e9713bcd54f82f9377de35ef5532008d6004/apps/mobile/src/lib/navigation/StackNavigation.tsx

然后我们还需要处理 native navigation 的状态同步,主要在 native 触发 pop 和 dismiss 的时机发送的事件。在前面的文章中讲过,可以通过 ScreenStackItemonDismissed 监听。

这里我们直接对 ScreenStackItem 再次封装。

export const WrappedScreenItem: FC<
{
screenId: string
children: React.ReactNode
stackPresentation?: StackPresentationTypes

screenOptions?: NavigationControllerViewExtraProps
style?: StyleProp&LTViewStyle>
} & ScreenOptionsContextType
> = memo(
({
screenId,
children,
stackPresentation,

screenOptions: screenOptionsProp,
style,
...rest
}) => {
const navigation = useNavigation()

const screenOptionsCtxValue = useMemo<
PrimitiveAtom&LTScreenOptionsContextType>
>(() => atom({}), [])

const screenOptionsFromCtx = useAtomValue(screenOptionsCtxValue)

// Priority: Ctx > Define on Component

const mergedScreenOptions = useMemo(
() => ({
...screenOptionsProp,
...resolveScreenOptions(screenOptionsFromCtx),
}),
[screenOptionsFromCtx, screenOptionsProp],
)

const handleDismiss = useCallback(
(
e: NativeSyntheticEvent<{
dismissCount: number
}>,
) => {
if (e.nativeEvent.dismissCount > 0) {
for (let i = 0; i < e.nativeEvent.dismissCount; i++) {
navigation.__internal_dismiss(screenId)
}
}
},
[navigation, screenId],
)

const ref = useRef&LTView>(null)

return (
&LTScreenItemContext value={ctxValue}>
&LTScreenOptionsContext value={screenOptionsCtxValue}>
&LTScreenStackItem
key={screenId}
screenId={screenId}
ref={ref}
stackPresentation={stackPresentation}
style={[StyleSheet.absoluteFill, style]}
{...rest}
{...mergedScreenOptions}
onDismissed={handleDismiss}
onNativeDismissCancelled={handleDismiss}
>
{children}
&LT/ScreenStackItem>
&LT/ScreenOptionsContext>
&LT/ScreenItemContext>
)
},
)

定义 NavigationControllerView
export const PlayerScreen: NavigationControllerView = () => {
return &LTSheetScreen onClose={() => navigation.dismiss()}>&LT/SheetScreen>
}

PlayerScreen.transparent = true

使用 Navigation

那么现在我们就可以在 React 中使用 Navigation 了。

const navigation = useNavigation()

navigation.pushControllerView(PlayerScreen)

那么,一个简单的 Navigation 就完成了。

当然如果你有兴趣的话,也可以查看 Folo 这部分的完整实现,包括如何和 Bottom Tab 结合和页面 ScrollView 的联动。

https://github.com/RSSNext/Follow/blob/6694a346a0bd9f2cea19c71e87484acc56ed3705/apps/mobile/src/lib/navigation


看完了?说点什么呢


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

Source

Report Page