适用平台: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的经典搭配:一个管结构,一个管状态,配合得挺好的。

等等!还有个事,为了能让这个代码真的跑起来,咱们还得先把 ArticleCategoryCategoryPage 这两个组件搞出来。放心,都很简单,几分钟就能搞定:

📝 先实现必需的组件

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 是一个特殊的容器组件,它有两个核心能力:

  1. 保持所有子页面存活:子组件不会被销毁,只是被隐藏
  2. 通过索引控制显示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(页面级保持)
    ↓
完美的状态缓存 ✅

🎯 总结

通过本教程,我们学会了:

  1. Tab布局实现:使用 Scaffold + BottomNavigationBar 快速搭建多频道应用
  2. 交互切换逻辑:通过 currentIndexsetState 实现页面切换
  3. 状态缓存核心:使用 IndexedStack 替代直接索引访问,完美解决页面重置问题

核心要点回顾

  • ❌ 避免使用 body: _pages[_currentIndex]
  • ✅ 使用 body: IndexedStack(index: _currentIndex, children: _pages)
  • ✅ 配合 AutomaticKeepAliveClientMixin 双重保险

完整的代码在AtomGit 仓库:

https://gitcode.com/wangye110/test_csdn.git

欢迎加

入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐