OpenHarmony Flutter 实战:如何实现底部导航栏
适用平台:OpenHarmony、Android、iOS
难度等级:⭐⭐⭐
核心技术:IndexedStack、AutomaticKeepAliveClientMixin、Provider状态管理
🎯 向专业应用迈进:加入底部导航栏
还记得我们在[上一篇教程]中实现的 CSDN 文章列表吗?现在我们要在它的基础上,加入一个让应用体验飞跃的关键功能——底部导航栏!
🌟 底部导航栏的魅力
底部导航栏是现代移动应用的标准配置,就像这样:
📱 典型底部导航栏结构:
┌─────────────────────────────────────┐
│ App内容 │
│ │
│ │
│ │ ← 文章列表等内容区域
│ │
│ │
├─────────────────────────────────────┤
│ [🏠全部] [🌐前端] [⚙️后端] [💾数据]│ ← 底部导航栏
└─────────────────────────────────────┘
这种设计模式的优势非常明显:
🎨 用户体验优秀
- 单手操作友好:拇指轻松触及,符合人体工程学
- 视觉层次清晰:内容区域与导航区域分离
- 操作路径短:一键直达,无需层层点击
⚡ 功能组织合理
- 并行访问:四个技术分类同时可用
- 状态独立:每个分类维护自己的浏览状态
- 快速切换:无需重新加载,瞬间切换分类
🔥 广泛应用验证
底部导航栏已成为主流设计模式,被无数成功应用采用:
- 📘 微信:聊天、通讯录、发现、我
- 🛒 淘宝:首页、微淘、消息、购物车、我的
- 📱 微博:首页、视频、发现、消息、我
- 💬 QQ:消息、联系人、动态
🎯 我们一起要做的
这次,咱们一起来给应用加上底部导航栏,实现四个技术分类:
- 🏠 全部文章:最新的技术趋势和热门内容
- 🌐 前端:React、Vue、Flutter这些前端技术
- ⚙️ 后端:微服务、API设计、性能优化
- 💾 数据库:SQL优化、NoSQL、数据建模
看起来需求很简单对吧?用户可以通过底部导航栏在不同技术分类间快速切换。
🛠️ 底部导航栏技术选型
在 Flutter 中,实现底部导航栏主要用到这几个 Widget:
1. Scaffold 组件
Scaffold 是 Flutter 提供的页面骨架组件,它内置了对底部导航栏的支持:
Scaffold(
body: 你的页面内容,
bottomNavigationBar: 底部导航栏组件,
)
2. BottomNavigationBar 组件
这是底部导航栏的核心组件,支持:
- 多个 Tab 项配置
- 图标和文字显示
- 选中/未选中状态样式
- 点击事件回调
3. 页面容器管理
IndexedStack:保持页面状态的堆叠容器- 或者直接用
List<Widget>[index]:简单的页面切换
搞清楚了要用哪些组件,咱们就动手来实现这个底部导航栏吧!
🚀 第一步:实现底部导航栏基础版本
先不管那么多,咱们用最直观的方式来实现这个底部导航栏:
📦 基于现有项目架构
test_csdn 项目已经为我们提供了很好的基础设施,我们可以在此基础上新增导航栏功能:
✅ 项目的基础设施:
- test_csdn 项目已经搭好了很好的架子(Provider状态管理、HTTP请求、基础UI组件等)
- 咱们就在这个基础上,搞出自己的底部导航栏
🆕 咱们新增的组件:
- ArticleCategory 枚举:定义四个技术分类
- CategoryPage 组件:一个通用的分类页面组件
- MainTabPage 组件:主页面,负责底部导航栏的管理
- 这些组件会跟项目现有的架构慢慢磨合,最终完美集成
好了,咱们直接进入正题。一步步实现这个功能。下面就是我们的 MainTabPage 实现:
class MainTabPage extends StatefulWidget {
_MainTabPageState createState() => _MainTabPageState();
}
class _MainTabPageState extends State<MainTabPage> {
int _currentIndex = 0;
// 四个技术分类页面
// 每个页面会根据传入的category参数发起对应的网络请求
final List<Widget> _pages = [
CategoryPage(category: ArticleCategory.all), // 🏠 全部文章(无分类参数)
CategoryPage(category: ArticleCategory.frontend), // 🌐 前端(cate2=前端)
CategoryPage(category: ArticleCategory.backend), // ⚙️ 后端(cate2=后端)
CategoryPage(category: ArticleCategory.database), // 💾 数据库(cate2=数据库)
];
Widget build(BuildContext context) {
return Scaffold(
// 核心:直接使用数组索引来切换页面
body: _pages[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed, // 固定显示4个Tab
currentIndex: _currentIndex,
onTap: (index) {
setState(() => _currentIndex = index);
},
selectedItemColor: Color(0xFFFC5531), // CSDN品牌红
unselectedItemColor: Color(0xFF999999), // 灰色
items: [
BottomNavigationBarItem(
icon: Icon(Icons.home_rounded),
label: '全部文章'
),
BottomNavigationBarItem(
icon: Icon(Icons.web_rounded),
label: '前端'
),
BottomNavigationBarItem(
icon: Icon(Icons.developer_mode_rounded),
label: '后端'
),
BottomNavigationBarItem(
icon: Icon(Icons.storage_rounded),
label: '数据库'
),
],
),
);
}
}
看起来是不是很简单?来,简单聊聊这两个类的分工:
MainTabPage 这哥们:
- 主要负责整个页面的"骨架",也就是底部导航栏的整体结构
- 像个不动的外壳,稳稳地撑着整个界面
_MainTabPageState 这个兄弟:
- 负责页面内部的各种"状态":当前选中哪个Tab、有哪些页面要显示
_currentIndex记住当前哪个tab是选中状态(0=全部文章,1=前端,2=后端,3=数据库)。setState()改变_currentIndex的值,会引起UI刷新_pages列表装着咱们的四个分类页面
这就是Flutter的经典搭配:一个管结构,一个管状态,配合得挺好的。
等等!还有个事,为了能让这个代码真的跑起来,咱们还得先把 ArticleCategory 和 CategoryPage 这两个组件搞出来。放心,都很简单,几分钟就能搞定:
📝 先实现必需的组件
1. ArticleCategory 枚举(lib/models/article_category.dart):
/// 文章分类枚举
/// 定义应用中支持的文章分类类型
enum ArticleCategory {
/// 全部文章
all('全部文章', null),
/// 前端分类
frontend('前端', '前端'),
/// 后端分类
backend('后端', '后端'),
/// 数据库分类
database('数据库', '数据库');
const ArticleCategory(this.displayName, this.apiParameter);
/// 用于UI显示的分类名称
final String displayName;
/// 用于API请求的分类参数
final String? apiParameter;
}
2. CategoryPage 页面(lib/pages/category_page.dart):
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CategoryPage extends StatefulWidget {
final ArticleCategory category;
const CategoryPage({required this.category, super.key});
State<CategoryPage> createState() => _CategoryPageState();
}
class _CategoryPageState extends State<CategoryPage> {
List<String> articles = []; // 简单模拟文章列表
bool isLoading = true;
void initState() {
super.initState();
_loadArticles();
}
void _loadArticles() async {
// 模拟网络请求延迟
await Future.delayed(Duration(seconds: 1));
setState(() {
// 为不同分类生成不同的测试数据
articles = List.generate(20, (index) =>
'${widget.category.displayName}文章 ${index + 1}');
isLoading = false;
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.category.displayName),
backgroundColor: Color(0xFFFC5531),
foregroundColor: Colors.white,
),
body: isLoading
? Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: articles.length,
itemBuilder: (context, index) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
title: Text(articles[index]),
subtitle: Text('这是${widget.category.displayName}分类下的文章内容...'),
),
);
},
),
);
}
}
这样,我们的基础版本就能真正跑起来了!每个分类都会显示一个简单的文章列表。
看起来挺简单的,对吧?代码编译运行都没问题,底部导航栏也能正常切换…
但是! 这个实现有个致命缺陷:每次切换Tab,页面都会被销毁重建!
你看这行代码:
body: _pages[_currentIndex], // 核心:直接使用数组索引来切换页面
现在我们已经实现了底部的导航,是不是很简单?但它有一个致命的问题,每次切换tab都会创建新的页面实例。
🔍 第二步:发现问题
咱们来做个实验,想象一下这个场景:
- 你在"前端"分类下翻到第15篇文章,找到了一个很不错的Vue教程
- 这时你突发奇想,想去"后端"分类看看有没有相关的Node.js文章
- 你切换到"后端"分类,随便看了两眼
- 然后你回到"前端"分类,准备接着刚才的Vue教程继续看…
**结果页面重新加载了!**😱
你又得从头开始翻,之前的滚动位置、搜索状态全都没了,真让人抓狂。这种体验对于用户来说简直就是灾难级别的!
接下来咱们一起解决这个问题,让应用体验直接上一个档次!🚀
🎯 我们的核心问题
通过刚才的实验,我们发现了一个关键的用户体验痛点:
"如何在使用底部导航栏切换页面时,保持每个页面的浏览状态不丢失?
这个问题在技术上的本质是:
- ❌
body: _pages[_currentIndex]每次切换都会销毁重建页面 - ✅ 我们需要一种机制让页面"隐藏"而不是"销毁"
🔧 如何解决问题?
既然问题的关键是页面被销毁重建,那么解决方案就很明确了:
我们需要一个能让页面"隐藏"但保持存活而不销毁的容器!
幸运的是,Flutter 已经为我们准备了这个神器 —— IndexedStack!
什么是 IndexedStack?
IndexedStack 是一个特殊的容器组件,它有两个核心能力:
- 保持所有子页面存活:子组件不会被销毁,只是被隐藏
- 通过索引控制显示:
index参数指定哪个子页面可见
核心机制:
IndexedStack 内部用 Offstage 控制页面显示/隐藏,但所有页面始终在内存里,所以状态不会丢!
IndexedStack内部使用Offstage组件包裹每个子页面Offstage(offstage: true)= 页面隐藏但保持状态Offstage(offstage: false)= 页面显示且活跃
想象一下 IndexedStack 就像一叠纸:
┌─────────────────┐ ← 当前显示的页面(index=1,前端页面)
│ 前端页面 │
├─────────────────┤ ← 被隐藏但保持状态的页面
│ 全部文章页面 │ (Offstage,不可见但存活)
├─────────────────┤
│ 后端页面 │ (Offstage,不可见但存活)
├─────────────────┤
│ 数据库页面 │ (Offstage,不可见但存活)
└─────────────────┘
形象比喻:
- 普通做法像撕掉旧纸写新纸,每次都要重写
- IndexedStack像多张小纸叠一起,想看哪张就翻哪张
步骤 1:准备页面列表
在这之前,先聊聊什么是 TabItem 以及为什么要用它。
🤔 什么是 TabItem?
TabItem 是一个数据模型类,用来统一管理底部导航栏的标签页配置。它把标签页的文字、图标和对应的页面类型封装在一起。
想象一下,如果没有 TabItem,我们要管理底部导航栏的这些信息:
- 标签文字(“全部文章”、“前端”、“后端”、“数据库”)
- 图标(🏠、🌐、⚙️、💾)
- 对应的页面类型(全部、前端、后端、数据库分类)
这些信息如果用普通数组分别管理,代码会很分散,后期维护也麻烦。
** ✅ 为什么要用 TabItem?**
TabItem 的核心价值是:配置与代码分离
把标签页的配置信息集中在一起管理,这样:
- 🎯 一次配置,多处复用:UI、逻辑、页面创建都用同一份配置
- 🔧 维护简单:改一个地方,整个应用都生效
- 📱 类型安全:编译器会检查类型,避免配置错误
** 📷 TabItem 长什么样?**
简单来说,TabItem 就像配置文件一样,把标签页的信息打包:
TabItem 结构:
┌───────────────────────────────────┐
│ label: "前端" │ ← 显示文字
│ icon: Icons.web_rounded │ ← 图标
│ category: ArticleCategory.frontend│ ← 对应的分类
└───────────────────────────────────┘
然后在代码中,你就可以这样用:
- 创建页面:
CategoryPage(category: tab.category) - 获取图标:
Icon(tab.icon) - 获取文字:
Text(tab.label)
所有信息都在一个地方管理,再也不需要到处找配置了!
为了代码的可维护性和灵活性,我们使用 TabItem 配置来统一管理标签页:
class _MainTabPageState extends State<MainTabPage> {
int _currentIndex = 0;
// 使用 TabItem 配置
static const List<TabItem> _tabs = TabItem.defaultTabs;
// 页面列表 - 根据 TabItem 配置动态创建
late List<Widget> _pages;
void initState() {
super.initState();
// 根据分类配置创建对应的页面
_pages = _tabs.map((tab) => CategoryPage(category: tab.category)).toList();
}
// ...
其中 TabItem 的完整配置如下:
// lib/models/tab_item.dart 中的定义
static const List<TabItem> defaultTabs = [
TabItem(
label: '全部文章',
icon: Icons.home_rounded,
category: ArticleCategory.all,
),
TabItem(
label: '前端',
icon: Icons.web_rounded,
category: ArticleCategory.frontend,
),
TabItem(
label: '后端',
icon: Icons.developer_mode_rounded,
category: ArticleCategory.backend,
),
TabItem(
label: '数据库',
icon: Icons.storage_rounded,
category: ArticleCategory.database,
),
];
步骤 2:用 IndexedStack 替换直接索引
只需要改一行代码!
Widget build(BuildContext context) {
return Scaffold(
// ❌ 之前的写法
// body: _pages[_currentIndex],
// ✅ 现在的写法 - 核心就是这一行!
body: IndexedStack(
index: _currentIndex, // 显示哪个页面
children: _pages, // 所有页面列表
),
bottomNavigationBar: _buildBottomNavigationBar(),
);
}
步骤 3:构建底部导航栏(完整配置)
Widget _buildBottomNavigationBar() {
return BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: _currentIndex,
onTap: _onTabTapped,
// CSDN 品牌红色配置
selectedItemColor: Color(0xFFFC5531), // CSDN红色
unselectedItemColor: Color(0xFF999999), // 灰色
items: _tabs.map((tab) => BottomNavigationBarItem(
icon: Icon(tab.icon), // 使用 TabItem 中的图标
label: tab.label, // 使用 TabItem 中的标签
)).toList(),
);
}
步骤 4:处理标签切换
void _onTabTapped(int index) {
// 避免重复点击同一个标签
if (index != _currentIndex && index >= 0 && index < _tabs.length) {
setState(() {
_currentIndex = index; // 更新索引,触发 IndexedStack 切换
});
}
}
🎯 效果对比
| 方式 | 切换时页面状态 | 滚动位置 | 网络请求 | 用户体验 |
|---|---|---|---|---|
body: _pages[index] |
❌ 销毁重建 | ❌ 丢失 | ❌ 重复 | 😭 糟糕 |
IndexedStack |
✅ 保持存活 | ✅ 保持 | ✅ 缓存 | 🎉 完美 |
搞定了! 只需要把 body: _pages[_currentIndex] 换成 IndexedStack,你就拥有了完美的页面状态保持能力!
🔄 页面状态缓存进阶
使用 AutomaticKeepAliveClientMixin
为了进一步确保页面状态保持,我们在每个页面中添加:
class CategoryPage extends StatefulWidget {
final ArticleCategory category;
const CategoryPage({required this.category});
_CategoryPageState createState() => _CategoryPageState();
}
class _CategoryPageState extends State<CategoryPage>
with AutomaticKeepAliveClientMixin { // 关键Mixin
bool get wantKeepAlive => true; // 声明需要保持状态
Widget build(BuildContext context) {
super.build(context); // 必须调用!
return Scaffold(
appBar: AppBar(title: Text(widget.category.displayName)),
body: ListView.builder(
itemCount: articles.length,
itemBuilder: (context, index) {
return ArticleItem(article: articles[index]);
},
),
);
}
}
双重保险机制
IndexedStack(容器级保持)
↓
AutomaticKeepAliveClientMixin(页面级保持)
↓
完美的状态缓存 ✅
🎯 总结
通过本教程,我们学会了:
- Tab布局实现:使用
Scaffold + BottomNavigationBar快速搭建多频道应用 - 交互切换逻辑:通过
currentIndex和setState实现页面切换 - 状态缓存核心:使用
IndexedStack替代直接索引访问,完美解决页面重置问题
核心要点回顾:
- ❌ 避免使用
body: _pages[_currentIndex] - ✅ 使用
body: IndexedStack(index: _currentIndex, children: _pages) - ✅ 配合
AutomaticKeepAliveClientMixin双重保险
完整的代码在AtomGit 仓库:
https://gitcode.com/wangye110/test_csdn.git
欢迎加
入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)