上回说到,我们已经大概了解了 React Native Screen 内部是如何工作的。这篇文章将综合前面的内容,实现一个简单的 React Native Navigation。
构想
一个 Navigation 最基础的就是要实现一个 navigate 方法。
navigate 方法需要实现前进后退的基本行为。
模拟在 iOS Native 中,我们可以使用 pushController 和 presentController 两个方法去实现前进的行为。
那么我们可以命名为:
pushControllerView
推入 Stack NavigationpresentControllerView
推入 Modal
然后是后退的行为,命名为:
back
回退 Stack Navigationdismiss
关闭 Modal
然后我们需要集中式管理 push 和 present 的 navigation 的数据,然后在 UI 中呈现和反馈。
实现
Navigation 框架
顺着上面的构想,我们首先需要一个 Navigation 的类。
class Navigation {}
我们需要把 navigation 的数据保存在这个类中,所以我还需要定义数据的类型。
export interface Route {
id: string
Component?: NavigationControllerView<any>
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<P = {}> = FC<P> &
NavigationControllerViewExtraProps
上面我们定义 NavigationControllerView 的类型,和 Route 的类型。NavigationControllerView 用于定义 NavigationView 的组件类型,Route 用于在 Navigation 类中保存 navigation 的数据。
为了实现在 UI 中的响应式,我们使用 Jotai 去管理这个数据。
export type ChainNavigationContextType = {
routesAtom: PrimitiveAtom<Route[]>
}
在 Navigation 类中初始化数据:
export class Navigation {
private ctxValue: ChainNavigationContextType
constructor(ctxValue: ChainNavigationContextType) {
this.ctxValue = ctxValue
}
static readonly rootNavigation: Navigation = new Navigation({
routesAtom: atom<Route[]>([]),
})
}
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<T>(
view: NavigationControllerView<T>,
): Required<NavigationControllerViewExtraProps> {
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<T>(view: NavigationControllerView<T>, props?: T) {
const screenOptions = this.resolveScreenOptions(view)
this.__push({
id: screenOptions.id,
type: 'push',
Component: view,
props,
screenOptions,
})
}
presentControllerView<T>(
view: NavigationControllerView<T>,
props?: T,
type: Exclude<NavigationControllerViewType, '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 的 <ScreenStack />
中就能实现原生的 navigate 的效果。
那我们现在只需要透过 Navigation 类中管理的数据,通过一些转换就能实现了。
首先我们在 React 中定义一个 Navigation 上下文对象,确保得到正确的 Navigation 实例(如有多个)。
export const NavigationInstanceContext = createContext<Navigation>(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 (
<SafeAreaProvider>
<NavigationInstanceContext value={Navigation.rootNavigation}>
<ScreenStack style={StyleSheet.absoluteFill}>
<ScreenStackItem headerConfig={headerConfig} screenId="root">
{children}
</ScreenStackItem>
</ScreenStack>
</NavigationInstanceContext>
</SafeAreaProvider>
)
}
在 App 的入口文件中,我们使用 RootStackNavigation 组件包裹整个应用。
export default function App() {
return (
<RootStackNavigation>
<HomeScreen>
</RootStackNavigation>
)
}
const HomeScreen = () => {
return (
<View>
<Text>Home</Text>
</View>
)
}
RootStackNavigation 组件的 Children 为首屏,也是 Navigation 的根组件,不参与整体的 navigate 行为,即不能被 pop。
Navigation 数据在 UI 中呈现
接下来我们需要把这些数据转换到 React 元素传入到 React Native Screens 的 <ScreenStackItem />
中。
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 (
<GroupedNavigationRouteContext value={routeGroups}>
{routeGroups.map((group) => {
const isPushGroup = group.at(0)?.type === 'push'
if (!isPushGroup) {
return <ModalScreenStackItems key={group.at(0)?.id} routes={group} />
}
return <MapScreenStackItems key={group.at(0)?.id} routes={group} />
})}
</GroupedNavigationRouteContext>
)
}
const MapScreenStackItems: FC<{
routes: Route[]
}> = memo(({ routes }) => {
return routes.map((route) => {
return (
<ScreenStackItem
stackPresentation={'push'}
key={route.id}
screenId={route.id}
screenOptions={route.screenOptions}
>
<ResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
</ScreenStackItem>
)
})
})
const ModalScreenStackItems: FC<{
routes: Route[]
}> = memo(({ routes }) => {
const rootModalRoute = routes.at(0)
const modalScreenOptionsCtxValue = useMemo<
PrimitiveAtom<ScreenOptionsContextType>
>(() => 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 (
<ModalScreenItemOptionsContext value={modalScreenOptionsCtxValue}>
<WrappedScreenItem
stackPresentation={rootModalRoute?.type}
key={rootModalRoute.id}
screenId={rootModalRoute.id}
screenOptions={rootModalRoute.screenOptions}
{...modalScreenOptions}
>
<ModalSafeAreaInsetsContext hasTopInset={isFullScreen}>
<ScreenStack style={StyleSheet.absoluteFill}>
<WrappedScreenItem
screenId={rootModalRoute.id}
screenOptions={rootModalRoute.screenOptions}
>
<ResolveView
comp={rootModalRoute.Component}
element={rootModalRoute.element}
props={rootModalRoute.props}
/>
</WrappedScreenItem>
{routes.slice(1).map((route) => {
return (
<WrappedScreenItem
stackPresentation={'push'}
key={route.id}
screenId={route.id}
screenOptions={route.screenOptions}
>
<ResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
</WrappedScreenItem>
)
})}
</ScreenStack>
</ModalSafeAreaInsetsContext>
</WrappedScreenItem>
</ModalScreenItemOptionsContext>
)
}
return routes.map((route) => {
return (
<ModalScreenItemOptionsContext
value={modalScreenOptionsCtxValue}
key={route.id}
>
<ModalSafeAreaInsetsContext hasTopInset={!isFormSheet}>
<WrappedScreenItem
screenId={route.id}
stackPresentation={route.type}
screenOptions={route.screenOptions}
>
<ResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
</WrappedScreenItem>
</ModalSafeAreaInsetsContext>
</ModalScreenItemOptionsContext>
)
})
})
const ResolveView: FC<{
comp?: NavigationControllerView<any>
element?: React.ReactElement
props?: unknown
}> = ({ comp: Component, element, props }) => {
if (Component && typeof Component === 'function') {
return <Component {...(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 (
<SafeAreaFrameContext value={rootFrame}>
<SafeAreaInsetsContext
value={useMemo(
() => ({
...rootInsets,
top: hasTopInset ? rootInsets.top : 0,
}),
[hasTopInset, rootInsets],
)}
>
{children}
</SafeAreaInsetsContext>
</SafeAreaFrameContext>
)
}
这里需要判断的逻辑可能会有点复杂,需要区分 Stack 和 Modal 的类型,在 ModalStack 中又需要区分 formSheet 等等。同时每个 Modal 中有需要再包裹一层 StackScreen 等等。
从简单来说,就是需要根据 Navigation 的数据,生成对应的 <ScreenStackItem />
,然后传入到 <ScreenStack />
中。
这里的详细的代码均可在下面的链接中查看:
https://github.com/RSSNext/Follow/blob/efc2e9713bcd54f82f9377de35ef5532008d6004/apps/mobile/src/lib/navigation/StackNavigation.tsx
然后我们还需要处理 native navigation 的状态同步,主要在 native 触发 pop 和 dismiss 的时机发送的事件。在前面的文章中讲过,可以通过 ScreenStackItem
的 onDismissed
监听。
这里我们直接对 ScreenStackItem
再次封装。
export const WrappedScreenItem: FC<
{
screenId: string
children: React.ReactNode
stackPresentation?: StackPresentationTypes
screenOptions?: NavigationControllerViewExtraProps
style?: StyleProp<ViewStyle>
} & ScreenOptionsContextType
> = memo(
({
screenId,
children,
stackPresentation,
screenOptions: screenOptionsProp,
style,
...rest
}) => {
const navigation = useNavigation()
const screenOptionsCtxValue = useMemo<
PrimitiveAtom<ScreenOptionsContextType>
>(() => 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<View>(null)
return (
<ScreenItemContext value={ctxValue}>
<ScreenOptionsContext value={screenOptionsCtxValue}>
<ScreenStackItem
key={screenId}
screenId={screenId}
ref={ref}
stackPresentation={stackPresentation}
style={[StyleSheet.absoluteFill, style]}
{...rest}
{...mergedScreenOptions}
onDismissed={handleDismiss}
onNativeDismissCancelled={handleDismiss}
>
{children}
</ScreenStackItem>
</ScreenOptionsContext>
</ScreenItemContext>
)
},
)
定义 NavigationControllerView
export const PlayerScreen: NavigationControllerView = () => {
return <SheetScreen onClose={() => navigation.dismiss()}></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