Flutter 两个反直觉布局坑:ListTile 水波纹 / VerticalDivider 踩坑实录

📅 2026/8/9 20:10:46
Flutter 两个反直觉布局坑:ListTile 水波纹 / VerticalDivider 踩坑实录
Flutter 两个反直觉布局坑ListTile 水波纹 / VerticalDivider 踩坑实录各位看官好。说实话Flutter 里最让我难受的不是报错是不报错。报错好办复制粘贴一搜十有八九能找到答案。最气人的是那种代码看着完全正确、analyzer 干干净净、跑起来视觉就是不对的坑。你盯着代码看半天怀疑人生最后发现是某个 Widget 的层级关系没摆对。这篇就记两个我实打实栽过的一个是卡片式ListTile点下去没有水波纹一个是**VerticalDivider放进Row里死活不显示**。这俩坑有个共同点——它们都是约束和层级的问题都跟你写的业务逻辑一毛钱关系没有而且都得真机跑起来才看得见。一、坑一ListTile 的水波纹被 Container 给吃了我原来的写法做设置页、列表页白底圆角卡片 一行 ListTile是个特别常见的组合。我当时顺手就这么写了// ❌ 看着没毛病实际有问题Container(margin:constEdgeInsets.all(8),decoration:BoxDecoration(color:Colors.white,borderRadius:BorderRadius.circular(10),),child:constListTile(title:Text(设置项)),);卡片是出来了圆角也有了白底也有了。但点下去——没有水波纹。手指按上去屏幕跟死了一样完全没有我点到了的反馈。而且控制台还会给你甩一条大意如下的提示ListTile background color or ink splashes may be invisible为什么这里得说说 Flutter 的水波纹Ink splash是怎么画的。ListTile也好、InkWell也好水波纹并不是画在它们自己身上的而是画在离它最近的那个Material祖先身上。你可以理解成Material 是一块画布水波纹这层墨水是泼在这块画布上的。那问题来了。我上面那个Container带了decoration它内部其实会生成一个DecoratedBox而这个DecoratedBox在绘制顺序上是盖在 Material 这块画布上面的。于是就出现了这么个局面墨水确实泼出去了泼在下面那层 Material 上了但被我这个不透明的白色DecoratedBox严严实实地挡住了。水波纹画了你看不见。Flutter 觉得这大概率不是你想要的效果就好心给你提示了一下。说实话这个提示挺善良的只是我当时没往层级上想还以为是ListTile需要额外开什么开关。修法让 Material 自己去当那张卡片思路很直白既然是 decoration 挡住了 Material那就别用 decoration 画背景让 Material 自己把背景和圆角一起画了。// ✅ 正确Material 自己出背景 圆角Container(margin:constEdgeInsets.all(8),// Container 只管外边距不碰 decorationchild:Material(type:MaterialType.card,color:Colors.white,elevation:0,// 圆角交给 Material水波纹会跟着这个 shape 一起裁剪shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10)),clipBehavior:Clip.antiAlias,child:constListTile(title:Text(设置项)),),);这么一改水波纹立马就回来了而且是带圆角裁剪的——shape给了 Material水波纹扩散到边界就被切住不会溢出圆角变成个方块。这个细节挺重要我见过不少人用ClipRRect在外面硬裁效果是能出来但多套一层裁剪性能上不太划算。顺带说几个等价或相关的写法各位看官按场景挑直接用CardCard本身就是Material的封装懒得配就用它记得设clipBehavior: Clip.antiAlias。想保留decoration的写法比如要渐变背景、要边框把Container(decoration:)换成Ink(decoration:)。Ink这个 Widget 就是专门为了解决这个问题存在的——它会把 decoration 画到 Material 那层上去水波纹就能盖在它上面了。如果只是要个可点击区域不需要ListTile那套布局MaterialInkWell组合更轻。提醒一句Ink也有它的脾气它要求自己必须在Material里面而且不能给它设width/height之外的奇怪约束否则一样会 assert。这块各位看官用的时候留个心眼。二、坑二VerticalDivider 在 Row 里死活不显示我原来的写法需求也简单一行里放两块内容中间来根竖线隔开。我理所当然地这么写// ❌ 经常什么都看不见Row(children:const[Expanded(child:Text(A)),VerticalDivider(),// 说好的竖线呢Expanded(child:Text(B)),],)跑起来A 和 B 都在中间那根线根本不存在。我第一反应是颜色问题把color手动设成红色还是没有。再把thickness调到 5依然没有。这时候我才意识到它不是看不清它是高度为 0。为什么VerticalDivider内部是希望自己撑满可用高度的它给自己要的高度约束是double.infinity。而Row在交叉轴也就是纵向上默认是CrossAxisAlignment.center它给孩子的高度约束是松约束loose也就是0 height 父级给的最大高度。如果这个Row本身又处在一个高度不确定的环境里比如放在Column里、放在可滚动区域里那 Row 的高度其实是由最高的那个孩子决定的。于是就死循环了分隔线问我能有多高Row 说你自己定分隔线要无穷高最后被约束成——0。线在只是高度是 0所以你怎么调颜色、调粗细都没用。两个修法 怎么选方案 A固定高度的SizedBox 1px 容器我常用这个// ✅ 最稳高度完全可控Row(children:[constExpanded(child:Text(A)),SizedBox(height:28,// 明确给个高度别再让它自己猜child:Container(width:1,color:BrandColors.line),),constExpanded(child:Text(B)),],)方案 B用IntrinsicHeight包住 Row// ✅ 让 Row 先算出最高孩子的高度再把这个高度给分隔线IntrinsicHeight(child:Row(crossAxisAlignment:CrossAxisAlignment.stretch,// 关键让孩子纵向撑满children:const[Expanded(child:Text(A)),VerticalDivider(width:16,thickness:1),Expanded(child:Text(B)),],),)两个方案怎么选我摊成一张表省得各位看官纠结方案做法优缺点何时用SizedBox 1px 容器明确给个固定高度高度可控、观感好留余量不顶格、无额外测量大多数场景首选IntrinsicHeight包 Row让 Row 先算最高孩子高度再分配自适应内容但额外做一次布局测量较费左右两栏高度不定的卡片方案 A 视觉上更好看——分隔线跟内容一样高、两头顶格其实是有点丑的留点余量比如内容高 40线高 28观感更舒服。方案 B 的优势是自适应内容变高线跟着变高做那种左右两栏高度不定的卡片挺合适。够用就好看场景挑。三、同一家族的坑一并说了既然聊到约束和层级我把同类型踩过的再补几个都是 analyzer 查不出来的坑现象真因解法Divider的height不是线粗加了线上下间距莫名变大height是整体占位默认 16thickness才是线粗Divider(height: 1, thickness: 1)Row里直接放Text/TextField一长就黄黑溢出条 / TextField assertRow主轴给无限宽约束Text 一直往右画用Expanded/Flexible包一下Column里嵌ListView报 unbounded heightColumn给无限高ListView又想撑满冲突外层Expanded或shrinkWrap:true禁滚InkWell没有水波纹点了没反应无Material祖先 / 被不透明背景挡套Material背景色交给它稍微展开讲两句免得各位看官只看表不明白Divider的height这个太多人搞错了。Divider默认height是16VerticalDivider默认width也是16——这个值是整体占位线只是画在这块占位区域的正中间。所以加了根分隔线间距变大就是这么来的。Row溢出Row在主轴上给孩子的是无限宽约束Text就会一直往右画然后你就看到那条经典的黄黑警戒条了。TextField更狠不包直接 assert 报无限宽。Column嵌ListView同理Column主轴给的是无限高ListView又想撑满冲突。解法二选一外面套Expanded列表占满剩余空间或者给ListView加shrinkWrap: truephysics: NeverScrollableScrollPhysics()列表按内容撑开交给外层滚。后者数据多了会有性能问题能用Expanded就别用shrinkWrap。InkWell没水波纹跟坑一是同一个病因。InkWell上面没有Material祖先或者被不透明背景挡住了都会导致点了没反应。修法一样套Material背景色交给 Material。小结好啦这两个反直觉的布局坑就唠到这。回头复盘这类坑最恶心的地方在于它们不报错或者报的错跟真正的原因隔着一层。analyzer 只管语法和类型管不了你这个 Widget 摆的位置对不对所以这种问题只能靠真机跑、靠肉眼看靠一次次踩出来的直觉。这也印证了我一直坚持的一个习惯——改完 UI 必须真机实测别光看 analyzer 绿了就提交。两个核心坑我把症状和修法再拎一张总表坑症状真因修法ListTile/InkWell没水波纹点着没反馈不透明decoration盖住 Material 上的墨水Material(type: card)出背景圆角 / 用InkVerticalDivider不显示线高度被压成 0Row松约束下要无穷高被压成 0固定高SizedBox1px /IntrinsicHeight说到底就两句话——要么是约束没给够要么是层级摆错了。我把这两类归一下类别涉及坑一句话原则约束没给够VerticalDivider、Row 溢出、Column 嵌 ListView孩子要的约束父没给明确给或包一层层级摆错了ListTile/InkWell 水波纹墨水画在 Material 上别用不透明背景挡想明白这两条Flutter 布局上百分之八十的怪事儿都能自己推出来。如果这篇小文帮各位看官少踩了几个代码没错但视觉不对的坑希望您用发财的小手点个小赞哈那么各位看官您还踩过什么不报错但视觉不对的奇葩坑欢迎在评论区甩出来咱们一起长长见识。谢谢大家本文由 FungLeo 主导Deepseek 优化校阅转发请注明首发地址谢谢大家相关阅读Flutter 超长 StatefulWidget 拆分术part of extension on State 实战Flutter 骨架屏 Shimmer 实现不用 transform 的扫光法实战Flutter 401 自动刷新拦截器并发死锁_refreshQueue 死锁根治实录Flutter 带 TTL 的多级缓存设计内存磁盘网络三层实战Flutter 可复用公共组件库设计与落地AppDialog/BottomSheet 等实战