Flutter for OpenHarmony 健康管理App应用实战 - 底部导航栏实现
说实话,底部导航栏这东西看起来简单,但要做好还真得花点心思。你打开手机上的微信、支付宝、抖音,底部都有一排图标,点哪个就切换到哪个页面。这种交互模式用户早就习惯了,所以我们的健康管理App也采用这种设计。不过我们加了点小心思——中间放了个悬浮的加号按钮。为啥这么设计?因为健康管理App最核心的操作就是"记录",记录吃了什么、喝了多少水、做了什么运动。把这个高频操作放在最显眼的位置,用户一眼就能看到

写在前面
说实话,底部导航栏这东西看起来简单,但要做好还真得花点心思。
你打开手机上的微信、支付宝、抖音,底部都有一排图标,点哪个就切换到哪个页面。这种交互模式用户早就习惯了,所以我们的健康管理App也采用这种设计。
不过我们加了点小心思——中间放了个悬浮的加号按钮。为啥这么设计?因为健康管理App最核心的操作就是"记录",记录吃了什么、喝了多少水、做了什么运动。把这个高频操作放在最显眼的位置,用户一眼就能看到,一点就能用。
这种设计在Keep、薄荷健康这类App里都能看到,算是健康类App的标配了。
先想清楚要做什么
动手写代码之前,先理一下思路。
我们的底部导航栏需要实现这几个功能:
第一,两个Tab切换。 左边是首页,显示今日的健康数据汇总;右边是个人中心,可以查看和修改个人信息、设置目标等。
第二,中间的悬浮按钮。 点击后弹出一个菜单,可以快速添加早餐、午餐、晚餐、零食、饮水、运动等记录。
第三,提醒功能。 用户可以设置喝水提醒、运动提醒等,到时间了要弹窗提示。这个功能的监听逻辑放在TabPage里,因为不管用户在哪个页面,提醒都要能弹出来。
想清楚了,开始写代码。
选择合适的组件
Flutter提供了好几种实现底部导航的方式。
最常用的是 BottomNavigationBar,简单直接,几行代码就能搞定。但它有个问题:中间没法放悬浮按钮,或者说放了也不好看,因为它不会自动给按钮留出空间。
还有一种是 BottomAppBar,这个组件更灵活。它有个 shape 属性,可以设置成 CircularNotchedRectangle(),底部栏中间就会自动挖出一个圆形的缺口,悬浮按钮正好嵌进去,严丝合缝。
所以我们选 BottomAppBar。
导入需要的包
先把依赖导进来:
import 'package:flutter/material.dart';
这是Flutter的核心UI库,Scaffold、BottomAppBar、FloatingActionButton 这些组件都在里面。
import 'package:provider/provider.dart';
Provider是状态管理库。我们的提醒功能需要监听状态变化,用Provider来实现。如果你还没用过Provider,可以先简单理解为:它能让不同的Widget共享数据,数据变了会自动通知相关的Widget更新。
import '../../utils/colors.dart';
这是我们自己定义的颜色工具类,里面有App的主题色、深色模式的颜色等。统一管理颜色可以让代码更整洁,改起来也方便。
import '../../widgets/add_bottom_sheet.dart';
import '../../widgets/reminder_dialog.dart';
AddBottomSheet 是点击加号按钮后弹出的底部菜单,ReminderDialog 是提醒弹窗。这两个组件我们后面会单独讲,这里先导入。
import '../../providers/reminder_provider.dart';
提醒功能的状态管理类,负责管理提醒的添加、删除、触发等逻辑。
import '../home/home_page.dart';
import '../profile/profile_page.dart';
两个主要页面:首页和个人中心。
创建TabPage类
底部导航栏所在的页面我们叫它 TabPage。因为需要维护"当前选中哪个Tab"这个状态,所以用 StatefulWidget:
class TabPage extends StatefulWidget {
const TabPage({super.key});
State<TabPage> createState() => _TabPageState();
}
这段代码没什么特别的,就是标准的StatefulWidget写法。super.key 是Dart 2.17引入的语法糖,等价于 Key? key 然后 super(key: key)。
定义状态变量
在State类里定义当前选中的Tab索引:
class _TabPageState extends State<TabPage> {
int _currentIndex = 0;
_currentIndex 为0表示选中首页,为1表示选中个人中心。变量名前面加下划线表示私有,外部访问不到。
你可能会问:为什么不用枚举?比如定义一个 enum TabType { home, profile },然后用 TabType _currentTab = TabType.home。
确实可以这么做,代码可读性会更好。但对于只有两个Tab的情况,用int也够了,简单直接。如果以后Tab变多了,再重构成枚举也不迟。
初始化提醒监听
initState 是Widget初始化时调用的方法,我们在这里设置提醒监听:
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_setupReminderListener();
});
}
这里有个细节:为什么不直接调用 _setupReminderListener(),而要用 addPostFrameCallback 包一层?
原因是 initState 执行的时候,Widget还没有完全构建好,context 还不能用。如果这时候访问 context.read<ReminderProvider>(),会报错。
addPostFrameCallback 的作用是:等当前帧渲染完成后再执行回调。这时候Widget已经构建好了,context 可以正常使用。
这是Flutter开发中很常见的一个坑,记住就好。
设置监听器
void _setupReminderListener() {
final reminderProvider = context.read<ReminderProvider>();
reminderProvider.addListener(_onReminderChanged);
}
context.read<ReminderProvider>() 从Widget树上层获取 ReminderProvider 实例。注意是 read 不是 watch,区别在于:
read只获取一次,不监听变化watch获取并监听,数据变了会触发Widget重建
我们这里用 read 是因为不需要重建Widget,只需要手动添加一个监听器。
addListener 是 ChangeNotifier 的方法,当Provider调用 notifyListeners() 时,所有通过 addListener 添加的回调都会被执行。
处理提醒变化
监听器的回调函数:
void _onReminderChanged() {
final reminderProvider = context.read<ReminderProvider>();
final pending = reminderProvider.pendingReminder;
if (pending != null && mounted) {
ReminderDialog.show(context, pending);
}
}
逻辑很简单:从Provider获取待显示的提醒,如果有的话就弹窗。
这里有个 mounted 检查,这是什么意思?
mounted 是State类的一个属性,表示这个State是否还"挂载"在Widget树上。如果用户快速切换页面,可能出现这种情况:提醒触发了,但TabPage已经被销毁了。这时候如果还调用 ReminderDialog.show(context, pending),就会报错。
所以加个 mounted 检查,确保页面还在才弹窗。这也是Flutter开发中的常见模式。
释放资源
Widget销毁时要移除监听器,不然会内存泄漏:
void dispose() {
try {
context.read<ReminderProvider>().removeListener(_onReminderChanged);
} catch (_) {}
super.dispose();
}
为什么用 try-catch 包裹?因为在某些情况下(比如热重载、App被系统杀掉),Provider可能已经被销毁了,这时候调用 context.read 会抛异常。用 try-catch 捕获一下,避免崩溃。
catch (_) 里的下划线表示我们不关心具体的异常是什么,直接忽略就好。
构建页面骨架
现在来写 build 方法。先看整体结构:
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: _currentIndex == 0 ? const HomePage() : const ProfilePage(),
),
bottomNavigationBar: _buildBottomNav(),
floatingActionButton: _buildFloatingButton(),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
);
}
Scaffold 是Flutter的页面脚手架,提供了 body、bottomNavigationBar、floatingActionButton 等插槽,我们往里面填内容就行。
SafeArea 是个很实用的组件,它会自动给内容加上padding,避免被刘海屏、底部手势条、状态栏等遮挡。现在的手机屏幕形状五花八门,用 SafeArea 包一下省心。
body 里用三元表达式根据 _currentIndex 显示不同的页面。这种写法简单直接,适合只有两三个页面的情况。
floatingActionButtonLocation 设置为 centerDocked,让悬浮按钮居中并嵌入底部栏。
构建悬浮按钮
把悬浮按钮的构建逻辑抽成一个方法,代码更清晰:
Widget _buildFloatingButton() {
return FloatingActionButton(
onPressed: () => AddBottomSheet.show(context),
backgroundColor: AppColors.dark,
shape: const CircleBorder(),
child: const Icon(Icons.add, color: Colors.white, size: 28),
);
}
onPressed 是点击回调,调用 AddBottomSheet.show(context) 弹出底部菜单。
backgroundColor 设置背景色,我们用的是深色(AppColors.dark),和底部栏的白色形成对比,更醒目。
shape: const CircleBorder() 让按钮变成圆形。Flutter 3.0之后,FloatingActionButton 默认是圆角矩形,想要圆形需要手动设置。
child 是按钮里面的内容,一个白色的加号图标。size: 28 让图标稍微大一点,更容易点击。
构建底部导航栏
重头戏来了,底部导航栏的实现:
Widget _buildBottomNav() {
return BottomAppBar(
height: 70,
color: Colors.white,
shape: const CircularNotchedRectangle(),
notchMargin: 8,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(Icons.bar_chart, 0),
const SizedBox(width: 48),
_buildNavItem(Icons.grid_view, 1),
],
),
);
}
一个一个属性来看。
height: 70 设置底部栏高度为70像素。这个值可以根据设计稿调整,太矮了图标会显得拥挤,太高了又浪费空间。
color: Colors.white 背景色为白色。如果要支持深色模式,这里需要根据主题动态设置,后面会讲。
shape: const CircularNotchedRectangle() 这是关键!它让底部栏中间有个圆形缺口,悬浮按钮正好嵌进去。这个效果是Flutter内置的,不需要自己画。
notchMargin: 8 缺口和悬浮按钮之间的间距,8像素看起来比较舒服。
child 里面用 Row 布局两个导航项,中间用 SizedBox(width: 48) 留出空间给悬浮按钮。为什么是48?因为 FloatingActionButton 的默认直径是56,留48的空间加上两边的padding,刚好够用。
构建导航项
把单个导航项的构建也抽成方法:
Widget _buildNavItem(IconData icon, int index) {
final isSelected = _currentIndex == index;
return IconButton(
icon: Icon(
icon,
color: isSelected ? AppColors.primary : Colors.grey,
),
onPressed: () => setState(() => _currentIndex = index),
);
}
isSelected 判断当前项是否被选中,选中的显示主题色,未选中的显示灰色。
onPressed 里调用 setState 更新 _currentIndex。setState 会触发 build 方法重新执行,页面就会显示新选中的Tab对应的内容。
这里有个小技巧:setState(() => _currentIndex = index) 用箭头函数简写,等价于:
setState(() {
_currentIndex = index;
});
代码更简洁。
关于CircularNotchedRectangle
多说两句 CircularNotchedRectangle。
这是Flutter内置的一个 NotchedShape 实现,专门用来在底部栏上挖圆形缺口。它会自动检测 FloatingActionButton 的位置和大小,然后在对应位置挖出合适大小的缺口。
如果你想要其他形状的缺口,比如菱形、方形,可以自定义一个 NotchedShape,重写 getOuterPath 方法。不过说实话,圆形缺口是最常见的设计,其他形状很少用到。
还有一点,CircularNotchedRectangle 只有在 floatingActionButtonLocation 设置为 centerDocked 或 endDocked 时才会生效。如果悬浮按钮不是"嵌入"底部栏的,就没必要挖缺口了。
页面切换的另一种方式
前面说了,我们用三元表达式切换页面:
_currentIndex == 0 ? const HomePage() : const ProfilePage()
这种方式有个问题:每次切换,之前的页面会被销毁,再切回来时重新创建。如果页面有滚动位置、表单输入等状态,切换后就丢了。
举个例子:用户在首页滚动到中间位置,切到个人中心,再切回首页,会发现又回到顶部了。
如果需要保持页面状态,可以用 IndexedStack:
body: IndexedStack(
index: _currentIndex,
children: const [
HomePage(),
ProfilePage(),
],
),
IndexedStack 会同时保持所有子Widget在内存里,只显示当前索引对应的那个。切换Tab时不会销毁页面,状态自然就保留了。
缺点是内存占用会高一些。如果页面很多或者很重,可能会有性能问题。
还有一种方案是用 PageView 配合 PageController,可以实现滑动切换,体验更好。但实现起来稍微复杂一点。
对于我们这个App,只有两个页面,都比较轻量,用三元表达式就够了。如果以后发现有状态丢失的问题,再改成 IndexedStack 也不迟。
深色模式适配
现在的App基本都要支持深色模式,我们的底部导航栏也不例外。
改造一下 _buildBottomNav 方法:
Widget _buildBottomNav() {
final isDark = Theme.of(context).brightness == Brightness.dark;
return BottomAppBar(
height: 70,
color: isDark ? AppColors.darkCard : Colors.white,
shape: const CircularNotchedRectangle(),
notchMargin: 8,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(Icons.bar_chart, 0, isDark),
const SizedBox(width: 48),
_buildNavItem(Icons.grid_view, 1, isDark),
],
),
);
}
Theme.of(context).brightness 获取当前主题的亮度,Brightness.dark 表示深色模式,Brightness.light 表示亮色模式。
根据模式选择不同的背景色:深色模式用 AppColors.darkCard(一个深灰色),亮色模式用白色。
导航项也要相应调整:
Widget _buildNavItem(IconData icon, int index, bool isDark) {
final isSelected = _currentIndex == index;
final selectedColor = isDark ? AppColors.primaryLight : AppColors.primary;
return IconButton(
icon: Icon(
icon,
color: isSelected ? selectedColor : Colors.grey,
),
onPressed: () => setState(() => _currentIndex = index),
);
}
深色模式下选中的颜色用 AppColors.primaryLight,比主题色稍微亮一点,在深色背景上更清晰。
关于Provider的补充说明
前面用到了Provider,这里再补充几点。
Provider的核心是 ChangeNotifier。我们的 ReminderProvider 继承自 ChangeNotifier,当数据变化时调用 notifyListeners(),所有监听者就会收到通知。
获取Provider有三种方式:
context.read<T>() - 获取一次,不监听变化。适合在回调函数、initState 等地方使用。
context.watch<T>() - 获取并监听,数据变了会触发Widget重建。适合在 build 方法里使用。
Consumer<T> - 和 watch 类似,但可以精确控制重建范围,性能更好。
我们在 _setupReminderListener 里用 read,因为只需要获取Provider实例来添加监听器,不需要重建Widget。
如果用 watch,每次Provider变化都会触发 build 方法执行,但我们的 build 方法里并没有用到Provider的数据,重建是多余的。
这是个小细节,但在复杂的App里,这种细节积累起来会影响性能。
一些踩坑经验
写这个页面的时候踩了几个坑,分享一下。
坑一:悬浮按钮位置不对。
一开始忘了设置 floatingActionButtonLocation,悬浮按钮跑到右下角去了。加上 FloatingActionButtonLocation.centerDocked 就好了。
坑二:缺口不显示。
设置了 CircularNotchedRectangle() 但底部栏没有缺口。排查了半天,发现是 floatingActionButtonLocation 设置成了 centerFloat 而不是 centerDocked。centerFloat 是悬浮在底部栏上方,不嵌入,所以不会挖缺口。
坑三:热重载后Provider报错。
热重载时偶尔会报 ProviderNotFoundException。原因是热重载会重建Widget树,但Provider的生命周期可能和Widget不同步。在 dispose 里加 try-catch 可以避免崩溃。
坑四:点击图标没反应。
IconButton 的点击区域默认是48x48,如果图标太小,点击边缘可能没反应。可以设置 IconButton 的 padding 或者用 InkWell 包裹一个更大的区域。
完整代码
最后贴一下完整代码,方便复制:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../utils/colors.dart';
import '../../widgets/add_bottom_sheet.dart';
import '../../widgets/reminder_dialog.dart';
import '../../providers/reminder_provider.dart';
import '../home/home_page.dart';
import '../profile/profile_page.dart';
class TabPage extends StatefulWidget {
const TabPage({super.key});
State<TabPage> createState() => _TabPageState();
}
这是Widget的定义部分,没什么特别的。
class _TabPageState extends State<TabPage> {
int _currentIndex = 0;
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_setupReminderListener();
});
}
void _setupReminderListener() {
final reminderProvider = context.read<ReminderProvider>();
reminderProvider.addListener(_onReminderChanged);
}
void _onReminderChanged() {
final reminderProvider = context.read<ReminderProvider>();
final pending = reminderProvider.pendingReminder;
if (pending != null && mounted) {
ReminderDialog.show(context, pending);
}
}
void dispose() {
try {
context.read<ReminderProvider>().removeListener(_onReminderChanged);
} catch (_) {}
super.dispose();
}
State类的初始化和销毁逻辑,主要是提醒监听的设置和清理。
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: _currentIndex == 0 ? const HomePage() : const ProfilePage(),
),
bottomNavigationBar: _buildBottomNav(),
floatingActionButton: FloatingActionButton(
onPressed: () => AddBottomSheet.show(context),
backgroundColor: AppColors.dark,
shape: const CircleBorder(),
child: const Icon(Icons.add, color: Colors.white, size: 28),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
);
}
build 方法,构建页面的整体结构。
Widget _buildBottomNav() {
return BottomAppBar(
height: 70,
color: Colors.white,
shape: const CircularNotchedRectangle(),
notchMargin: 8,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(
icon: Icon(
Icons.bar_chart,
color: _currentIndex == 0 ? AppColors.primary : Colors.grey,
),
onPressed: () => setState(() => _currentIndex = 0),
),
const SizedBox(width: 48),
IconButton(
icon: Icon(
Icons.grid_view,
color: _currentIndex == 1 ? AppColors.primary : Colors.grey,
),
onPressed: () => setState(() => _currentIndex = 1),
),
],
),
);
}
}
底部导航栏的构建,用 BottomAppBar 配合 CircularNotchedRectangle 实现中间有缺口的效果。
小结
这篇文章实现了一个带悬浮按钮的底部导航栏。核心知识点:
BottomAppBar+CircularNotchedRectangle实现中间有缺口的底部栏FloatingActionButton+centerDocked实现嵌入式悬浮按钮addPostFrameCallback解决initState中访问context的问题- Provider的
addListener实现手动监听
底部导航栏是App的骨架,后面所有的页面都挂在它下面。下一篇我们来实现首页的整体布局,敬请期待。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)