Chapter 04

Tabs 与 Drawer:主框架就位

底部 Tab 是 80% 的 App 主框架,抽屉 Drawer 多见于工具类。Expo Router 用「分组路由」让 Tabs/Drawer 与 Stack 自然嵌套。

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 很丑。() 语法让目录变透明,既组织代码又保留整洁 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,回到商品页。文件结构一目了然。

本章小结