RN for OpenHarmony 实战 TodoList 项目:底部 Tab 栏

案例开源地址:https://atomgit.com/lqjmac/rn_openharmony_todolist

拇指够得着的地方

手机越来越大,单手操作越来越难。把导航放在底部,是因为那里是拇指最容易够到的地方。

想想你平时怎么握手机的。大拇指自然地落在屏幕下半部分,点击底部的按钮几乎不用移动手指。如果导航在顶部,你得把手指伸上去,或者换一只手。这种小小的不便,累积起来会影响用户体验。

我们的 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 NavigationReact 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 获取实际的安全区域高度。我们这里用固定值简化处理。

三等分布局

tabItemflex: 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 === indexfalse,整个表达式返回 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},

FlatListcontentContainerStyle 设置了 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

相关推荐
Van_Moonlight18 小时前
RN for OpenHarmony 实战 TodoList 项目:浮动添加按钮 FAB
javascript·开源·harmonyos
frontend_frank18 小时前
脱离 Electron autoUpdater:uni-app跨端更新:Windows+Android统一实现方案
android·前端·javascript·electron·uni-app
hqzing18 小时前
低成本玩转鸿蒙容器的丐版方案
docker·harmonyos
wulijuan88866618 小时前
BroadcastChannel API 同源的多个标签页可以使用 BroadcastChannel 进行通讯
前端·javascript·vue.js
kilito_0118 小时前
数字时钟翻页效果
javascript·css·css3
Van_Moonlight18 小时前
RN for OpenHarmony 实战 TodoList 项目:今日任务数量统计
javascript·开源·harmonyos
xkxnq19 小时前
第一阶段:Vue 基础入门(第 13天)
前端·javascript·vue.js
赵民勇19 小时前
ES5中prototype和prototype.constructor详解
javascript
Van_captain19 小时前
rn_for_openharmony常用组件_Tabs选项卡
javascript·开源·harmonyos