从一个改版需求说起:设计稿给的底部导航中间凸起一个圆形按钮、选中项是带弧度的胶囊背景、图标还会跟着手指做位移,四个入口的宽度还不完全相等。很多人的第一反应是把TabBar的indicator换成BoxDecoration,再调indicatorSize、indicatorPadding,折腾一晚上发现中间那个凸起怎么都做不出来。我后来放弃了在原生TabBar上继续加补丁,改成直接手写一个 Flutter 自定义 TabBar,把"样式"这件事从框架手里拿回来。这篇文章就把这套做法完整拆开:原生组件卡在哪、自绘底栏该怎么分层、凸起和弧形缺口怎么画、和页面容器怎么配合、真机上会踩哪些坑。读完之后,你手上会有一个能塞进任意设计稿的底栏组件,而不是一堆只能用在某个页面里的临时补丁。适合写过一两个 Flutter 项目、想彻底搞明白布局与手势的中级开发者,也适合刚接触 Flutter、想找一份能抄的完整代码的新手。
1. 设计稿给了一个"不像 TabBar"的底栏,原生组件先卡在哪
我们先把话说清楚:Flutter 官方的TabBar不是不能改样式。它的indicator接受任意Decoration,labelStyle、unselectedLabelStyle、labelColor、iconTheme全都能替换,甚至Tab里塞一个Column加自定义组件也完全可行。问题不在"能不能改",而在改的性价比。一旦切到非标准形态——中间凸起、选中项浮起、指示器是一个跟随位移的圆点、四个入口宽度 1:1.2:1.2:1——你会发现自己是在跟组件的三条内置假设搏斗,而不是在写业务。所以这一章先不动手,只做一件事:把原生TabBar的三条硬约束摆出来,让你判断哪些需求值得绕,哪些干脆换一条路。
1.1 指示器的位置是算出来的,不是你画的
TabBar的指示器不是独立的一层 Widget,它是TabBar内部根据tab数量、indicatorWeight、indicatorPadding、indicatorSize这套参数现算出来的一段绘制。indicatorSize只有TabBarIndicatorSize.tab(等于单个 tab 的宽度)和label(等于文字宽度)两个枚举值,没有第三种。
这意味着什么?如果你想要一个宽度是 tab 宽度的 60%、并且带圆角胶囊形状的选中背景,用IndicatorSize.tab配indicatorPadding左右各留 20% 的内边距,是能凑出来的。但一旦选中项的位置不是等分关系——比如中间那个 tab 因为凸起按钮占了更宽的位置——指示器的计算基准就崩了,因为你没法告诉TabBar"第 3 个 tab 的槽位比其他三个宽 1.4 倍"。
我试过用indicatorPadding传入一个EdgeInsets,让它在不同索引下给出不同的内边距,结果是在切换动画过程中左右内边距是同一个值,动画会"跳"。这条路的天花板就在这里,不是技巧问题,是设计问题。
1.2 等宽等高的默认约定,把不规则样式挤死了
TabBar内部用的是一个Flex布局,每个 tab 通过Expanded均分宽度。这一条几乎封死了所有"不规则"的形态:你想让某个入口宽一点,只能在外面套Flexible改 flex 比例,但TabBar并没有暴露控制每个 tab flex 的入口。
更要命的是高度。TabBar的高度是由内容撑起来的,加上Tab自身的height属性(默认 46)。如果你的设计稿是"选中项整体向上浮 6dp,并带一圈柔和阴影",那不是内容撑高度的问题,而是需要子元素突破父容器的边界。TabBar内部的布局不会给你这个空间,因为它自己就在一个受约束的盒子里。
我用一个很土但很有效的办法验证过这件事:给TabBar外面套一个SizedBox(height: 80),然后在tab里塞一个高 70 的Container,跑起来直接一条黄黑相间的溢出警告。这不是写法问题,是"父级给的高度"和"子级想占的高度"在打架。
1.3 高度由外部决定,内部想"长高"就会溢出
第三条约束最隐蔽:TabBar的绘制区域和它的点击区域是绑定的。凸起按钮这种"半截露在底栏上方、半截在底栏里"的形态,本质上是两个不同层级的东西拼在一起,而TabBar的层级是平的。
我见过的几种绕法,各有各的代价:
| 绕法 | 做法 | 代价 |
|---|---|---|
叠一个Stack | 把凸起按钮画在TabBar上方 | 凸起部分的点击事件要单独处理,容易和下方 tab 重叠 |
用BottomAppBar+FloatingActionButton | 官方就有notchMargin参数 | 只能做中间一个圆形,样式自由度极低 |
| 隐藏中间 tab,留空位 | 用Tab占位但内容为空 | 指示器计算仍然按等分,选中态错位 |
| 自己画 | 完全接管布局与绘制 | 需要理解手势、裁剪、动画,工作量大 |
表格里最后一行就是我最终选的。看起来工作量最大,但实际做下来,一旦你把底栏拆成三层,代码量并没有比"在TabBar上打补丁"多多少,而且后续任何新设计稿都能接住。接下来第二章就把这个拆分讲清楚。
2. 把底栏拆成容器层、指示器层、单元层再看
刚上手写自定义底栏的人,最常见的写法是在一个Container里放一个Row,每个子项用GestureDetector包一层,选中时把颜色改掉。这个写法能跑,但做到第二版设计稿就会崩——因为指示器一旦需要"滑动过去",你就得在Row外面再加一层Stack,然后手算每个槽位的偏移量,算错一个像素就会看到指示器和文字错位。
正确的做法是先把底栏在脑子里切成三层,每层职责单一,层与层之间只通过索引通信。这三层分别是:容器层(决定高度、背景、底部安全区、阴影)、指示器层(决定选中态的图形和它的位移动画)、单元层(决定图标、文字、角标的排版和点击热区)。分层之后,你会发现"任意样式"这个词其实是可控的——因为每一层都能独立替换,互不影响。
2.1 容器层:谁决定背景、高度和底部安全区
容器层要回答三个问题:底栏多高、背景长什么样、底部手势条要不要留白。
高度我建议由外部传入,而不是让内容撑。原因很实在:底栏高度会直接影响页面内容的可用高度,如果它随内容浮动,你在做页面布局时就没法预估body的剩余空间,滚动区域会出现"有时能滚、有时不能"的诡异现象。把高度定死成参数,页面那边就能用一个常量做padding,逻辑清晰。
底部安全区是另一个高频踩坑点。MediaQuery.viewPaddingOf(context).bottom拿到的是系统手势条的高度,在全面屏上通常是 34 左右。这个值必须加在底栏内部,而不是让页面自己加,否则底栏背景色会在手势条区域断开一截,视觉上非常难看。写法上不要用SafeArea直接包整个底栏——SafeArea会把背景色也被"推"上去,正确姿势是让容器自己撑满到屏幕底部,只在内边距里让出这块高度:
final safeBottom = MediaQuery.viewPaddingOf(context).bottom; Container( padding: EdgeInsets.only(bottom: safeBottom), decoration: BoxDecoration( color: Colors.white, boxShadow: const [ BoxShadow(color: Color(0x14000000), blurRadius: 12, offset: Offset(0, -2)), ], ), child: SizedBox(height: barHeight, child: ...), )这段代码里Container的背景会一直铺到屏幕最底部,而内容区被SizedBox限制了高度,视觉上就是"一块白底 + 里面一行图标"。阴影的方向用Offset(0, -2),也就是往上投,符合底栏的物理语义。
2.2 指示器层:用 Alignment 做插值,比手算 offset 稳
指示器的核心是"位置随索引变化,并且有过渡动画"。手算 offset 的写法大概是left = index * slotWidth,听起来简单,但一旦遇到等分以外的情况(比如首尾有额外留白),公式就要改,改完还要处理动画插值,很容易出错。
更稳的方案是用Alignment。Alignment的 x 值从 -1(最左)到 1(最右),中间是 0。对于 n 个等分槽位,第 i 个槽位对应的 alignment.x 是:
x = -1 + 2 * i / (n - 1)把Alignment交给AnimatedAlign,位置变化就自带补间动画,你完全不用关心像素。代码骨架是这样:
Positioned.fill( child: AnimatedAlign( alignment: Alignment(-1 + 2 * index / (count - 1), 0), duration: const Duration(milliseconds: 240), curve: Curves.easeOutCubic, child: SizedBox( width: slotWidth, height: barHeight, child: indicatorBuilder(context, index, true), ), ), )这里有个关键细节:AnimatedAlign必须放在Positioned.fill里。原因是Align系列组件在宽松约束下会收缩到子组件大小,如果它自己没撑满,内部就没有"可对齐的空间",alignment参数等于白设。我第一次写的时候就漏了这层Positioned.fill,结果指示器纹丝不动,排查了半小时才发现是约束问题。
count == 1时这个公式会除零,所以外面要加一层count > 1的判断。
2.3 单元层:文字、图标、角标怎么排才不打架
单元层看起来最简单,其实是最容易出细节问题的地方。
图标和文字的垂直排列,用Column加mainAxisAlignment: MainAxisAlignment.center就行,但间距不要用SizedBox(height: 2)硬写——不同字号下 2dp 的视觉感受完全不同。我的做法是把字号和间距绑定成一个比例,比如间距 = 字号 × 0.18。这样当系统字体放大时,视觉关系不会崩。
角标(红点或数字)建议用Stack定位在图标右上角,而不是塞进Column。原因很直接:Column会参与主轴计算,角标一出现就会把图标往下推,整个 tab 的内容会跳一下。用Stack加Positioned,角标是浮层,不占布局位置。
点击热区是另一个要点。GestureDetector默认的behavior是deferToChild,意思是只有子组件命中的区域才触发手势。如果你的 tab 内容只占槽位高度的一半(比如图标小、文字短),点到空白处就没反应。解决办法是显式指定behavior: HitTestBehavior.opaque,让整个Expanded区域都可点:
Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => onTap(i), child: itemBuilder(context, items[i], i, i == index), ), )HitTestBehavior.opaque的语义是"我这个区域参与命中测试,并且会阻挡下面的事件"。代价是它会吃掉下层的事件,所以如果底栏上有浮层(比如弹出菜单),要注意层级顺序。
三层拆完之后,代码结构已经清楚了。接下来是把它们拼成一个真正可用的组件。
3. 手写一个参数干净、能吃任意样式的自定义底栏
我要强调一个观点:自定义组件的质量,八成取决于接口设计,两成才是实现。见过太多组件,参数列表拉下来二十多个,showIcon、showLabel、iconSize、labelSize、activeColor、inactiveColor、badgeColor……每接一个新设计稿就要加两个参数,最后没人愿意维护。
正确的思路是把"变化的部分"抽象成回调,让调用方自己决定长什么样。组件只负责三件不变的事:布局分配、位移动画、点击分发。剩下的交给外部。
3.1 接口设计:为什么用回调而不是堆二十个参数
先看数据模型。一个 tab 至少需要这四样东西:文字、图标、选中态图标、可选角标。用一个小类承载:
class XTabItem { const XTabItem({ required this.label, required this.icon, this.activeIcon, this.badge, }); final String label; final Widget icon; final Widget? activeIcon; final String? badge; }再看组件签名。我只有六个参数:
class XTabBar extends StatelessWidget { const XTabBar({ super.key, required this.items, required this.currentIndex, required this.onTap, this.barHeight = 58, this.itemBuilder, this.indicatorBuilder, this.background, this.animationDuration = const Duration(milliseconds: 240), }); // ... }items是数据,currentIndex是状态,onTap是回调,这三个是必需的。barHeight和animationDuration是布局节奏。剩下三个是样式出口:itemBuilder决定每个单元长什么样,indicatorBuilder决定选中图形长什么样,background决定底栏背景长什么样。
为什么不用参数堆?因为参数是有限的,设计稿是无限的。今天要胶囊,明天要圆点,后天要一个带描边的方块,用参数你永远追不上。用 builder,调用方想画什么画什么,组件本身一行都不用改。这就是"任意样式"这四个字的真正含义。
3.2 完整实现与关键行说明
把前面三层的结论落成代码,核心的build方法是这样:
@override Widget build(BuildContext context) { final count = items.length; final safeBottom = MediaQuery.viewPaddingOf(context).bottom; return Container( padding: EdgeInsets.only(bottom: safeBottom), decoration: const BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow(color: Color(0x14000000), blurRadius: 12, offset: Offset(0, -2)), ], ), child: SizedBox( height: barHeight, child: LayoutBuilder( builder: (context, constraints) { final slotWidth = constraints.maxWidth / count; return Stack( children: [ if (background != null) Positioned.fill(child: background!), if (indicatorBuilder != null && count > 1) Positioned.fill( child: AnimatedAlign( alignment: Alignment(-1 + 2 * currentIndex / (count - 1), 0), duration: animationDuration, curve: Curves.easeOutCubic, child: SizedBox( width: slotWidth, height: barHeight, child: indicatorBuilder!(context, currentIndex, true), ), ), ), Row( children: [ for (var i = 0; i < count; i++) Expanded( child: Semantics( selected: i == currentIndex, button: true, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => onTap(i), child: itemBuilder?.call(context, items[i], i, i == currentIndex) ?? _DefaultItem(item: items[i], active: i == currentIndex), ), ), ), ], ), ], ); }, ), ), ); }几行值得单独说:
LayoutBuilder在这里的作用是拿到实际可用宽度,算出slotWidth,给指示器一个确定的宽度。不用LayoutBuilder也能用FractionallySizedBox(widthFactor: 1 / count)替代,但那个方案在需要"指示器比槽位窄一点"的时候就不好调了,不如直接拿宽度自己算。
Stack里的顺序很关键:background在最底,指示器在中间,Row单元在最上。如果把指示器放最后,它会盖住图标和文字,视觉上就是一团色块压在图标上。
Semantics我一开始没加,后来做无障碍测试才发现问题。加上selected和button之后,读屏软件会念成"首页,第 1 个,共 4 个,已选中",体验完全不同。这个成本极低,但漏掉的人特别多。
3.3 两种布局模式:等分与内容自适应
等分模式就是上面这段代码,每个槽位宽度相同。但有些场景你需要内容自适应——比如底部只有两个入口,设计稿要求它们居中、各自宽度随文字长度变化、总宽度不铺满屏幕。
这时候Row的Expanded就不合适了,改成MainAxisAlignment.center加Flexible:
Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var i = 0; i < count; i++) Flexible( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => onTap(i), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: itemBuilder!(context, items[i], i, i == currentIndex), ), ), ), ], )注意自适应模式下指示器的位置算法就失效了,因为槽位宽度不再相等。这种情况下我的建议是指示器交给单元自己画——在itemBuilder里判断active,给激活项包一层带背景的Container,用AnimatedContainer做颜色过渡。视觉上效果一样,但逻辑简单得多,不需要外部层去算位置。
这个取舍很重要:指示器外置适合等分、需要"滑动"效果的场景;指示器内置适合宽度不定、不需要滑动的场景。两种都别硬套。
4. 凸起按钮与弧形缺口:不规则外形的三种画法
回到最开始的那个需求:中间凸起一个圆形按钮。这类"底栏长出了底栏之外"的形态,本质上是层级问题和裁剪问题的组合。我试过三种做法,都能跑,但适用场景不同。
4.1 凸起按钮的本质是 Stack 的负边距溢出
第一种做法最直接:底栏外面再套一个Stack,clipBehavior: Clip.none,然后把圆形按钮用Positioned定位到上方,top给一个负值。
Stack( clipBehavior: Clip.none, children: [ XTabBar( items: items, currentIndex: index, onTap: onTap, // 中间那一位在 itemBuilder 里返回空盒子占位 ), Positioned( top: -26, left: 0, right: 0, child: Center( child: _CenterButton(onTap: () => onTap(2)), ), ), ], )这里有三个关键点。
clipBehavior: Clip.none必须加。Stack默认是Clip.hardEdge,超出的部分会被裁掉,你会看到圆形按钮只有下半截。
中间的 tab 要在itemBuilder里返回一个透明的占位盒子,宽度正常参与等分,否则按钮和槽位对不齐。
Positioned的top是负值,这个数值要等于"按钮高度的一半减去按钮压进底栏的深度"。比如按钮直径 52,压进底栏 26,那top就是-26。
这个方案的优点是简单,缺点也明显:凸起部分的点击区域和底栏不在同一层,如果底栏上有其他浮层,层级关系会变得很绕。而且当底栏有阴影时,按钮的阴影和底栏的阴影会叠在一起,视觉上会出现一条硬边。
4.2 弧形缺口与阴影:CustomPainter 的两处细节
第二种做法是把弧形缺口画进底栏背景里,按钮单独用Stack叠上去。这样阴影是整体的,不会有硬边。
画缺口用CustomPainter,路径大致是:从左到右走直线,走到中间区域开始向下凹一个弧,再走回来,然后沿底边闭合。
class NotchPainter extends CustomPainter { NotchPainter({ required this.color, required this.notchRadius, required this.notchDepth, required this.shadowColor, }); final Color color; final double notchRadius; final double notchDepth; final Color shadowColor; @override void paint(Canvas canvas, Size size) { final w = size.width; final h = size.height; final cx = w / 2; final r = notchRadius; final path = Path() ..moveTo(0, 0) ..lineTo(cx - r * 1.6, 0) ..cubicTo(cx - r * 0.8, 0, cx - r * 0.8, notchDepth, cx, notchDepth) ..cubicTo(cx + r * 0.8, notchDepth, cx + r * 0.8, 0, cx + r * 1.6, 0) ..lineTo(w, 0) ..lineTo(w, h) ..lineTo(0, h) ..close(); canvas.drawShadow(path, shadowColor, 6, false); canvas.drawPath(path, Paint()..color = color..isAntiAlias = true); } @override bool shouldRepaint(NotchPainter old) => old.color != color || old.notchRadius != notchRadius || old.notchDepth != notchDepth; }两处细节值得说。
一是用cubicTo而不是quadraticBezierTo。二次贝塞尔只有一个控制点,弧线在两端和直线的衔接处过渡会很生硬,视觉上像被刀切了一下。三次贝塞尔给两个控制点,可以让弧线在起点和终点都是水平切线,衔接自然。
二是阴影用canvas.drawShadow而不是BoxDecoration的boxShadow。BoxShadow是按矩形算的,画在自定义路径上会显示出矩形的边。drawShadow接受Path,沿着路径轮廓投阴影,效果才对。
shouldRepaint里比较了三个字段。这里不能直接return true,因为自定义绘制在每帧都会调用,如果每次都重绘,长列表滚动时会有明显的掉帧感。
4.3 裁剪与手势:ClipPath 吃掉点击的典型场景
第三种做法是给底栏套ClipPath,让整个底栏的形状就是带缺口的形状。视觉最统一,但坑也最多。
ClipPath裁剪的是绘制和命中测试两个东西。也就是说,被裁掉的部分既看不到也点不到。如果你的凸起按钮压在缺口里,而缺口区域在裁剪路径之外,那按钮上半部分的点击就会失效。
我遇到的具体现象是:按钮看起来在,但只有下半部分能点,上半部分点下去毫无反应。排查了半天才反应过来是裁剪的问题。
解决办法有两个。一是把按钮放到ClipPath外面,用Stack叠上去,让按钮自己不受裁剪影响;二是给ClipPath加clipBehavior: Clip.antiAlias,同时确认按钮的命中区域在裁剪路径之内。第一种更省事,我一般用第一种。
另外提一句,ClipPath的裁剪是有性能成本的,尤其是在有动画的时候。如果底栏形状是静态的,问题不大;如果缺口位置会跟着动画移动(比如按钮随选中项滑动),每帧都要重建裁剪路径,低端机上能明显看出卡顿。这种情况建议用CustomPainter直接画,绕开裁剪。
5. 底栏不是孤立的:页面容器与切换状态怎么配合
底栏写完了,接下来是它和页面的关系。这里有一个非常常见的错误:把索引状态在底栏组件和页面容器里各存一份,然后互相setState同步。代码跑起来没问题,但一旦有外部跳转(比如从通知点进来直接落到第 3 个 tab),两份状态就会打架,出现"底栏选中的是第 3 个,页面显示的是第 1 个"。
正确的做法是索引只有一个来源。要么由底栏自己持有,通过回调通知外部;要么由外层持有,底栏只做受控组件。我倾向于后者,因为页面切换、埋点、权限拦截这些逻辑都在外层,状态放外层更好管。
5.1 IndexedStack、PageView、懒加载三种容器方案对比
页面容器有三种常见选择,各有明确的适用场景:
| 方案 | 状态保持 | 切换动画 | 内存占用 | 适用场景 |
|---|---|---|---|---|
IndexedStack | 全部保持 | 无 | 全部页面常驻 | 页面少、切回要保留滚动位置 |
PageView+NeverScrollableScrollPhysics | 按需 | 可加滑动 | 相邻页面常驻 | 需要左右滑动手势 |
| 每次重建 body | 不保持 | 靠路由动画 | 最低 | 页面重、不要求保状态 |
IndexedStack是最省心的选择,它把所有子页面都建出来,只显示当前索引对应的那个。好处是切回来时滚动位置、输入框内容全都还在;坏处是首帧会把所有页面都 build 一遍,页面多的时候启动明显变慢。
PageView的方案我在需要左右滑动切换的场景用得多。记得把physics设成NeverScrollableScrollPhysics,否则用户横滑会同时触发页面切换和页面内的横向列表,手势冲突很难调。
懒加载是第三种,思路是第一次访问某个索引时才把它加进IndexedStack的children里,没访问过的用SizedBox.shrink()占位。这样既有状态保持,又不会首帧全建。代价是需要维护一个"已访问索引"的集合,代码稍微多几行。
5.2 索引状态该由谁持有:TabController 还是自己
如果你用了TabBar配套的TabBarView,那TabController是绑定的。但自绘底栏其实不需要TabController,因为它只关心一个int。
我的建议是:自绘底栏配自管理索引,别引入TabController。TabController的价值在于它把动画进度也管了,而你自绘的指示器用的是AnimatedAlign,不需要外部提供动画进度。引入它反而要多写一个TickerProviderStateMixin,还要处理indexIsChanging这个容易误解的状态。
如果确实需要TabController(比如页面里还有二级TabBar),那就把它当成唯一状态源,底栏的currentIndex直接取controller.index,并在controller的监听里setState。注意监听回调里要判断indexIsChanging,否则滑动过程中会触发大量无意义的重建:
@override void initState() { super.initState(); _controller = TabController(length: 4, vsync: this)..addListener(_onTabChanged); } void _onTabChanged() { if (_controller.indexIsChanging) return; if (_controller.index != _index) { setState(() => _index = _controller.index); } }这段判断很关键。indexIsChanging为true表示控制器正在执行切换动画,index已经更新但页面还没切完。这时候如果直接setState,指示器动画会和页面动画打架,看起来一顿一顿的。
6. 真机上跑出来的五个坑与对应修法
模拟器上跑得通,不代表真机没问题。下面这五个坑我都在实际项目里撞过,每一个都花了不止一小时。
6.1 系统字体放大把固定高度撑破
固定高度的SizedBox加上会随系统字号缩放的Text,是溢出警告的高发组合。用户在系统设置里把字体调到 1.5 倍,你的 58 高底栏瞬间不够用,图标和文字挤成一团,还会顶出一条黄黑警告。
修法有两种。一种是把文字缩放钳制在一个合理区间,Flutter 3.16 之后有现成 API:
MediaQuery.withClampedTextScaling( minScaleFactor: 1.0, maxScaleFactor: 1.3, child: tabBar, )另一种是让底栏高度跟着字号走,也就是高度 = 基础高度 × 缩放系数,但设一个上限。我一般用第一种,因为底栏这种全局组件,视觉一致性比"尊重用户所有设置"更重要。1.3 倍基本能覆盖大部分需求,再大就该考虑换成侧边抽屉了。
6.2 底部手势条与 SafeArea 的重复留白
前面说过底栏自己要处理viewPadding.bottom。但如果你在页面外层已经套过一次SafeArea,两处留白会叠加,底栏下面凭空多出一条 34dp 的白带。
排查方法很简单:打开"显示布局边界"(开发者选项里),看底栏的容器边界是不是比内容高出明显一截。如果是,往上找SafeArea。
原则是:底部留白只在一个地方处理。要么外层的Scaffold处理,要么底栏自己处理。我倾向于底栏自己处理,因为底栏可能被用在多个页面里,让每个页面都记得处理,早晚会漏。
6.3 无障碍朗读不出"选中"
这个问题在开发阶段几乎发现不了,上线后被用户反馈才知道。读屏软件读底栏时只念"首页",不念"已选中",用户根本不知道自己现在在哪一页。
修法就是前面代码里那个Semantics。除了selected和button,如果角标有数字,还要加label把数字念出来,否则读屏会跳过它。
顺带提一个相关的点:GestureDetector的点击区域如果小于 48×48,无障碍扫描会报"触摸目标过小"。底栏的槽位一般都够大,但如果你的设计稿是"图标很小、间距很大",要注意用SizedBox把热区撑到最小尺寸,别让可点区域跟着图标缩。
6.4 AnimatedBuilder 挂错位置导致每帧重建
指示器动画如果用AnimatedBuilder监听一个AnimationController,一定要注意它包住的子树范围。包住整个页面,动画期间整棵树每帧重建,低端机上直接掉到 30 帧。
正确的写法是只包住真正需要变的部分:
AnimatedBuilder( animation: _controller, builder: (context, child) { return CustomPaint( painter: IndicatorPainter(progress: _controller.value), ); }, child: const SizedBox.shrink(), )child参数是专门用来做这个优化的——它会被预先构建好,builder每次调用时直接复用,不参与重建。这个参数很多人不用,但它是AnimatedBuilder存在的意义之一。
另外,如果动画只影响颜色或透明度,用AnimatedContainer或TweenAnimationBuilder比手动管AnimationController省事得多,也不用担心dispose。
6.5 阿拉伯语环境下位置全反
这个坑比较小众,但如果你的应用有多语言需求,迟早会遇到。Alignment(-1, 0)在 RTL 环境下依然是"物理左侧",而 RTL 语言里第一个 tab 应该在右边。结果就是指示器跑到了错误的一边,和选中的 tab 错位。
修法是把Alignment换成AlignmentDirectional,它的start会跟着文字方向自动翻转:
AnimatedAlign( alignment: AlignmentDirectional(-1 + 2 * index / (count - 1), 0), // ... )同理,Positioned里的left、right也要换成start、end,边距用EdgeInsetsDirectional。这些东西写的时候多花两分钟,后面省下的是整轮回归测试。
7. 选型与封装的个人经验
写到这里,这套方案我在三个项目里用过,中间改过两次接口。最后分享几条不太能从文档里查到的心得。
别急着抽象。第一版我试图把"凸起按钮"也做成组件的内置能力,参数是centerButton加notchDepth。结果第二个项目要的是左边第二个凸起,第三个项目要的是凹进去。每次都得改组件。后来我把凸起改成外部用Stack叠,组件本身完全不知道有这回事,反而什么需求都能接。组件只做它有把握做对的事,剩下的交出去,这个原则在 UI 组件上尤其成立。
尺寸别写死。底栏高度、图标尺寸、圆角半径,全部走参数,哪怕调用方暂时不传。这不是为了"预留扩展性",而是为了做多端适配时能一处改全局。我吃过一次亏:底栏高度写死 56,结果平板端设计稿要 72,翻遍代码发现这个数字散落在七个文件里。
测试要覆盖"快速连点"。自绘底栏的指示器有 240ms 的动画,快速连点不同 tab 时,AnimatedAlign会从当前位置重新出发,行为是正确的,但如果你在外层用setState同步页面,页面切换会跟着排队,出现"点了三下,页面切了三次"的滞后感。解决方法是给页面切换也加一个短过渡,或者用IndexedStack直接切,让视觉反馈跟上手指。
最后一点,如果你的底栏只有两三个入口、样式也很普通,真的没必要自绘。原生TabBar配一个BoxDecoration指示器,二十分钟就能搞定,维护成本也低。自绘的价值在于"设计稿真的不常规",而不是"我想掌控一切"。
这套代码我一般会放在项目的widgets/tabbar/目录下,拆成三个文件:x_tab_item.dart放数据模型,x_tab_bar.dart放组件主体,x_tab_bar_painters.dart放自定义绘制。这样调用方只需要 import 第一个和第二个,绘制细节对业务层完全透明。用的时候一行就够:
XTabBar( items: [ const XTabItem(label: '首页', icon: Icon(Icons.home_outlined), activeIcon: Icon(Icons.home)), const XTabItem(label: '发现', icon: Icon(Icons.explore_outlined), activeIcon: Icon(Icons.explore)), const XTabItem(label: '消息', icon: Icon(Icons.chat_outlined), activeIcon: Icon(Icons.chat), badge: '3'), const XTabItem(label: '我的', icon: Icon(Icons.person_outline), activeIcon: Icon(Icons.person)), ], currentIndex: _index, onTap: (i) => setState(() => _index = i), indicatorBuilder: (context, index, active) => Container( margin: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), decoration: BoxDecoration( color: const Color(0xFF2F6BFF).withOpacity(0.10), borderRadius: BorderRadius.circular(14), ), ), )这段代码替换掉indicatorBuilder就能变成圆点、变成下划线、变成无指示器,业务代码一个字都不用改。我现在接新设计稿,基本是先看图,改indicatorBuilder和itemBuilder两个回调,跑起来看一眼,二十分钟收工。