Flutter 通用底部导航组件 CommonBottomNavWidget:状态保持 + 凸起按钮适配

在 Flutter 开发中,底部导航是多页面切换的核心载体。原生BottomNavigationBar存在状态不保持、样式扩展差、不支持凸起按钮、未读提示配置繁琐等问题。本文封装的CommonBottomNavWidget整合 "状态保持 + 自定义样式 + 中间凸起按钮 + 未读提示" 四大核心能力,支持 2-5 个导航项,一行代码集成,覆盖首页、商城、社交等多 Tab 场景,彻底解决重复开发痛点!

一、核心优势(精准解决开发痛点)

✅ 状态保持:基于IndexedStack+Navigator实现页面切换不刷新,解决原生组件切换时页面重建问题✅ 样式全自定义:导航栏背景(纯色 / 渐变)、图标 / 文本颜色、边框、阴影均可配置,统一 APP 视觉风格✅ 凸起按钮适配:内置中间凸起按钮(如 "发布""加号"),仅需 4 个导航项即可自动适配布局,无需手动偏移✅ 未读提示灵活:支持红点徽章、数字徽章(99 + 溢出处理),支持动态更新徽章状态,适配消息通知场景✅ 交互体验优化:点击带平滑切换动画、禁止重复点击、自动适配全面屏底部安全区,贴合平台交互规范✅ 深色模式兼容:自动适配亮色 / 深色主题,所有颜色统一处理,无需额外配置

二、核心配置速览(关键参数一目了然)

配置分类 核心参数 核心作用
必选配置 itemspages 导航项列表(图标 + 文本 + 徽章)、对应页面列表(数量需与导航项一致)
样式配置 bgColorselectedColorheight 导航栏背景色、选中状态颜色、导航栏高度(默认 56px,适配主流设计)
交互配置 initialIndexdisableRepeatTap 初始选中索引(默认 0)、是否禁止重复点击(避免页面重复构建,默认 true)
凸起按钮配置 hasCenterBtncenterBtnWidget 是否显示凸起按钮(仅 4 个导航项生效)、自定义凸起按钮样式、点击回调
未读提示配置 showBadgebadgeCount 导航项是否显示徽章、数字徽章数量(null 则显示红点,超过 99 自动显示 99+)
适配配置 adaptDarkModetopBorder 是否适配深色模式(默认 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("设置页面")),
      );
}

五、核心封装技巧(复用成熟设计思路)

  1. 状态保持实现 :通过IndexedStack包裹所有页面,保持页面实例不销毁;搭配Navigator为每个页面分配独立导航栈,支持子页面跳转不影响底部导航。
  2. 凸起按钮布局适配 :4 个导航项时,在第 2 个索引后插入占位符,凸起按钮通过margin-top向上偏移,保证左右导航项对称,无需手动计算布局。
  3. 徽章动态更新 :导航项模型BottomNavItem提供updateBadge方法,外部可通过setState动态修改徽章显示状态和数量,适配实时消息通知场景。
  4. 深色模式统一适配 :所有可视化颜色(背景、文字、边框)通过_adaptDarkMode方法处理,自动识别系统主题,无需单独配置深色模式样式。
  5. 交互体验优化:禁止重复点击避免页面重复构建,导航项点击带状态切换动画,全面屏底部安全区自动适配,贴合用户操作习惯。

六、避坑指南(解决 90% 开发痛点)

  1. 页面数量匹配pages数量必须与items一致,否则会触发断言错误;建议在初始化时通过常量定义导航项和页面,避免数量不一致。
  2. 凸起按钮限制 :仅支持 4 个导航项时显示凸起按钮,其他数量(2-3、5 个)设置hasCenterBtn: true无效,需提前规划导航项数量。
  3. 子页面跳转 :子页面跳转必须使用当前页面对应的_navigatorKeys[index],避免使用全局Navigator,否则会导致底部导航消失。
  4. 状态管理注意 :若页面需要保存复杂状态(如表单输入),建议在页面内部使用StatefulWidget+ViewModelIndexedStack仅保证页面实例不销毁,不负责业务状态管理。
  5. 导航项文本长度 :导航项文本建议控制在 2-3 个字,过长会导致文本溢出或布局错位;若需长文本,可缩小titleStyle字体大小或限制maxLines: 1
  6. 性能优化 :导航项数量建议不超过 5 个,过多会导致每个导航项点击区域过小;页面过多时,可考虑懒加载页面(通过IndexedStack+Offstage实现)。

欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。

相关推荐
走在路上的菜鸟8 小时前
Android学Dart学习笔记第二十节 类-枚举
android·笔记·学习·flutter
巴拉巴拉~~8 小时前
Flutter 通用表单输入组件 CustomInputWidget:校验 + 样式 + 交互一键适配
javascript·flutter·交互
yoona10208 小时前
Flutter 声明式 UI:为什么 build 会被反复调用?
flutter·ui·区块链·dex
ujainu小8 小时前
Flutter动画提效实战:animations 2.1.1 官方包全解析,4种Material动画开箱即用
flutter·animations
巴拉巴拉~~9 小时前
深入探索Flutter自定义绘制:从零到一实现炫酷仪表盘
flutter·ui
ujainu小9 小时前
Flutter 结合 path_provider 2.1.5 实现跨平台文件路径管理
flutter·path_provider
ujainu小9 小时前
Flutter image_picker 1.2.1 插件:图片与视频选择全攻略
flutter
巴拉巴拉~~9 小时前
Flutter 通用列表项组件 CommonListItemWidget:全场景布局 + 交互增强
flutter·php·交互
kirk_wang21 小时前
Flutter 导航锁踩坑实录:从断言失败到类型转换异常
前端·javascript·flutter