This documentation is available as Markdown for AI agents and LLMs. See the full Markdown index or append .md to any documentation URL.

构建自定义 Web modal

编辑页面

了解如何使用 Expo Router 为 Web 构建 modal 覆盖层


Expo Router 不再提供实验性的 Web modal 实现。在 Web 上,带有 presentation: 'modal' 或 presentation: 'formSheet' 的屏幕会渲染为普通的 stack 路由。

使用透明覆盖层路由

对于大多数应用,在 Web 上使用内置的 Stack 并将 modal 路由设置为 transparentModal。Expo Router 会将之前的路由保留在透明 modal 后方,因此路由组件只需要渲染背景层和对话框。

src/app/_layout.tsx
import { Stack } from 'expo-router'; import { Platform } from 'react-native'; export const unstable_settings = { anchor: 'index' }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: Platform.select({ web: 'transparentModal', default: 'modal' }), headerShown: false, animation: Platform.select({ web: 'none', default: undefined }), }} /> </Stack> ); }

使用平台专属组件来处理视觉样式。Web 实现会处理背景层、Escape 键和关闭操作。原生实现会原样返回 children,因此 modal.tsx 可以在所有平台上将其内容包裹在 WebModal 中。

src/components/WebModal.web.tsx
import { router } from 'expo-router'; import { useEffect, type ReactNode } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; export function WebModal({ children }: { children: ReactNode }) { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && router.canGoBack()) router.back(); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, []); const dismiss = () => (router.canGoBack() ? router.back() : router.replace('/')); return ( <View style={styles.overlay}> <Pressable accessibilityLabel="Dismiss modal" onPress={dismiss} style={StyleSheet.absoluteFill} /> <View role="dialog" style={styles.dialog}> {children} </View> </View> ); } const styles = StyleSheet.create({ overlay: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.4)', padding: 24, }, dialog: { width: '100%', maxWidth: 640, borderRadius: 16, backgroundColor: 'white', padding: 24 }, });
src/components/WebModal.native.tsx
import type { ReactNode } from 'react'; export function WebModal({ children }: { children: ReactNode }) { return children; }

构建自定义 navigator

对于包含多个 Web modal 的应用,使用 StackRouter 创建一个自定义 navigator。该 navigator 会添加一个 modal 屏幕选项,渲染直到最后一个非 modal 路由为止的 stack,然后将每个 modal 路由叠加在其上方。设置覆盖层样式,使其与应用相匹配。

src/components/ModalStack.web.tsx
import { NativeStackView, type NativeStackDescriptorMap, type NativeStackNavigationOptions, type NativeStackViewState, StackRouter, createStandardRouterNavigator, type NavigatorContentProps, } from 'expo-router'; import type { ReactNode } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; type Options = NativeStackNavigationOptions & { modal?: boolean }; function YourModal({ children, onDismiss }: { children: ReactNode; onDismiss: () => void }) { return ( <View style={styles.overlay}> <Pressable onPress={onDismiss} style={StyleSheet.absoluteFill} /> <View style={styles.dialog}>{children}</View> </View> ); } function ModalStackContent({ state, descriptors, actions }: NavigatorContentProps<Options>) { // Filter preloaded routes. const activeRoutes = state.routes.slice(0, state.index + 1); const lastNonModalRouteIndex = activeRoutes.findLastIndex( route => !descriptors[route.key].options.modal ); const hasModals = lastNonModalRouteIndex < state.index; // Standard navigation descriptors use the same runtime shape as NativeStackView descriptors. const nativeStackDescriptors = descriptors as unknown as NativeStackDescriptorMap; if (!hasModals) { return ( <NativeStackView state={state as NativeStackViewState} descriptors={nativeStackDescriptors} /> ); } const baseStackRoutes = activeRoutes.slice(0, lastNonModalRouteIndex + 1); const baseStackPreloadedRoutes = state.routes.slice(state.index + 1); const baseStackState = { ...state, index: lastNonModalRouteIndex, routes: [...baseStackRoutes, ...baseStackPreloadedRoutes], } as NativeStackViewState; const modalRoutes = activeRoutes.slice(lastNonModalRouteIndex + 1); return ( <View style={{ flex: 1 }}> <NativeStackView state={baseStackState} descriptors={nativeStackDescriptors} /> {modalRoutes.map(route => ( <YourModal key={route.key} onDismiss={actions.back}> {descriptors[route.key].render()} </YourModal> ))} </View> ); } export const ModalStack = createStandardRouterNavigator(ModalStackContent, StackRouter); const styles = StyleSheet.create({ overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.4)', padding: 24, }, dialog: { width: '100%', maxWidth: 640, backgroundColor: 'white', padding: 24 }, });
src/app/_layout.web.tsx
import { ModalStack } from '../components/ModalStack.web'; export const unstable_settings = { anchor: 'index' }; export default function Layout() { return ( <ModalStack> <ModalStack.Screen name="index" /> <ModalStack.Screen name="modal" options={{ modal: true }} /> </ModalStack> ); }