Tabs 最简
app/
├── _layout.tsx ← Root Stack
└── (tabs)/
├── _layout.tsx ← Tabs
├── index.tsx ← / (首页 tab)
├── search.tsx ← /search
└── profile.tsx ← /profile
// app/(tabs)/_layout.tsx import { Tabs } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; export default function TabsLayout() { return ( <Tabs screenOptions={{ tabBarActiveTintColor: '#4F46E5', headerShown: false, }} > <Tabs.Screen name="index" options={{ title: '首页', tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />, }} /> <Tabs.Screen name="search" options={{ title: '搜索', tabBarIcon: ({ color, size }) => <Ionicons name="search" color={color} size={size} />, }} /> <Tabs.Screen name="profile" options={{ title: '我的', tabBarIcon: ProfileIcon }} /> </Tabs> ); }
分组的作用
(tabs) 作为目录名,URL 里不出现——用户访问 /、/search、/profile,感觉就是平级。分组只是给 Expo Router 知道「这些屏共享一个 Tabs 布局」。
为什么不用 tabs 而是 (tabs)
如果目录叫 tabs,URL 就得 /tabs/search 很丑。
如果目录叫 tabs,URL 就得 /tabs/search 很丑。
() 语法让目录变透明,既组织代码又保留整洁 URL。这是 Next.js 借过来的设计。
Tab 里嵌 Stack(大部分 App 都这么干)
典型场景:首页 tab 里面,点商品要推到详情页,但详情页仍在首页 tab 的上下文——详情页显示 header 返回首页,底部 tab 栏保持不变。
app/
├── _layout.tsx
└── (tabs)/
├── _layout.tsx ← Tabs
├── home/
│ ├── _layout.tsx ← Home Tab 的 Stack
│ ├── index.tsx ← /home
│ └── [id].tsx ← /home/:id
├── search.tsx
└── profile.tsx
// app/(tabs)/home/_layout.tsx import { Stack } from 'expo-router'; export default function HomeStack() { return <Stack />; } // app/(tabs)/_layout.tsx 中 Tab 指向目录 <Tabs.Screen name="home" options={{ title: '首页', ...}} />
徽标(Badge)
<Tabs.Screen name="inbox" options={{ title: '消息', tabBarBadge: unreadCount > 0 ? unreadCount : undefined, tabBarBadgeStyle: { backgroundColor: '#ef4444' }, }} />
自定义 TabBar
import { BottomTabBarProps } from '@react-navigation/bottom-tabs'; import { View, Pressable, Text } from 'react-native'; function MyTabBar({ state, descriptors, navigation }: BottomTabBarProps) { return ( <View style={{ flexDirection: 'row', height: 60, borderTopWidth: 1 }}> {state.routes.map((route, i) => { const focused = state.index === i; const { options } = descriptors[route.key]; return ( <Pressable key={route.key} onPress={() => navigation.navigate(route.name)} style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }} > <Text style={{ color: focused ? '#4F46E5' : '#999' }}> {options.title} </Text> </Pressable> ); })} </View> ); } <Tabs tabBar={(props) => <MyTabBar {...props} />} />
Drawer 抽屉
pnpm add @react-navigation/drawer react-native-gesture-handler react-native-reanimated
app/
├── _layout.tsx
└── (drawer)/
├── _layout.tsx
├── index.tsx
├── reports.tsx
└── settings.tsx
// app/(drawer)/_layout.tsx import { Drawer } from 'expo-router/drawer'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; export default function DrawerLayout() { return ( <GestureHandlerRootView style={{ flex: 1 }}> <Drawer> <Drawer.Screen name="index" options={{ drawerLabel: '首页', title: '首页' }} /> <Drawer.Screen name="reports" options={{ drawerLabel: '报告', title: '报告' }} /> <Drawer.Screen name="settings" options={{ drawerLabel: '设置', title: '设置' }} /> </Drawer> </GestureHandlerRootView> ); }
自定义 Drawer 内容
import { DrawerContentScrollView, DrawerItemList } from '@react-navigation/drawer'; <Drawer drawerContent={(props) => ( <DrawerContentScrollView {...props}> <View style={{ padding: 16 }}> <Text style={{ fontSize: 18, fontWeight: '600' }}>李雷</Text> <Text style={{ color: '#666' }}>lilei@example.com</Text> </View> <DrawerItemList {...props} /> </DrawerContentScrollView> )} />
Top Tabs(material-top-tabs)
import { withLayoutContext } from 'expo-router'; import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; const { Navigator } = createMaterialTopTabNavigator(); const MaterialTopTabs = withLayoutContext(Navigator); export default function Layout() { return ( <MaterialTopTabs> <MaterialTopTabs.Screen name="latest" options={{ title: '最新' }} /> <MaterialTopTabs.Screen name="popular" options={{ title: '热门' }} /> </MaterialTopTabs> ); }
Expo Router 的 withLayoutContext 是把任意 React Navigation Navigator 接入文件路由的通用桥,非常灵活。
条件 Tab(某 tab 要登录才可见)
<Tabs.Screen name="profile" options={{ title: '我的', href: isLoggedIn ? '/profile' : null, // null 即隐藏这个 tab }} />
嵌套示例:Tabs + 模态 + 深层 Stack
app/
├── _layout.tsx ← Root Stack(全局 modal)
├── login.tsx ← 全局登录 modal
└── (tabs)/
├── _layout.tsx ← Tabs
├── home/
│ ├── _layout.tsx ← Stack
│ ├── index.tsx
│ └── [id].tsx
├── search/
│ ├── _layout.tsx
│ ├── index.tsx
│ └── results.tsx
└── profile.tsx
用户在 Home Tab 里浏览商品,点购买跳转 /login(从 Root Stack 弹出 modal)——登录后 dismiss,回到商品页。文件结构一目了然。
本章小结
(tabs)分组让 URL 保持扁平,文件组织按 Tab 分目录- 每个 Tab 里再嵌
_layout.tsx的 Stack,实现"tab 持久 + 内部推屏" - Drawer 需要 gesture-handler 和 reanimated,
expo-router/drawer暴露同名 API - tabBar / drawerContent 支持完全自定义
href: null动态隐藏 tab(未登录、实验开关)