
经过前面 29 篇文章,我们已经实现了 App 的所有功能页面。但这些页面是分散的,需要一个导航系统把它们串联起来。这就是主导航的作用------它是整个 App 的骨架,决定了用户如何在不同页面之间切换。
这篇文章是本系列的最后一篇,我们来实现主导航系统,包括路由配置 、底部 Tab 栏 、顶部导航栏 、以及安全区域处理。这些组件共同构成了 App 的导航框架。
导航系统的整体架构
在开始看代码之前,先了解一下导航系统的整体架构:
┌─────────────────────────────────────┐
│ SafeView │ ← 安全区域容器
│ ┌───────────────────────────────┐ │
│ │ Header │ │ ← 顶部导航栏
│ ├───────────────────────────────┤ │
│ │ │ │
│ │ PageComponent │ │ ← 当前页面内容
│ │ │ │
│ ├───────────────────────────────┤ │
│ │ BottomTabBar │ │ ← 底部 Tab 栏
│ └───────────────────────────────┘ │
│ │ ToastContainer │ │ ← 全局 Toast 提示
└─────────────────────────────────────┘
这种结构是移动 App 中最常见的导航模式:顶部标题栏 + 内容区域 + 底部 Tab 栏。几乎所有主流 App(微信、淘宝、抖音等)都采用这种布局。
为什么这种布局如此流行?
- 符合人体工学:底部 Tab 栏在拇指可触及范围内,单手操作方便
- 信息层级清晰:顶部显示当前位置,底部显示主要入口
- 用户习惯:用户已经形成了这种操作习惯,学习成本低
路由配置的设计
tsx
// 主Tab页面
const mainTabs = ['Home', 'ChampionList', 'ItemList', 'RuneList', 'Settings'];
// 路由配置
const routes: Record<string, {component: React.FC; title: string}> = {
Home: {component: HomePage, title: '首页'},
News: {component: NewsPage, title: '版本资讯'},
ChampionList: {component: ChampionListPage, title: '英雄图鉴'},
ChampionDetail: {component: ChampionDetailPage, title: '英雄详情'},
ChampionSkill: {component: ChampionSkillPage, title: '技能详情'},
ChampionSkin: {component: ChampionSkinPage, title: '皮肤列表'},
SkinDetail: {component: SkinDetailPage, title: '皮肤详情'},
ChampionStory: {component: ChampionStoryPage, title: '背景故事'},
ChampionTips: {component: ChampionTipsPage, title: '使用技巧'},
ChampionFilter: {component: ChampionFilterPage, title: '英雄筛选'},
ChampionCompare: {component: ChampionComparePage, title: '英雄对比'},
ChampionSearch: {component: ChampionSearchPage, title: '英雄搜索'},
ItemList: {component: ItemListPage, title: '装备大全'},
ItemDetail: {component: ItemDetailPage, title: '装备详情'},
ItemBuild: {component: ItemBuildPage, title: '合成树'},
ItemFilter: {component: ItemFilterPage, title: '装备筛选'},
ItemSearch: {component: ItemSearchPage, title: '装备搜索'},
ItemCompare: {component: ItemComparePage, title: '装备对比'},
RuneList: {component: RuneListPage, title: '符文系统'},
RuneDetail: {component: RuneDetailPage, title: '符文详情'},
RuneBuilder: {component: RuneBuilderPage, title: '符文配置'},
RunePreset: {component: RunePresetPage, title: '符文预设'},
SpellList: {component: SpellListPage, title: '召唤师技能'},
SpellDetail: {component: SpellDetailPage, title: '技能详情'},
Tools: {component: ToolsPage, title: '实用工具'},
DamageCalc: {component: DamageCalcPage, title: '伤害计算'},
CounterPick: {component: CounterPickPage, title: '克制关系'},
Settings: {component: SettingsPage, title: '设置'},
About: {component: AboutPage, title: '关于'},
};
路由配置的数据结构:
每个路由是一个键值对,键是路由名称(如 'Home'),值是一个对象:
| 属性 | 类型 | 用途 |
|---|---|---|
| component | React.FC | 要渲染的页面组件 |
| title | string | 显示在顶部导航栏的标题 |
mainTabs 数组的作用:
mainTabs 定义了哪些页面是主 Tab 页面。这个数组有两个重要用途:
- 控制返回按钮:主 Tab 页面不显示返回按钮,因为它们是顶级页面
- 底部 Tab 高亮:当用户在某个主 Tab 页面时,对应的 Tab 会高亮
路由命名的规范:
观察路由名称,可以发现一些命名规律:
- 列表页 :
XxxList(如 ChampionList、ItemList) - 详情页 :
XxxDetail(如 ChampionDetail、ItemDetail) - 功能页:动词或名词(如 ChampionFilter、ChampionCompare)
这种命名规范让代码自文档化,看到路由名称就知道是什么页面。
MainPage 组件的实现
tsx
import React, {useMemo} from 'react';
import {View, StyleSheet} from 'react-native';
import {useTheme} from '../context/ThemeContext';
import {useNavigation} from '../context/NavigationContext';
import {Header, SafeView, BottomTabBar} from '../components/layout';
import {ToastContainer} from '../components/common/Toast';
export function MainPage() {
const {colors} = useTheme();
const {currentRoute} = useNavigation();
const route = routes[currentRoute] || routes.Home;
const PageComponent = route.component;
const showBack = !mainTabs.includes(currentRoute);
const styles = useMemo(() => StyleSheet.create({
content: {flex: 1, backgroundColor: colors.background},
}), [colors]);
return (
<SafeView>
<Header title={route.title} showBack={showBack} />
<View style={styles.content}>
<PageComponent />
</View>
<BottomTabBar />
<ToastContainer />
</SafeView>
);
}
核心逻辑逐行分析:
tsx
const route = routes[currentRoute] || routes.Home;
根据当前路由名称从配置中获取对应的路由信息。|| routes.Home 是降级处理:如果找不到对应路由(比如路由名称拼写错误),就显示首页,避免白屏崩溃。
tsx
const PageComponent = route.component;
把组件赋值给一个大写开头的变量。在 React 中,组件名必须大写开头,这样 JSX 才能正确识别它是组件而不是 HTML 标签。
tsx
const showBack = !mainTabs.includes(currentRoute);
判断是否显示返回按钮。逻辑是:如果当前路由不在 mainTabs 数组中,就显示返回按钮。换句话说,只有子页面才显示返回按钮。
组件渲染结构:
tsx
<SafeView>
<Header title={route.title} showBack={showBack} />
<View style={styles.content}>
<PageComponent />
</View>
<BottomTabBar />
<ToastContainer />
</SafeView>
这个结构从外到内:
- SafeView:最外层,处理安全区域和状态栏
- Header:顶部导航栏,固定在顶部
- content View :内容区域,
flex: 1占据剩余空间 - PageComponent:当前页面的实际内容
- BottomTabBar:底部 Tab 栏,固定在底部
- ToastContainer:Toast 提示容器,浮动在最上层
底部 Tab 栏的实现
tsx
const tabs = [
{key: 'Home', label: '首页', icon: '🏠'},
{key: 'ChampionList', label: '英雄', icon: '⚔️'},
{key: 'ItemList', label: '装备', icon: '🛡️'},
{key: 'RuneList', label: '符文', icon: '✨'},
{key: 'Settings', label: '设置', icon: '⚙️'},
];
Tab 配置的设计:
| 属性 | 类型 | 说明 |
|---|---|---|
| key | string | 路由名称,点击时用于导航 |
| label | string | 显示的文字标签 |
| icon | string | Emoji 图标 |
为什么选择 5 个 Tab?
5 个是移动端 Tab 栏的黄金数量:
- 太少(2-3 个):浪费屏幕空间,功能入口不够
- 太多(6 个以上):每个 Tab 太窄,图标和文字挤在一起,难以点击
- 5 个:每个 Tab 宽度适中,图标清晰,文字可读
图标的选择:
每个图标都和功能语义相关:
- 🏠 首页:房子代表"家",是 App 的起点
- ⚔️ 英雄:剑代表战斗,英雄是战斗的主体
- 🛡️ 装备:盾牌是典型的装备
- ✨ 符文:闪光代表魔法效果
- ⚙️ 设置:齿轮是设置的通用图标
智能高亮的实现
tsx
const getActiveTab = (route: string): string => {
if (route.startsWith('Champion') || route === 'SkinDetail') return 'ChampionList';
if (route.startsWith('Item')) return 'ItemList';
if (route.startsWith('Rune')) return 'RuneList';
if (route.startsWith('Spell') || route.startsWith('Tool') || route === 'DamageCalc' || route === 'CounterPick') return 'Home';
if (route === 'Settings' || route === 'About') return 'Settings';
return 'Home';
};
这个函数实现了智能高亮功能:即使用户在子页面,底部 Tab 栏也能正确高亮对应的主 Tab。
匹配规则详解:
| 当前路由 | 高亮的 Tab | 匹配规则 |
|---|---|---|
| ChampionDetail | 英雄 | 以 'Champion' 开头 |
| ChampionSkill | 英雄 | 以 'Champion' 开头 |
| SkinDetail | 英雄 | 特殊处理(皮肤属于英雄) |
| ItemBuild | 装备 | 以 'Item' 开头 |
| RuneBuilder | 符文 | 以 'Rune' 开头 |
| SpellList | 首页 | 召唤师技能从首页进入 |
| DamageCalc | 首页 | 工具从首页进入 |
| About | 设置 | 关于从设置进入 |
为什么需要智能高亮?
如果没有智能高亮,用户进入子页面后,底部 Tab 栏会失去高亮 ,用户不知道自己在哪个模块。智能高亮让用户始终清楚自己的位置,这是良好导航体验的关键。
Tab 栏的渲染逻辑
tsx
export function BottomTabBar() {
const {colors} = useTheme();
const {currentRoute, navigate} = useNavigation();
const activeTab = getActiveTab(currentRoute);
const styles = useMemo(() => StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: colors.backgroundLight,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingBottom: 20,
paddingTop: 8,
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 4,
position: 'relative'
},
icon: {fontSize: 22, marginBottom: 2},
iconActive: {transform: [{scale: 1.1}]},
label: {fontSize: 11, color: colors.textMuted},
labelActive: {color: colors.textGold, fontWeight: '600'},
indicator: {
position: 'absolute',
top: 0,
width: 20,
height: 3,
backgroundColor: colors.primary,
borderRadius: 2
},
}), [colors]);
return (
<View style={styles.container}>
{tabs.map(tab => {
const isActive = activeTab === tab.key;
return (
<TouchableOpacity
key={tab.key}
style={styles.tab}
onPress={() => navigate(tab.key)}
activeOpacity={0.7}>
<Text style={[styles.icon, isActive && styles.iconActive]}>{tab.icon}</Text>
<Text style={[styles.label, isActive && styles.labelActive]}>{tab.label}</Text>
{isActive && <View style={styles.indicator} />}
</TouchableOpacity>
);
})}
</View>
);
}
选中状态的三重视觉反馈:
选中的 Tab 有三个视觉变化,让用户一眼就能看出哪个是当前选中的:
- 图标放大 :
transform: [{scale: 1.1}],放大 10%,微妙但可感知 - 文字变色加粗:从灰色变成金色,并加粗(fontWeight: '600')
- 顶部指示条:显示一个 20px 宽、3px 高的金色小横条
paddingBottom: 20 的作用:
这个较大的底部内边距是为了适配全面屏手机。在 iPhone X 及以后的机型上,底部有一个手势操作区域(Home Indicator)。如果 Tab 栏太贴近底部,用户点击时可能会误触发系统的返回主屏手势。
flex: 1 的均分效果:
每个 Tab 都设置了 flex: 1,这意味着它们会平均分配容器的宽度。5 个 Tab 各占 20%,无论屏幕多宽都能保持均匀分布。
顶部导航栏的实现
tsx
interface HeaderProps {
title: string;
showBack?: boolean;
onMenuPress?: () => void;
rightElement?: React.ReactNode;
}
export function Header({title, showBack = false, onMenuPress, rightElement}: HeaderProps) {
const {colors} = useTheme();
const {goBack, canGoBack} = useNavigation();
const styles = useMemo(() => StyleSheet.create({
container: {
height: 56,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
backgroundColor: colors.backgroundLight,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
left: {width: 48, alignItems: 'flex-start'},
right: {width: 48, alignItems: 'flex-end'},
title: {
flex: 1,
fontSize: 18,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center'
},
button: {width: 40, height: 40, alignItems: 'center', justifyContent: 'center'},
backIcon: {fontSize: 24, color: colors.textPrimary},
menuIcon: {fontSize: 22, color: colors.textPrimary},
placeholder: {width: 40},
}), [colors]);
Props 的灵活设计:
| Prop | 类型 | 默认值 | 用途 |
|---|---|---|---|
| title | string | 必填 | 标题文字 |
| showBack | boolean | false | 是否显示返回按钮 |
| onMenuPress | function | undefined | 菜单按钮点击回调 |
| rightElement | ReactNode | undefined | 右侧自定义元素 |
这种设计让 Header 组件高度可复用:
- 主 Tab 页面:只传 title
- 子页面:传 title + showBack
- 需要右侧按钮的页面:传 title + rightElement
- 需要菜单的页面:传 title + onMenuPress
经典的三栏布局:
tsx
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}
left: {width: 48}
title: {flex: 1, textAlign: 'center'}
right: {width: 48}
这是移动端导航栏的经典布局:
- 左侧固定 48px,放返回按钮或菜单按钮
- 中间 flex: 1,放标题,自动占据剩余空间
- 右侧固定 48px,放操作按钮或留空
为什么左右两侧要固定宽度?
如果左右宽度不固定,标题就无法真正居中。比如左侧有返回按钮(40px),右侧没有按钮,标题会偏向右边。固定左右宽度后,标题始终在正中间。
左侧按钮的条件渲染
tsx
const handleBack = () => {
if (canGoBack()) goBack();
};
return (
<View style={styles.container}>
<View style={styles.left}>
{showBack && canGoBack() ? (
<TouchableOpacity onPress={handleBack} style={styles.button}>
<Text style={styles.backIcon}>←</Text>
</TouchableOpacity>
) : onMenuPress ? (
<TouchableOpacity onPress={onMenuPress} style={styles.button}>
<Text style={styles.menuIcon}>☰</Text>
</TouchableOpacity>
) : (
<View style={styles.placeholder} />
)}
</View>
<Text style={styles.title} numberOfLines={1}>{title}</Text>
<View style={styles.right}>
{rightElement || <View style={styles.placeholder} />}
</View>
</View>
);
}
条件渲染的优先级:
左侧区域的渲染遵循优先级规则:
- 最高优先级 :如果
showBack为 true 且可以返回 → 显示返回按钮 ← - 次优先级 :如果有
onMenuPress回调 → 显示菜单按钮 ☰ - 默认 :都不满足 → 显示占位符(空的 View)
canGoBack() 的双重检查:
tsx
{showBack && canGoBack() ? (...) : (...)}
即使 showBack 为 true,也要检查 canGoBack()。这是因为用户可能通过深链接直接进入子页面,此时导航栈只有一个页面,无法返回。双重检查可以避免点击返回按钮无反应的尴尬情况。
占位符的必要性:
tsx
<View style={styles.placeholder} />
占位符是一个空的 View ,宽度和按钮相同(40px)。它的作用是保持布局平衡:即使左侧没有按钮,也要占据相同的空间,这样标题才能真正居中。
安全区域的处理
tsx
import React from 'react';
import {View, StyleSheet, StatusBar, Platform} from 'react-native';
import {useTheme} from '../../context/ThemeContext';
interface SafeViewProps {
children: React.ReactNode;
backgroundColor?: string;
}
export function SafeView({children, backgroundColor}: SafeViewProps) {
const {colors, isDark} = useTheme();
const bgColor = backgroundColor || colors.background;
return (
<View style={[styles.container, {backgroundColor: bgColor}]}>
<StatusBar
barStyle={isDark ? 'light-content' : 'dark-content'}
backgroundColor={colors.backgroundLight}
/>
{Platform.OS === 'ios' && (
<View style={[styles.statusBar, {backgroundColor: bgColor}]} />
)}
{children}
</View>
);
}
const styles = StyleSheet.create({
container: {flex: 1},
statusBar: {height: 44},
});
SafeView 解决的问题:
现代手机有各种"异形屏":刘海屏、挖孔屏、曲面屏等。如果不处理安全区域,内容可能会被刘海遮挡,或者延伸到圆角区域被裁切。
StatusBar 的配置:
tsx
<StatusBar
barStyle={isDark ? 'light-content' : 'dark-content'}
backgroundColor={colors.backgroundLight}
/>
barStyle:状态栏文字颜色。深色主题用白色文字('light-content'),浅色主题用黑色文字('dark-content')backgroundColor:状态栏背景色(仅 Android 有效)
iOS 刘海屏适配:
tsx
{Platform.OS === 'ios' && (
<View style={[styles.statusBar, {backgroundColor: bgColor}]} />
)}
在 iOS 上添加一个 44px 高的占位区域。这个高度覆盖了 iPhone X 及以后机型的刘海区域。
为什么只在 iOS 上添加?
Android 的状态栏处理方式不同,通过 StatusBar 组件的 backgroundColor 属性就能控制。而 iOS 的状态栏是透明的,需要手动添加占位区域。
导航流程的完整分析
让我们追踪一下用户点击 Tab 后的完整数据流:
1. 用户点击 "英雄" Tab
↓
2. BottomTabBar 的 onPress 触发
↓
3. 调用 navigate('ChampionList')
↓
4. NavigationContext 更新状态:
setHistory(prev => [...prev, {name: 'ChampionList', params: {}}])
↓
5. currentRoute 变为 'ChampionList'
↓
6. MainPage 组件重新渲染
↓
7. route = routes['ChampionList']
= {component: ChampionListPage, title: '英雄图鉴'}
↓
8. showBack = !mainTabs.includes('ChampionList') = false
↓
9. Header 渲染:title="英雄图鉴",无返回按钮
↓
10. PageComponent = ChampionListPage,渲染英雄列表
↓
11. BottomTabBar 重新渲染:
activeTab = getActiveTab('ChampionList') = 'ChampionList'
"英雄" Tab 高亮
整个流程是声明式 的:我们只需要更新状态(currentRoute),UI 会自动响应更新。这就是 React 的核心理念:UI = f(State)。
扩展:添加页面切换动画
当前实现没有页面切换动画,页面是瞬间切换的。可以添加简单的淡入淡出效果:
tsx
import {Animated} from 'react-native';
export function MainPage() {
const fadeAnim = useRef(new Animated.Value(1)).current;
const prevRoute = useRef(currentRoute);
useEffect(() => {
if (prevRoute.current !== currentRoute) {
// 先淡出
Animated.timing(fadeAnim, {
toValue: 0,
duration: 100,
useNativeDriver: true,
}).start(() => {
prevRoute.current = currentRoute;
// 再淡入
Animated.timing(fadeAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start();
});
}
}, [currentRoute]);
return (
<SafeView>
<Header ... />
<Animated.View style={[styles.content, {opacity: fadeAnim}]}>
<PageComponent />
</Animated.View>
<BottomTabBar />
</SafeView>
);
}
扩展:添加手势返回
iOS 用户习惯从屏幕左边缘滑动返回。可以用 PanResponder 实现:
tsx
import {PanResponder} from 'react-native';
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (evt, gestureState) => {
// 只在左边缘开始、向右滑动时响应
return evt.nativeEvent.pageX < 30 && gestureState.dx > 10;
},
onPanResponderRelease: (evt, gestureState) => {
// 滑动距离超过 100 时触发返回
if (gestureState.dx > 100 && canGoBack()) {
goBack();
}
},
})
).current;
return (
<View {...panResponder.panHandlers}>
{/* 页面内容 */}
</View>
);
系列总结
到这里,我们完成了整个 LOL 助手 App 的开发!让我们回顾一下这 30 篇文章涵盖的内容:
基础页面(1-9):首页、版本资讯、英雄图鉴、英雄详情、技能详情、皮肤列表、皮肤详情、背景故事、使用技巧
英雄功能(10-12):英雄筛选、英雄对比、英雄搜索
装备系统(13-18):装备大全、装备详情、合成树、装备筛选、装备搜索、装备对比
符文系统(19-22):符文系统、符文详情、符文配置、符文预设
召唤师技能(23-24):召唤师技能列表、召唤师技能详情
实用工具(25-27):实用工具、伤害计算、克制关系
设置与关于(28-29):设置、关于
导航系统(30):主导航
通过这个项目,我们学习了 React Native 开发的核心技能:
- 组件设计:如何拆分和组织组件
- 状态管理:Context API 的使用
- 导航系统:自定义导航的实现
- 主题切换:深色/浅色主题的支持
- API 调用:网络请求和数据处理
- 列表渲染:FlatList 和 ScrollView 的使用
- 表单处理:受控组件和输入验证
- 样式设计:Flexbox 布局和动态样式
希望这个系列对你的 React Native 学习之旅有所帮助!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net