请添加图片描述

写在前面

说实话,底部导航栏这东西看起来简单,但要做好还真得花点心思。

你打开手机上的微信、支付宝、抖音,底部都有一排图标,点哪个就切换到哪个页面。这种交互模式用户早就习惯了,所以我们的健康管理App也采用这种设计。

不过我们加了点小心思——中间放了个悬浮的加号按钮。为啥这么设计?因为健康管理App最核心的操作就是"记录",记录吃了什么、喝了多少水、做了什么运动。把这个高频操作放在最显眼的位置,用户一眼就能看到,一点就能用。

这种设计在Keep、薄荷健康这类App里都能看到,算是健康类App的标配了。


先想清楚要做什么

动手写代码之前,先理一下思路。

我们的底部导航栏需要实现这几个功能:

第一,两个Tab切换。 左边是首页,显示今日的健康数据汇总;右边是个人中心,可以查看和修改个人信息、设置目标等。

第二,中间的悬浮按钮。 点击后弹出一个菜单,可以快速添加早餐、午餐、晚餐、零食、饮水、运动等记录。

第三,提醒功能。 用户可以设置喝水提醒、运动提醒等,到时间了要弹窗提示。这个功能的监听逻辑放在TabPage里,因为不管用户在哪个页面,提醒都要能弹出来。

想清楚了,开始写代码。


选择合适的组件

Flutter提供了好几种实现底部导航的方式。

最常用的是 BottomNavigationBar,简单直接,几行代码就能搞定。但它有个问题:中间没法放悬浮按钮,或者说放了也不好看,因为它不会自动给按钮留出空间。

还有一种是 BottomAppBar,这个组件更灵活。它有个 shape 属性,可以设置成 CircularNotchedRectangle(),底部栏中间就会自动挖出一个圆形的缺口,悬浮按钮正好嵌进去,严丝合缝。

所以我们选 BottomAppBar


导入需要的包

先把依赖导进来:

import 'package:flutter/material.dart';

这是Flutter的核心UI库,ScaffoldBottomAppBarFloatingActionButton 这些组件都在里面。

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,只需要手动添加一个监听器。

addListenerChangeNotifier 的方法,当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的页面脚手架,提供了 bodybottomNavigationBarfloatingActionButton 等插槽,我们往里面填内容就行。

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 更新 _currentIndexsetState 会触发 build 方法重新执行,页面就会显示新选中的Tab对应的内容。

这里有个小技巧:setState(() => _currentIndex = index) 用箭头函数简写,等价于:

setState(() {
  _currentIndex = index;
});

代码更简洁。


关于CircularNotchedRectangle

多说两句 CircularNotchedRectangle

这是Flutter内置的一个 NotchedShape 实现,专门用来在底部栏上挖圆形缺口。它会自动检测 FloatingActionButton 的位置和大小,然后在对应位置挖出合适大小的缺口。

如果你想要其他形状的缺口,比如菱形、方形,可以自定义一个 NotchedShape,重写 getOuterPath 方法。不过说实话,圆形缺口是最常见的设计,其他形状很少用到。

还有一点,CircularNotchedRectangle 只有在 floatingActionButtonLocation 设置为 centerDockedendDocked 时才会生效。如果悬浮按钮不是"嵌入"底部栏的,就没必要挖缺口了。


页面切换的另一种方式

前面说了,我们用三元表达式切换页面:

_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 而不是 centerDockedcenterFloat 是悬浮在底部栏上方,不嵌入,所以不会挖缺口。

坑三:热重载后Provider报错。

热重载时偶尔会报 ProviderNotFoundException。原因是热重载会重建Widget树,但Provider的生命周期可能和Widget不同步。在 dispose 里加 try-catch 可以避免崩溃。

坑四:点击图标没反应。

IconButton 的点击区域默认是48x48,如果图标太小,点击边缘可能没反应。可以设置 IconButtonpadding 或者用 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

Logo

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

更多推荐