接到这个仿闲鱼底部导航栏的需求时,我第一反应是:底部导航栏,不就是BottomNavigationBar一摆、五个 item 一配、onTap里切个页面就完事了吗?等我把参考应用的交互录屏放慢看了三遍,才发现自己想得太简单了。中间那个圆形发布按钮根本不是嵌在导航栏里的普通图标,它是整体向上凸起、自带投影、按下还有缩放反馈的独立按钮;左右两边的 tab 在切换时也不是单纯变个颜色,而是图标、文字、选中态一体联动;消息 tab 上还挂着数字角标和未读红点。
这篇文章就围绕这个需求来写:我会把从需求拆解、布局选型,到中间凸起按钮、状态联动、角标实现,再到真机适配和性能排查的完整过程都过一遍。如果你正准备给自己的 Flutter 项目加一个类似风格的底部导航栏,或者已经加完了但总觉得哪里不对,这篇文章应该能帮你少走不少弯路。
1. 先拆需求:仿闲鱼导航栏到底“仿”的是什么
1.1 一眼看去是五个按钮,实际是三种交互载体
很多需求方描述这个功能时,就一句话:“做一个像那样子的底部导航栏。”但落到设计稿上,你把导航栏拆开看,会发现它根本不是同一个层级的组件。
左右两边的四个入口,本质上是常规的 tab 切换,点击切换页面、切换选中态。中间的发布按钮不一样,它更像一个“主操作按钮”,点击之后不是切换 tab,而是打开一个独立的发布页或者半屏弹窗。参考应用中,这类按钮承担的是最高优先级的用户动作,所以它在视觉上必须跳出来,尺寸更大、颜色更鲜艳、位置更高。
消息 tab 又比普通 tab 多了一层信息展示逻辑:未读数字角标、纯红点、数字超过 99 显示99+,这些状态既要跟随业务数据变化,又不能在每次切换 tab 时重建整个导航栏。
所以仿闲鱼底部导航栏,本质上是“四个普通 tab 按钮 + 一个独立 CTA 按钮 + 一套角标系统”的组合。如果你一开始就把五个条目塞进同一个BottomNavigationBarItem列表,后面一定会被各种特殊需求绕进去。
1.2 从设计稿倒推尺寸,而不是凭感觉写死
开始写代码之前,我习惯先把设计稿里的关键尺寸量出来。导航栏整体高度、中间按钮直径、按钮凸出量、按钮距离两边的余量、选中色值,这些参数最后都要写进代码里,如果一开始不统一,后面调样式会非常痛苦。
以我当时接到的设计稿为例,关键参数大概是这样的:
| 参数 | 数值 |
|---|---|
| 底部栏主体高度 | 56 |
| 底部安全区高度 | 跟随系统,用MediaQuery.padding.bottom |
| 中间按钮直径 | 52 |
| 按钮凸出导航栏主体顶部的距离 | 16 |
| 左右 tab 图标尺寸 | 24 |
| 选中态颜色 | 对应品牌主色 |
| 未选中态颜色 | 中性灰 |
这些数值不一定要跟参考应用完全一致,但必须固定下来,作为组件里的常量。我踩过一个坑:早期实现时凸出量随手写了个-20,后来设计评审说“凸太多了,跟参考不像”,我全局找了一通才改干净。正确的做法是把导航栏尺寸收敛到一个配置类里,比如BottomBarConfig.publishButtonSize、BottomBarConfig.publishOverflow,后面调参只改一处。
1.3 为什么系统自带的 BottomNavigationBar 直接出局
可能有人会问,Flutter 自带的BottomNavigationBar不是也支持自定义图标和文字吗?为什么不用?
问题在于它的模型是“等分 item”。你可以设置type: BottomNavigationBarType.fixed,让它显示五个等宽的按钮,但每个 item 的高度、图标位置、选中态动画都是统一的。中间按钮要凸出到导航栏主体之外,要使用与背景色完全不同的渐变、阴影,要响应onTapDown和onTapUp做出按压反馈,这些东西在BottomNavigationBar的框架里都得通过 hack 的方式实现,还不如自己掌控布局。
从维护角度看,自定义布局的代码量多一些,但可控性强。只要把尺寸常量收敛好,后续改样式、加动画、适配平板都非常直接。
2. 布局方案选型:BottomAppBar、FloatingActionButton 还是纯 Stack
2.1 三种主流方案的对比
网上搜仿闲鱼底部导航栏,最常见的有三种实现路线。我先把它们放在一起对比一下。
| 方案 | 实现方式 | 优点 | 明显的坑 |
|---|---|---|---|
| BottomAppBar + FloatingActionButton | Scaffold里同时设置bottomNavigationBar和floatingActionButton,按钮位置用centerDocked | 代码量少,自带阴影和水波纹 | 默认会在导航栏顶部留一个凹口,需要自定义NotchedShape才能变成“凸出”而不是“凹陷”;按钮和导航栏的背景融合也比较别扭 |
| 纯 Stack 自定义 bottomNavigationBar | 在Scaffold.bottomNavigationBar里放一个Stack,用Row排四个 tab,中间用Positioned放按钮 | 完全可控,凸出、阴影、按压缩放都能精确实现 | 需要自己处理点击区域、安全区、阴影裁剪,细节多 |
| 使用第三方包 | 搜到一些现成的 bottom bar 包 | 上手快 | 样式和交互不一定匹配,定制需求一变就要改造源码,不如自己写 |
我第一次给这个需求做技术方案时,其实先试了BottomAppBar + FloatingActionButton路线,因为代码真的很少。但后面发现,FloatingActionButton的定位逻辑是为“悬浮按钮”设计的,它在导航栏上方预留的位置跟参考应用的凸起效果差别很大。你要让按钮刚好“骑”在导航栏中轴线上,同时保持导航栏本身的白色背景完整,就得花不少精力去调NotchedShape,而且不同屏幕尺寸下表现也会有偏差。
2.2 我最终的选择:Scaffold 底部栏内嵌 Stack
最后我选了第三种方案:在Scaffold.bottomNavigationBar参数里,传入一个自定义的Container + Stack组合。理由有这么几个。
第一,参考应用里中间按钮虽然凸起,但整个导航栏的点击区域、背景、阴影是一个整体。用 Stack 我可以把按钮和四个 tab 放在同一个容器里,方便统一管理。
第二,我希望按钮凸出的部分可以“超出”导航栏主体的可视区域,但又不希望它被裁剪。Stack 提供了clipBehavior: Clip.none,可以允许子组件绘制在边界之外,这正好满足凸起需求。
第三,四个 tab 加上中间按钮只有五个固定位置,用Row加SizedBox占位就能排好,不需要依赖复杂的布局系统。
基础骨架长这样:
Scaffold( body: IndexedStack( index: _currentIndex, children: _pages, ), bottomNavigationBar: CustomBottomBar( currentIndex: _currentIndex, unreadCount: _unreadCount, onTabSelected: (index) { setState(() { _currentIndex = index; }); }, onPublishPressed: _openPublishPage, ), )CustomBottomBar内部再用Container包Stack,这部分会在下一节展开。
2.3 关于 extendBody 的取舍
在Scaffold里,有一个参数叫extendBody。设置为true之后,body会延伸到bottomNavigationBar后面,视觉上页面内容可以“压”到导航栏底下,实现沉浸式效果。
我做仿闲鱼导航栏时,对这个参数的处理是:不全局开启。因为中间按钮凸起,如果body里的列表滑动到了底部,内容很容易被凸起的按钮遮挡。参考应用的做法是页面内容不会被导航栏盖住,而是导航栏本身有一层半透明背景,列表可以在导航栏后面滑动过去。
如果你想要沉浸式,建议这样配合:extendBody: true,然后给CustomBottomBar的背景加一个从白色到半透明的渐变,让列表滚到最底部时能看到内容从导航栏下方穿过。这个效果很加分,但要注意列表底部要留出足够padding,否则内容会被按钮挡住。
3. 中间发布按钮的实现:凸起、按压缩放与点击区域
3.1 用 Positioned 实现凸起,用 Transform 控制按压缩放
中间按钮的位置,我是在CustomBottomBar内部的Stack里,用Positioned直接放上去的。
Widget buildBottomBar() { return Container( height: 56 + MediaQuery.of(context).padding.bottom, decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 12, offset: Offset(0, -2), ), ], ), child: Stack( clipBehavior: Clip.none, children: [ _buildRowTabs(), Positioned( top: -16, left: 0, right: 0, child: Center( child: _buildPublishButton(), ), ), ], ), ); }这里最核心就两点:Stack的clipBehavior必须设为Clip.none,否则子组件一旦画出边界就会被裁剪;Positioned的top设为负值,按钮就能向上凸出。
按压缩放我用的是AnimatedScale,而不是手动改宽高:
Widget _buildPublishButton() { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _publishPressed = true), onTapUp: (_) => setState(() => _publishPressed = false), onTapCancel: () => setState(() => _publishPressed = false), onTap: widget.onPublishPressed, child: AnimatedScale( scale: _publishPressed ? 0.92 : 1.0, duration: Duration(milliseconds: 120), curve: Curves.easeOut, child: Container( width: 52, height: 52, decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF6A3D), Color(0xFFFF3D00)], ), boxShadow: [ BoxShadow( color: Color(0x33FF3D00), blurRadius: 16, offset: Offset(0, 6), ), ], ), child: Icon(Icons.add, size: 30, color: Colors.white), ), ), ); }HitTestBehavior.opaque是为了让整个圆形区域包括透明部分都能响应点击。如果不设置,按钮边缘可能在某些设备上出现“点不准”的问题。
3.2 凸出部分是否会被父级裁剪掉
这是我自己实现时踩得比较深的一个坑。一开始我的写法是把Stack放在Container里,Container设置了decoration和固定高度。在 iOS 模拟器上一切正常,换成某台 Android 真机后,按钮上方竟然被切掉了一截。
排查后发现,问题不在Stack,而在于某些主题或父组件会给底部导航栏套一层Clip。最稳妥的方案是:在CustomBottomBar的最外层不要使用会强制裁剪的Material或ClipRect,只使用Container + BoxDecoration。如果用了Material,记得显式设置clipBehavior: Clip.none。
另外,Scaffold的bottomNavigationBar区域本身不会裁剪超出部分,所以只要不在自定义组件内部引入裁剪,凸起按钮就能正常显示。
3.3 按钮的点击反馈不要只做颜色变化
仿闲鱼风格的按钮按压反馈,视觉上不只是换个阴影,而是整个圆钮轻微缩小,同时内部图标也保持居中。这个细节用AnimatedScale很容易实现,关键是时长和曲线。120 毫秒的easeOut比较接近原生手感,太短会显得生硬,太长又感觉拖沓。
还有一个细节容易被忽略:发布按钮的语义标签。中间按钮不是普通 tab,而是“发布”操作,在无障碍模式下应该提供明确的Semantics标签,否则读屏软件会把它读成一个无意义的 add 图标。
4. Tab 切换与页面联动:状态管理怎么做才不臃肿
4.1 用 ValueNotifier 管理索引,而不是全局 setState
导航栏的切换逻辑本身很简单,就是当前索引变化、页面跟着变化。但如果整个页面都调用setState,页面里所有子树都会重建,对复杂页面来说不划算。
我比较推荐用一个ValueNotifier<int>来管理当前索引,配合ValueListenableBuilder监听变化,这样只有真正依赖这个索引的组件才会重建。
final ValueNotifier<int> _tabIndex = ValueNotifier(0); void _onTabSelected(int index) { _tabIndex.value = index; }然后在Scaffold的body和bottomNavigationBar里,分别用ValueListenableBuilder包住需要响应变化的布局。这样,导航栏图标和页面内容可以各自独立更新,避免一改全改。
如果你的项目已经用了 Provider 或 Riverpod,直接用状态管理包里的监听也一样,核心思路是不要让Scaffold整棵子树都跟着 tab 变化而重建。
4.2 IndexedStack 与懒加载的取舍
页面切换部分,我用了IndexedStack。它会在第一次构建时把四个页面都 build 出来,然后通过index控制显示哪一个。好处是切换 tab 时页面状态不会丢失,比如你在第一个 tab 的列表里滑到了很靠下的位置,切到别的 tab 再切回来,滚动位置还在。
但它的代价是:所有子页面一开始都会被构建。如果某个 tab 页面里有地图、短视频播放器或者比较重的网络请求,提前构建会带来不必要的开销。
针对这个问题,我的处理方式是:轻量页面直接放进IndexedStack,重页面在内部自己做懒加载初始化,比如用FutureBuilder或者延迟到页面可见时才加载数据。对于简单项目,IndexedStack依然是性价比最高的方案;如果 tab 页面足够重,再考虑PageView配合AutomaticKeepAliveClientMixin,但那是另一个复杂度的故事了。
4.3 发布按钮不参与 tab 索引
需要特别注意的是,发布按钮点击后不应该改变_tabIndex。在参考应用里,点击发布按钮通常是打开发布页面,发布完成后回到原来的 tab,而不是把当前 tab 切到中间。
所以我在CustomBottomBar里把回调分成两路:onTabSelected处理四个常规 tab,onPublishPressed单独处理按钮点击。这样在逻辑上就把中间按钮和 tab 索引解耦了,后续做埋点、权限判断也方便。
void _openPublishPage() { // 打开独立发布页,不修改 _tabIndex Navigator.of(context).push( MaterialPageRoute(builder: (_) => PublishPage()), ); }5. 红点、角标与未读数量的更新细节
5.1 用 Stack 自己画角标,而不是依赖第三方组件
消息 tab 上需要展示未读数,常见处理是给Icon外面套一个Stack,用Positioned定位角标。Flutter 3.7 之后官方也提供了Badge组件,但我在项目里更喜欢自己画,原因是定制空间更大,比如需要展示99+、需要控制角标距离图标的偏移量、需要同时显示红点和数字角标两种形态,自己画不容易被组件版本捆住。
基本的数字角标实现如下:
Widget _buildMessageIcon() { return Stack( clipBehavior: Clip.none, children: [ Icon(Icons.message_outlined, size: 24, color: _normalColor), if (_unreadCount > 0) Positioned( right: -10, top: -8, child: Container( padding: EdgeInsets.symmetric(horizontal: 5, vertical: 1), constraints: BoxConstraints(minWidth: 16), decoration: BoxDecoration( color: Color(0xFFFF3B30), borderRadius: BorderRadius.circular(9), ), child: Text( _unreadCount > 99 ? '99+' : '$_unreadCount', style: TextStyle( color: Colors.white, fontSize: 10, height: 1.2, ), textAlign: TextAlign.center, ), ), ), ], ); }这里同样要设置clipBehavior: Clip.none,因为角标有一部分是在图标边界外的。
5.2 数字角标和纯红点应该是同一套逻辑
参考应用的角标有几种状态:无任何提醒、有未读消息且数量小于等于 99、数量超过 99、完全不展示数量只展示一个小红点。后一种常见于“你有新内容但没必要打扰用户”的场景。
我在实现时把它们收敛成一个枚举或者可空的int状态:
| 状态 | 展示形式 |
|---|---|
null或 0 | 不显示 |
| 1~99 | 显示数字角标 |
| 大于 99 | 显示99+ |
| 使用红点模式 | 显示 8 像素左右的小圆点 |
用可空int比用“0 表示隐藏”更安全,因为业务里可能会出现未读数确实为 0 但红点仍然要亮的情况,比如“已读但未处理”的状态。把角标状态建模成独立对象,后面接接口时能少踩很多坑。
5.3 角标数据与导航栏解耦,避免整栏重建
如果未读数量变化时,每次都调用setState重建整个CustomBottomBar,那么中间的发布按钮、四个 tab 图标全部会跟着重建。虽然 Flutter 重建开销不大,但代价是动画状态、按压缩放状态都被打断。
我建议把角标数量单独用一个ValueNotifier<int>或监听器管理,在CustomBottomBar内部只包住消息图标那一小块区域监听变化。这样未读数从 8 变成 9,整个导航栏不用重画一遍。
ValueListenableBuilder<int>( valueListenable: _unreadCountNotifier, builder: (context, count, child) { return _buildMessageIcon(count); }, )6. 真机适配与性能排查:这一轮最容易翻车
6.1 底部安全区与凸出部分的适配
iPhone 全面屏的底部有home indicator区域,Android 手机也有手势导航条,这些都会占据一段底部空间。MediaQuery.of(context).padding.bottom拿到的值通常就是安全区高度。
我的做法是:底部栏总体高度 = 56 + 安全区高度,四个普通 tab 的可视区域保持在安全区之上,中间按钮的凸出量以导航栏主体顶部为基准计算,而不是以整个组件顶部为基准。第一次写的时候,我偷懒直接让按钮top: -16,但在全面屏上按钮看起来会偏上,因为整个组件的高度含了安全区,视觉上按钮离文字区域更远了。
修正的方法是让按钮的Positioned位置相对主体顶部计算,或者直接把导航栏组件拆成两层:外层负责安全区,内层固定 56 高,按钮放在内层 Stack 里。
6.2 点击区域偏移:为什么会出现“点不到”和“误触”
自定义Stack导航栏最容易出现的问题是点击区域和视觉区域不一致。我调试时碰到过一次:按钮视觉上在正确位置,但手指按下去时,顶部一小块区域没反应。
原因是我把GestureDetector包在了AnimatedScale外面,而按钮上半部分在父组件边界之外。某些情况下,Flutter 对超出组件边界的 hit test 会失效。解决办法有两个:一是把CustomBottomBar的外层高度多留出 20 像素的富余,让按钮完全落在组件实际区域内;二是给GestureDetector设置behavior: HitTestBehavior.opaque,并确保Stack的边界不要裁剪。
我最终选了第一种方案:导航栏外层高度按56 + 16(凸出富余) + 安全区高度计算,背景容器再单独对齐到底部 56 高度。这样既保证按钮绘制不被裁剪,也保证点击区域完整。
6.3 阴影、离屏渲染与切换卡顿
中间按钮的阴影、底部栏的阴影,以及角标上的颜色,看起来是小细节,但在低端 Android 设备上,过多的阴影叠加会导致掉帧。尤其是带透明度的阴影,在列表滚动时会频繁触发重绘。
我做过一轮优化,整体效果比较明显:
- 底部栏阴影的模糊半径从 16 降到 10,颜色透明度从 0.08 降到 0.05。
- 发布按钮的阴影只在空闲状态绘制,按压缩放时阴影透明度进一步降低,避免动画期间双重重绘。
- 四个 tab 的图标和文字选中色用
AnimatedSwitcher或AnimatedDefaultTextStyle做过渡,不要每次切换都生成新的渐变。
另外,如果发布了页面本身很重,建议打开 Flutter DevTools 的 Performance 页面,观察导航栏切换时是否有大的 build 时间。多数情况下,问题都出在setState范围太大,而不是 Flutter 本身性能不够。
7. 沉淀下来的实现清单与个人体会
7.1 一套可以直接上手的结构清单
如果你也想实现一个类似的底部导航栏,下面这份清单是我最终沉淀下来的实现顺序,照着做基本不会走偏:
- 先把设计稿尺寸量化:底部栏高度、按钮直径、凸出量、安全区处理方式。
- 自定义
CustomBottomBar,外层用Container,内层用Stack(Clip.none)。 Row排四个普通 tab,中间用SizedBox(width: 按钮直径)占位。Positioned定位中间按钮,按钮用GestureDetector + AnimatedScale实现按压反馈。- 导航栏外层高度留出凸出富余,保证点击区域完整。
- 用
ValueNotifier管理 tab 索引,发布按钮走独立的回调。 - 角标用
Stack + Positioned绘制,用独立的ValueNotifier管理未读数。 - 处理好
MediaQuery.padding.bottom,适配全面屏。
7.2 几个值得记住的取舍
有三个选择,如果你后面自己实现,可能会遇到同样的纠结。
第一个是BottomAppBar方案。如果你只是想要一个简单的中心凸起按钮,不追求完全还原参考应用的细节,BottomAppBar + FloatingActionButton确实更省事。但一旦你开始调整按压反馈、角标、阴影,就会发现自定义方案的前期成本很快就被“好改”两个字吃回来了。
第二个是IndexedStack。如果你四个 tab 页面都很轻,无脑用没问题;如果有一个页面很重,一定要在页面内部做懒加载,不要把压力都留给IndexedStack的首次构建。
第三个是状态管理。我见过有人为了一个导航栏索引就引入一整套状态管理库,其实没必要。ValueNotifier这种轻量方案在这种场景下足够干净,也容易测试。
7.3 后续还能扩展的方向
导航栏做完之后,我还做了一轮小扩展:把四个 tab 的选中态从单色改成带渐变的图标背景,把发布按钮的点击动画从单纯缩放改成带轻微旋转的“加号”展开效果。这些都是在现有结构上做增量,不需要推翻重写。
如果你后续要做暗黑模式,只需要把颜色配置抽到主题层,CustomBottomBar内部不写死颜色,整个导航栏就能自动适配。这个扩展在自定义方案里很容易做,但在第三方包里往往要绕很远。
我做这类组件的心得是:底部导航栏是整个 App 里使用频率最高、但功能又相对简单的组件。它值得你多花半天时间做得更可控,而不是为了省事去迁就一个不太好扩展的现成组件。把尺寸逻辑、点击逻辑、状态逻辑拆干净之后,后面加需求、改样式,都会顺畅很多。