拇指够得着的地方
手机越来越大,单手操作越来越难。把导航放在底部,是因为那里是拇指最容易够到的地方。
想想你平时怎么握手机的。大拇指自然地落在屏幕下半部分,点击底部的按钮几乎不用移动手指。如果导航在顶部,你得把手指伸上去,或者换一只手。这种小小的不便,累积起来会影响用户体验。
我们的 TodoList 应用有三个页面:任务列表、统计、设置。用底部 Tab 栏来切换,用户可以单手轻松操作。
Tab 栏的结构
先看代码:
tsx
<View style={[styles.tabBar, {backgroundColor: theme.card, borderColor: theme.border}]}>
{['📋 任务', '📊 统计', '⚙️ 设置'].map((tab, index) => (
<TouchableOpacity key={index} style={[styles.tabItem, activeTab === index && styles.tabItemActive]} onPress={() => setActiveTab(index)}>
<Text style={[styles.tabText, {color: activeTab === index ? theme.accent : theme.subText}]}>{tab}</Text>
</TouchableOpacity>
))}
</View>
一个容器 tabBar,里面三个按钮,每个按钮对应一个页面。点击按钮切换页面,就这么简单。
用数组生成按钮
['📋 任务', '📊 统计', '⚙️ 设置'].map(...) 用数组遍历生成三个按钮。这比手写三个 TouchableOpacity 更简洁。如果以后要加第四个 Tab,只需要在数组里加一项。
emoji 图标
每个 Tab 前面有一个 emoji 图标。📋 代表任务列表,📊 代表统计图表,⚙️ 代表设置齿轮。emoji 的好处是不用引入图标库,直接写在字符串里就行。
当然,emoji 在不同系统上显示可能略有差异。如果追求一致性,可以用图标库比如 react-native-vector-icons。我们这里用 emoji 是为了简单。
Tab 状态的管理
tsx
const [activeTab, setActiveTab] = useState(0);
activeTab 存储当前选中的 Tab 索引。0 是任务页,1 是统计页,2 是设置页。
为什么用索引而不是字符串?因为索引和数组下标对应,处理起来更方便。activeTab === index 就能判断当前 Tab 是否选中。
默认选中
默认值是 0,也就是任务页。用户打开应用,第一眼看到的是任务列表。这是最重要的页面,应该作为默认页。
如果默认是统计页或设置页,用户可能会困惑"我的任务在哪"。
页面切换的实现
点击 Tab 后,怎么显示对应的页面?
tsx
const renderCurrentPage = () => {
switch (activeTab) {
case 0: return <TasksPage />;
case 1: return <StatsPage />;
case 2: return <SettingsPage />;
default: return <TasksPage />;
}
};
renderCurrentPage 函数根据 activeTab 的值返回对应的页面组件。在 JSX 里调用这个函数:
tsx
{renderCurrentPage()}
这种方式叫条件渲染。只有当前选中的页面会被渲染,其他页面不会出现在 DOM 里。
为什么不用导航库
React Navigation 是 React Native 最流行的导航库,它提供了现成的 Tab 导航组件。为什么我们不用?
因为这是一个演示项目,想展示如何从零实现 Tab 切换。理解原理比使用库更重要。而且我们的需求很简单,三个页面切换,手写几十行代码就能搞定,没必要引入一个大库。
真实项目里,如果导航逻辑复杂(比如有嵌套导航、深度链接、页面参数传递),用 React Navigation 会省很多事。
Tab 栏的样式
tsx
tabBar: {position: 'absolute', bottom: 0, left: 0, right: 0, flexDirection: 'row', borderTopWidth: 1, paddingVertical: 8, paddingBottom: 20},
tabItem: {flex: 1, alignItems: 'center', paddingVertical: 8},
tabItemActive: {borderTopWidth: 2, borderTopColor: '#6c5ce7', marginTop: -1},
tabText: {fontSize: 12},
固定在底部
position: 'absolute' 让 Tab 栏脱离正常的文档流,可以自由定位。
bottom: 0, left: 0, right: 0 把 Tab 栏固定在屏幕底部,左右撑满。
这样不管页面内容有多长,Tab 栏始终在底部,不会被滚动走。
底部安全区域
paddingBottom: 20 是为了适配有底部横条的设备,比如 iPhone X 及以后的机型。
这些设备底部有一个横条用于手势操作,如果 Tab 栏贴着屏幕底部,会和横条重叠。加 20 像素的下内边距,让 Tab 栏往上移一点。
更精确的做法是用 SafeAreaView 或者 useSafeAreaInsets 获取实际的安全区域高度。我们这里用固定值简化处理。
三等分布局
tabItem 的 flex: 1 让三个 Tab 平分可用宽度。不管屏幕多宽,每个 Tab 都占三分之一。
alignItems: 'center' 让 Tab 内容水平居中。
选中状态的指示
tsx
tabItemActive: {borderTopWidth: 2, borderTopColor: '#6c5ce7', marginTop: -1},
选中的 Tab 顶部有一条紫色的线。这是一种常见的设计,用线条指示当前位置。
marginTop: -1 是为了让这条线和 Tab 栏的顶部边框重叠,看起来像是边框变色了,而不是多了一条线。
选中状态的样式切换
tsx
style={[styles.tabItem, activeTab === index && styles.tabItemActive]}
这是条件样式的写法。styles.tabItem 是基础样式,所有 Tab 都有。activeTab === index && styles.tabItemActive 只有选中的 Tab 才有。
&& 是短路运算符。如果 activeTab === index 为 false,整个表达式返回 false,不应用额外样式。如果为 true,返回 styles.tabItemActive,应用选中样式。
文字颜色的切换
tsx
{color: activeTab === index ? theme.accent : theme.subText}
选中的 Tab 文字用主题强调色(紫色),未选中的用次要文字颜色(灰色)。
颜色对比让用户一眼就能看出当前在哪个页面。
Tab 栏的背景和边框
tsx
<View style={[styles.tabBar, {backgroundColor: theme.card, borderColor: theme.border}]}>
背景色用 theme.card,和卡片背景一致。边框色用 theme.border。
borderTopWidth: 1 在 Tab 栏顶部加一条细线,把 Tab 栏和上面的内容分开。这条线很细,不会很突兀,但能起到分隔作用。
Tab 栏和内容的关系
Tab 栏是绝对定位的,会覆盖在内容上面。如果内容太长,底部会被 Tab 栏遮挡。
怎么解决?在内容区域底部加一些空白:
tsx
listContent: {paddingBottom: 160},
FlatList 的 contentContainerStyle 设置了 paddingBottom: 160,让列表底部有足够的空白,最后一个任务不会被 Tab 栏遮挡。
160 像素是怎么来的?Tab 栏高度大约 60 像素,加上一些余量。这个值可以根据实际情况调整。
点击反馈
TouchableOpacity 在按下时会降低透明度,给用户视觉反馈。
tsx
<TouchableOpacity key={index} style={...} onPress={() => setActiveTab(index)}>
用户点击 Tab,按钮会短暂变淡,然后恢复。这个反馈告诉用户"你点到了"。
如果想要更明显的反馈,可以用 TouchableHighlight,按下时会显示一个高亮背景。或者用 Pressable,可以自定义按下时的样式。
我们用 TouchableOpacity 是因为它最简单,效果也够用。
Tab 切换的动画
当前的实现是瞬间切换,没有动画。如果想要滑动切换的效果,可以用 Animated 或者第三方库。
一个简单的淡入淡出效果:
tsx
const [fadeAnim] = useState(new Animated.Value(1));
const switchTab = (index: number) => {
Animated.timing(fadeAnim, {toValue: 0, duration: 100, useNativeDriver: true}).start(() => {
setActiveTab(index);
Animated.timing(fadeAnim, {toValue: 1, duration: 100, useNativeDriver: true}).start();
});
};
先淡出当前页面,切换后再淡入新页面。这样切换不会太突兀。
不过要注意,动画会增加切换的延迟。如果用户快速切换多个 Tab,动画可能会堆积。简单的应用不加动画也挺好。
Tab 栏的可访问性
tsx
<TouchableOpacity
key={index}
style={[styles.tabItem, activeTab === index && styles.tabItemActive]}
onPress={() => setActiveTab(index)}
accessibilityRole="tab"
accessibilityState={{selected: activeTab === index}}
accessibilityLabel={tab.replace(/[^\u4e00-\u9fa5]/g, '')}
>
accessibilityRole="tab" 告诉屏幕阅读器这是一个标签页按钮。
accessibilityState={``{selected: ...}} 告诉屏幕阅读器当前 Tab 是否被选中。
accessibilityLabel 是屏幕阅读器朗读的文字。我们用正则去掉 emoji,只保留中文,这样朗读的是"任务"而不是"📋 任务"。
三个页面的组件
Tab 栏切换的是三个页面组件:
TasksPage 任务页
tsx
const TasksPage = () => (
<>
{/* 统计区域 */}
<View style={[styles.statsContainer, ...]}>...</View>
{/* 搜索框 */}
<View style={[styles.searchContainer, ...]}>...</View>
{/* 筛选按钮 */}
...
{/* 任务列表 */}
<FlatList ... />
</>
);
任务页是最复杂的,包含统计、搜索、筛选、列表等多个部分。
StatsPage 统计页
tsx
const StatsPage = () => (
<ScrollView style={styles.pageContainer} showsVerticalScrollIndicator={false}>
<Text style={[styles.pageTitle, {color: theme.text}]}>📊 任务统计</Text>
{/* 各种统计卡片 */}
...
</ScrollView>
);
统计页用 ScrollView 包裹,因为内容可能超出一屏。
SettingsPage 设置页
tsx
const SettingsPage = () => (
<ScrollView style={styles.pageContainer} showsVerticalScrollIndicator={false}>
<Text style={[styles.pageTitle, {color: theme.text}]}>⚙️ 设置</Text>
{/* 各种设置项 */}
...
</ScrollView>
);
设置页也用 ScrollView,结构和统计页类似。
页面状态的保持
当前的实现,切换 Tab 后再切回来,页面状态会保持。比如在任务页滚动到中间,切到统计页再切回来,还是在中间位置。
这是因为我们用的是条件渲染,组件没有被卸载。如果用 {activeTab === 0 && <TasksPage />} 这种写法,切换时组件会被卸载,状态会丢失。
tsx
// 当前写法,组件不会卸载
const renderCurrentPage = () => {
switch (activeTab) {
case 0: return <TasksPage />;
...
}
};
// 另一种写法,组件会卸载
{activeTab === 0 && <TasksPage />}
{activeTab === 1 && <StatsPage />}
{activeTab === 2 && <SettingsPage />}
两种写法各有优缺点。保持状态对用户友好,但会占用更多内存。卸载组件节省内存,但用户体验差一些。
我们选择保持状态,因为三个页面都不复杂,内存占用可以忽略。
Tab 栏的位置
Tab 栏在 Animated.View 里面,和内容区域一起:
tsx
<Animated.View style={[styles.content, {opacity: fadeAnim, transform: [{translateY: slideAnim}]}]}>
{/* 顶部导航栏 */}
<View style={styles.header}>...</View>
{/* 当前页面内容 */}
{renderCurrentPage()}
{/* 浮动添加按钮 */}
{activeTab === 0 && <TouchableOpacity style={[styles.fab, ...]} ... />}
{/* 底部 Tab 栏 */}
<View style={[styles.tabBar, ...]}>...</View>
</Animated.View>
Tab 栏在最后,但因为是绝对定位,实际显示在底部。
注意浮动添加按钮只在任务页显示 {activeTab === 0 && ...}。在统计页和设置页,不需要添加任务的按钮。
小结
底部 Tab 栏是移动应用最常见的导航方式。它把主要功能入口放在拇指最容易够到的地方,方便单手操作。
我们的实现包括:三个 Tab 用数组遍历生成,activeTab 状态控制当前选中,renderCurrentPage 函数渲染对应页面,绝对定位固定在底部,选中状态用顶部边框和文字颜色区分。
Tab 栏看起来简单,但细节不少。底部安全区域、内容底部留白、点击反馈、状态保持,这些都要考虑到。
好的 Tab 栏应该是"一目了然、一点即达"。用户看一眼就知道有哪些页面,点一下就能切换过去。不需要思考,不需要等待,这就是好的导航体验。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
