在 Flutter 开发中,底部导航是多页面切换的核心载体。原生BottomNavigationBar存在状态不保持、样式扩展差、不支持凸起按钮、未读提示配置繁琐等问题。本文封装的CommonBottomNavWidget整合 "状态保持 + 自定义样式 + 中间凸起按钮 + 未读提示" 四大核心能力,支持 2-5 个导航项,一行代码集成,覆盖首页、商城、社交等多 Tab 场景,彻底解决重复开发痛点!
一、核心优势(精准解决开发痛点)
✅ 状态保持:基于IndexedStack+Navigator实现页面切换不刷新,解决原生组件切换时页面重建问题✅ 样式全自定义:导航栏背景(纯色 / 渐变)、图标 / 文本颜色、边框、阴影均可配置,统一 APP 视觉风格✅ 凸起按钮适配:内置中间凸起按钮(如 "发布""加号"),仅需 4 个导航项即可自动适配布局,无需手动偏移✅ 未读提示灵活:支持红点徽章、数字徽章(99 + 溢出处理),支持动态更新徽章状态,适配消息通知场景✅ 交互体验优化:点击带平滑切换动画、禁止重复点击、自动适配全面屏底部安全区,贴合平台交互规范✅ 深色模式兼容:自动适配亮色 / 深色主题,所有颜色统一处理,无需额外配置
二、核心配置速览(关键参数一目了然)
| 配置分类 | 核心参数 | 核心作用 |
|---|---|---|
| 必选配置 | items、pages |
导航项列表(图标 + 文本 + 徽章)、对应页面列表(数量需与导航项一致) |
| 样式配置 | bgColor、selectedColor、height |
导航栏背景色、选中状态颜色、导航栏高度(默认 56px,适配主流设计) |
| 交互配置 | initialIndex、disableRepeatTap |
初始选中索引(默认 0)、是否禁止重复点击(避免页面重复构建,默认 true) |
| 凸起按钮配置 | hasCenterBtn、centerBtnWidget |
是否显示凸起按钮(仅 4 个导航项生效)、自定义凸起按钮样式、点击回调 |
| 未读提示配置 | showBadge、badgeCount |
导航项是否显示徽章、数字徽章数量(null 则显示红点,超过 99 自动显示 99+) |
| 适配配置 | adaptDarkMode、topBorder |
是否适配深色模式(默认 true)、顶部边框样式(区分导航栏与页面) |
三、生产级完整代码(可直接复制,开箱即用)
dart
import 'package:flutter/material.dart';
/// 底部导航项模型(统一管理导航项配置,清晰无冗余)
class BottomNavItem {
final IconData selectedIcon; // 选中状态图标
final IconData unselectedIcon; // 未选中状态图标
final String title; // 导航项文本
bool showBadge; // 是否显示徽章(红点/数字)
int? badgeCount; // 数字徽章数量(null则显示红点)
BottomNavItem({
required this.selectedIcon,
required this.unselectedIcon,
required this.title,
this.showBadge = false,
this.badgeCount,
});
/// 动态更新徽章状态(支持外部实时修改)
void updateBadge({required bool showBadge, int? badgeCount}) {
this.showBadge = showBadge;
this.badgeCount = badgeCount;
}
}
/// 通用底部导航组件(支持状态保持、凸起按钮、未读提示)
class CommonBottomNavWidget extends StatefulWidget {
// 必选参数(核心依赖)
final List<BottomNavItem> items; // 导航项列表(2-5个)
final List<Widget> pages; // 对应页面列表(数量需与导航项一致)
// 样式配置(统一视觉风格)
final Color bgColor; // 导航栏背景色(默认白色)
final Color selectedColor; // 选中状态颜色(图标+文本,默认蓝色)
final Color unselectedColor; // 未选中状态颜色(默认灰色)
final TextStyle titleStyle; // 文本样式(默认12号字体)
final double iconSize; // 图标大小(默认24px)
final double height; // 导航栏高度(默认56px)
final double elevation; // 阴影高度(默认4px,增强层次感)
final Border topBorder; // 顶部边框(默认浅灰色细边框,区分页面与导航栏)
// 交互配置(优化用户体验)
final int initialIndex; // 初始选中索引(默认0)
final ValueChanged<int>? onTap; // 切换回调(返回选中索引)
final bool disableRepeatTap; // 禁止重复点击(默认true,避免页面重复构建)
final Duration animationDuration; // 切换动画时长(默认200ms,平滑过渡)
// 凸起按钮配置(仅4个导航项生效)
final bool hasCenterBtn; // 是否显示中间凸起按钮(默认false)
final Widget? centerBtnWidget; // 自定义凸起按钮样式(默认圆形加号)
final VoidCallback? centerBtnOnTap; // 凸起按钮点击回调(必填)
final double centerBtnSize; // 凸起按钮大小(默认60px)
final double centerBtnOffset; // 向上偏移量(默认20px,突出导航栏)
// 适配配置(兼容多场景)
final bool adaptDarkMode; // 是否适配深色模式(默认true)
const CommonBottomNavWidget({
super.key,
required this.items,
required this.pages,
this.bgColor = Colors.white,
this.selectedColor = Colors.blue,
this.unselectedColor = Colors.grey,
this.titleStyle = const TextStyle(fontSize: 12),
this.iconSize = 24.0,
this.height = 56.0,
this.elevation = 4.0,
this.topBorder = const Border(top: BorderSide(color: Color(0xFFE0E0E0), width: 0.5)),
this.initialIndex = 0,
this.onTap,
this.disableRepeatTap = true,
this.animationDuration = const Duration(milliseconds: 200),
this.hasCenterBtn = false,
this.centerBtnWidget,
this.centerBtnOnTap,
this.centerBtnSize = 60.0,
this.centerBtnOffset = 20.0,
this.adaptDarkMode = true,
}) : assert(items.length >= 2 && items.length <= 5, "导航项数量需为2-5个"),
assert(pages.length == items.length, "页面数量需与导航项一致"),
assert(!hasCenterBtn || items.length == 4, "凸起按钮仅支持4个导航项"),
assert(!hasCenterBtn || centerBtnOnTap != null, "凸起按钮需配置点击回调");
@override
State<CommonBottomNavWidget> createState() => _CommonBottomNavWidgetState();
}
class _CommonBottomNavWidgetState extends State<CommonBottomNavWidget> {
late int _currentIndex;
late List<GlobalKey<NavigatorState>> _navigatorKeys; // 子页面导航键(用于子页面跳转)
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
// 为每个页面创建独立NavigatorKey,支持子页面单独跳转
_navigatorKeys = List.generate(widget.pages.length, (_) => GlobalKey<NavigatorState>());
}
/// 导航项点击处理(禁止重复点击+切换动画)
void _onItemTap(int index) {
if (widget.disableRepeatTap && index == _currentIndex) return;
setState(() => _currentIndex = index);
widget.onTap?.call(index);
}
/// 凸起按钮点击处理
void _onCenterBtnTap() => widget.centerBtnOnTap?.call();
/// 深色模式颜色适配(统一处理所有可视化颜色)
Color _adaptDarkMode(Color lightColor, Color darkColor) {
if (!widget.adaptDarkMode) return lightColor;
return MediaQuery.platformBrightnessOf(context) == Brightness.dark ? darkColor : lightColor;
}
/// 构建单个导航项(含图标、文本、未读徽章)
Widget _buildNavItem(BottomNavItem item, int index) {
final isSelected = index == _currentIndex;
final itemColor = isSelected
? _adaptDarkMode(widget.selectedColor, Colors.blueAccent)
: _adaptDarkMode(widget.unselectedColor, Colors.grey[400]!);
return Expanded(
child: GestureDetector(
onTap: () => _onItemTap(index),
child: Container(
height: widget.height,
padding: const EdgeInsets.only(top: 8),
child: Stack(
alignment: Alignment.topCenter,
children: [
// 图标+文本组合
Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isSelected ? item.selectedIcon : item.unselectedIcon,
size: widget.iconSize,
color: itemColor,
),
const SizedBox(height: 4),
Text(
item.title,
style: widget.titleStyle.copyWith(color: itemColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
// 未读提示(红点/数字)
if (item.showBadge)
Positioned(
top: 0,
right: 20,
child: item.badgeCount == null
? // 红点徽章
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
)
: // 数字徽章(99+溢出处理)
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(8)),
child: Text(
item.badgeCount! > 99 ? "99+" : "${item.badgeCount}",
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
),
],
),
),
),
);
}
/// 构建导航项列表(含凸起按钮布局适配)
List<Widget> _buildNavItems() {
final items = <Widget>[];
for (int i = 0; i < widget.items.length; i++) {
// 4个导航项+显示凸起按钮:在第2个索引后插入占位符+凸起按钮
if (widget.hasCenterBtn && widget.items.length == 4 && i == 2) {
items.add(const Expanded(child: SizedBox())); // 占位,保证布局对称
items.add(_buildCenterBtn()); // 插入凸起按钮
}
items.add(_buildNavItem(widget.items[i], i));
}
return items;
}
/// 构建中间凸起按钮(向上偏移,突出导航栏)
Widget _buildCenterBtn() {
// 默认凸起按钮(圆形+加号图标)
final defaultBtn = Container(
width: widget.centerBtnSize,
height: widget.centerBtnSize,
decoration: BoxDecoration(
color: _adaptDarkMode(widget.selectedColor, Colors.blueAccent),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.3), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Icon(Icons.add, color: Colors.white, size: widget.iconSize),
);
final btnWidget = widget.centerBtnWidget ?? defaultBtn;
return GestureDetector(
onTap: _onCenterBtnTap,
child: Container(
margin: EdgeInsets.only(top: -widget.centerBtnOffset), // 向上偏移,突出导航栏
child: btnWidget,
),
);
}
@override
Widget build(BuildContext context) {
// 适配深色模式的颜色
final adaptedBgColor = _adaptDarkMode(widget.bgColor, const Color(0xFF2D2D2D));
final adaptedBorder = widget.topBorder.copyWith(
top: BorderSide(
color: _adaptDarkMode(widget.topBorder.top.color, const Color(0xFF444444)),
width: widget.topBorder.top.width,
),
);
return Scaffold(
// 状态保持核心:IndexedStack保持所有页面状态,Navigator支持子页面跳转
body: IndexedStack(
index: _currentIndex,
children: widget.pages.asMap().entries.map((entry) {
final index = entry.key;
final page = entry.value;
return Navigator(
key: _navigatorKeys[index],
onGenerateRoute: (_) => MaterialPageRoute(builder: (_) => page),
);
}).toList(),
),
// 底部导航栏主体
bottomNavigationBar: Container(
height: widget.height + MediaQuery.of(context).padding.bottom, // 适配全面屏底部安全区
decoration: BoxDecoration(
color: adaptedBgColor,
border: adaptedBorder,
boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: widget.elevation)],
),
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
child: Row(children: _buildNavItems()),
),
),
);
}
}
四、四大高频场景实战示例(直接复制可用)
场景 1:基础导航(3 个导航项 + 未读徽章)
适用场景:APP 首页、商城等基础多 Tab 场景,支持未读消息提示
dart
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 导航项配置(含未读徽章)
final items = [
BottomNavItem(
selectedIcon: Icons.home_filled,
unselectedIcon: Icons.home_outlined,
title: "首页",
),
BottomNavItem(
selectedIcon: Icons.shopping_cart,
unselectedIcon: Icons.shopping_cart_outlined,
title: "商城",
showBadge: true,
badgeCount: 3, // 数字徽章
),
BottomNavItem(
selectedIcon: Icons.person,
unselectedIcon: Icons.person_outlined,
title: "我的",
showBadge: true, // 红点徽章(无数字)
),
];
// 对应页面(实际项目替换为真实页面)
final pages = [const HomeTab(), const MallTab(), const MineTab()];
return CommonBottomNavWidget(
items: items,
pages: pages,
selectedColor: Colors.orangeAccent,
unselectedColor: Colors.grey[600]!,
bgColor: Colors.white,
elevation: 2,
onTap: (index) => debugPrint("切换到第$index页"),
);
}
}
// 示例页面(状态保持生效,切换后不会重建)
class HomeTab extends StatelessWidget {
const HomeTab({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text("首页(切换不刷新)"));
}
class MallTab extends StatelessWidget {
const MallTab({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text("商城"));
}
class MineTab extends StatelessWidget {
const MineTab({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text("我的"));
}
场景 2:带凸起按钮(4 个导航项 + 自定义样式)
适用场景:社交、内容创作类 APP(如发布动态、上传作品)
dart
class MainPage extends StatefulWidget {
@override
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
late List<BottomNavItem> items;
@override
void initState() {
super.initState();
// 初始化导航项
items = [
BottomNavItem(
selectedIcon: Icons.home_filled,
unselectedIcon: Icons.home_outlined,
title: "首页",
),
BottomNavItem(
selectedIcon: Icons.message,
unselectedIcon: Icons.message_outlined,
title: "消息",
showBadge: true,
badgeCount: 12,
),
BottomNavItem(
selectedIcon: Icons.discover,
unselectedIcon: Icons.discover_outlined,
title: "发现",
),
BottomNavItem(
selectedIcon: Icons.person,
unselectedIcon: Icons.person_outlined,
title: "我的",
),
];
}
@override
Widget build(BuildContext context) {
final pages = [const HomeTab(), const MessageTab(), const DiscoverTab(), const MineTab()];
return CommonBottomNavWidget(
items: items,
pages: pages,
hasCenterBtn: true,
// 凸起按钮点击逻辑(打开发布弹窗)
centerBtnOnTap: () {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => const SizedBox(
height: 200,
child: Center(child: Text("发布动态/上传作品")),
),
);
},
// 自定义凸起按钮样式(渐变圆形+相机图标)
centerBtnWidget: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Colors.orange, Colors.redAccent]),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.orange.withOpacity(0.3), blurRadius: 6)],
),
child: const Icon(Icons.camera_alt, color: Colors.white, size: 28),
),
selectedColor: Colors.orange,
bgColor: const Color(0xFFFAFAFA),
topBorder: const Border(top: BorderSide(color: Color(0xFFF5F5F5))),
);
}
}
class MessageTab extends StatelessWidget {
const MessageTab({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text("消息"));
}
class DiscoverTab extends StatelessWidget {
const DiscoverTab({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text("发现"));
}
场景 3:深色模式适配 + 动态更新徽章
适用场景:支持深色模式的 APP,需要动态更新未读消息数
dart
class DarkModeNavPage extends StatefulWidget {
@override
State<DarkModeNavPage> createState() => _DarkModeNavPageState();
}
class _DarkModeNavPageState extends State<DarkModeNavPage> {
late List<BottomNavItem> items;
@override
void initState() {
super.initState();
items = [
BottomNavItem(
selectedIcon: Icons.home_filled,
unselectedIcon: Icons.home_outlined,
title: "首页",
),
BottomNavItem(
selectedIcon: Icons.notifications,
unselectedIcon: Icons.notifications_outlined,
title: "通知",
showBadge: true,
badgeCount: 0,
),
];
// 模拟3秒后动态更新徽章(如收到新通知)
Future.delayed(const Duration(seconds: 3), () {
setState(() {
items[1].updateBadge(showBadge: true, badgeCount: 8);
});
});
}
@override
Widget build(BuildContext context) {
final pages = [const HomeTab(), const NotificationTab()];
return CommonBottomNavWidget(
items: items,
pages: pages,
adaptDarkMode: true,
bgColor: Colors.white,
selectedColor: Colors.blue,
unselectedColor: Colors.grey,
topBorder: const Border(top: BorderSide(color: Color(0xFFE0E0E0))),
// 深色模式下自动适配背景色、文字色、边框色
);
}
}
class NotificationTab extends StatelessWidget {
const NotificationTab({super.key});
@override
Widget build(BuildContext context) => const Center(child: Text("通知中心"));
}
场景 4:子页面跳转(不影响底部导航)
适用场景:导航项对应页面内需要跳转子页面(如 "我的" 页面跳转设置页)
dart
class MineTab extends StatelessWidget {
const MineTab({super.key});
@override
Widget build(BuildContext context) {
// 获取当前页面的NavigatorKey(需在父组件中传递或通过全局状态管理获取)
final navigatorKey = (context.findAncestorWidgetOfExactType<CommonBottomNavWidget>() as CommonBottomNavWidget)
._navigatorKeys[3]; // 对应"我的"页面的索引(3)
return Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () {
// 子页面跳转(不影响底部导航,返回时回到当前页面)
navigatorKey.currentState?.push(
MaterialPageRoute(builder: (_) => const SettingsTab()),
);
},
child: const Text("跳转设置页"),
),
),
);
}
}
class SettingsTab extends StatelessWidget {
const SettingsTab({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text("设置")),
body: const Center(child: Text("设置页面")),
);
}
五、核心封装技巧(复用成熟设计思路)
- 状态保持实现 :通过
IndexedStack包裹所有页面,保持页面实例不销毁;搭配Navigator为每个页面分配独立导航栈,支持子页面跳转不影响底部导航。 - 凸起按钮布局适配 :4 个导航项时,在第 2 个索引后插入占位符,凸起按钮通过
margin-top向上偏移,保证左右导航项对称,无需手动计算布局。 - 徽章动态更新 :导航项模型
BottomNavItem提供updateBadge方法,外部可通过setState动态修改徽章显示状态和数量,适配实时消息通知场景。 - 深色模式统一适配 :所有可视化颜色(背景、文字、边框)通过
_adaptDarkMode方法处理,自动识别系统主题,无需单独配置深色模式样式。 - 交互体验优化:禁止重复点击避免页面重复构建,导航项点击带状态切换动画,全面屏底部安全区自动适配,贴合用户操作习惯。
六、避坑指南(解决 90% 开发痛点)
- 页面数量匹配 :
pages数量必须与items一致,否则会触发断言错误;建议在初始化时通过常量定义导航项和页面,避免数量不一致。 - 凸起按钮限制 :仅支持 4 个导航项时显示凸起按钮,其他数量(2-3、5 个)设置
hasCenterBtn: true无效,需提前规划导航项数量。 - 子页面跳转 :子页面跳转必须使用当前页面对应的
_navigatorKeys[index],避免使用全局Navigator,否则会导致底部导航消失。 - 状态管理注意 :若页面需要保存复杂状态(如表单输入),建议在页面内部使用
StatefulWidget+ViewModel,IndexedStack仅保证页面实例不销毁,不负责业务状态管理。 - 导航项文本长度 :导航项文本建议控制在 2-3 个字,过长会导致文本溢出或布局错位;若需长文本,可缩小
titleStyle字体大小或限制maxLines: 1。 - 性能优化 :导航项数量建议不超过 5 个,过多会导致每个导航项点击区域过小;页面过多时,可考虑懒加载页面(通过
IndexedStack+Offstage实现)。
欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。