外观:Container 其实是一台十层组装厂
.background().padding().clip() 顺着写就行。」
得扔掉
Flutter 没有 Modifier 链,所以你没法用「调整顺序」来解决问题。取而代之的是 Container——一台把最多十个 widget 按固定顺序组装起来的工厂。这一章把那个顺序摊开:知道了它,圆角为什么剪不到 child、背景色为什么涂不到 margin、边框为什么会吃掉空间,全部一起讲通。
先扔掉那条链
在 Compose 里,外观是顺序敏感的一串修饰符:
// Compose:顺序不同,结果不同 Modifier.padding(8.dp).background(Blue) // 蓝色只涂在内容上 Modifier.background(Blue).padding(8.dp) // 蓝色连内边距一起涂
这个模型很好用,代价是你必须记住「谁在前谁在后」。Flutter 走了另一条路:把这些能力做成一个个独立的 widget,然后用嵌套表达顺序。上面那两行在 Flutter 里长这样:
ColoredBox(color: Colors.blue, child: Padding(padding: pad, child: content)) // 涂满 Padding(padding: pad, child: ColoredBox(color: Colors.blue, child: content)) // 只涂内容
更清楚,也更啰嗦。于是 Flutter 给了一个「常用组合的打包」——Container。而理解 Container 的唯一正确方式,是知道它把这些 widget 按什么顺序套起来。
Container 不是一个 widget,它是一个 build 方法:按固定顺序、只在你给了参数时,往外套上对应的那一层。
由内到外依次是:child → Align → Padding(含边框占的) → ColoredBox → ClipPath → DecoratedBox → 前景 DecoratedBox → ConstrainedBox → Padding(margin) → Transform。
这十层就是 Container 的全部秘密。你没法调整这个顺序——要别的顺序,就自己写那几层 widget。
这个顺序不是我编的,是 widgets/container.dart 里那段 build 一行一行读出来的。下面这台机器会在你改参数时,把「实际组装出了哪几层」列在右边——顺便把真能编译的 Dart 也生成出来:
那个顺序解释的四件事
一、padding 在里,margin 在外
Padding(padding) 在 ColoredBox 和 DecoratedBox 里面,Padding(margin) 在它们外面。所以:背景色会涂满 padding,但涂不到 margin。这条和 CSS 的盒模型一致,不用重新记。
二、边框会往里吃掉 child 的空间
那一层 Padding 用的不是你写的 padding,而是 padding + decoration.padding。而 BoxDecoration.padding 等于边框的 strokeInset——默认 strokeAlign 是 strokeAlignInside(−1.0),也就是整根线都画在盒子内侧,于是每条边各占 width。
实测:一个 200 × 100、带 4px 边框的 Container,它的 child 只拿到 192 × 92,decoration.padding 就是 EdgeInsets.all(4.0)。所以「加了边框之后里面的东西挪了一点」不是错觉,也不是 bug。
三、圆角剪不到 child,因为 decoration 画在 child 后面
这是最高频的一个「明明写了 borderRadius,图片的角还是方的」。看顺序就明白了:DecoratedBox 在 child 外面,它负责画背景——画在 child 底下。child 自己该画多大就画多大,没人剪它。
两条正确解法:
// ① 让 Container 自己剪:注意 clipBehavior 必须配 decoration,否则断言不过 Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Image.network(url, fit: BoxFit.cover), ) // ② 自己包一层剪刀(更直白,也更省一层 DecoratedBox) ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.network(url, fit: BoxFit.cover), )
圆形头像同理,用 ClipOval 或者 shape: BoxShape.circle 配 clipBehavior。demo 里的「圆形头像」那一档就是它,右边能看到 ClipPath 是独立的一层。
Clip.antiAlias 会开抗锯齿,Clip.antiAliasWithSaveLayer 会额外开一个离屏缓冲——后者在列表里逐项用会明显掉帧。默认的 Clip.none 最快,Clip.hardEdge 次之。
如果只是想给一张纯色卡片加圆角,根本不需要剪裁:让 DecoratedBox 画一个圆角背景就行了,child 是文字的话本来也不会溢出到角上。剪裁只在 child 自己会画到角上时才需要(图片、渐变、有底色的子 widget)。
四、没有 child 的 Container 可能是 0 × 0
Container 在「没有 child、又没有紧约束」时,套的是 LimitedBox(maxWidth: 0, maxHeight: 0)。翻译成人话:有界就撑满,无界就缩成 0。
实测:放在一个 300 × 120 的 SizedBox 里,它是 300 × 120;放在 Row 里(横向无界),它是 0 × 0——你设的 color 一点都看不见。这就是「我明明给了颜色,屏幕上什么都没有」的标准答案:给它 width/height,或者给它一个 child。
BoxDecoration:能画的东西就这几样
| 字段 | 干什么 | 值得注意的 |
|---|---|---|
color | 纯色底 | 和 Container(color:) 二选一,同时给会断言失败。 |
gradient | 渐变底 | LinearGradient / RadialGradient / SweepGradient。会盖掉 color,两个都给不报错但 color 白写。 |
image | 背景图 | DecorationImage(image: …, fit: BoxFit.cover)。做「图上压文字」比 Stack 省事。 |
border | 边框 | Border.all(...) 最常用;四边不同用 Border(top: …)。会吃 child 的空间。 |
borderRadius | 圆角 | 只在 shape 是矩形时可用。BorderRadius.circular / only / vertical。 |
boxShadow | 阴影(可多层) | blurRadius 是虚化、spreadRadius 是外扩、offset 是偏移。 |
shape | 矩形或圆 | BoxShape.circle 时不能再给 borderRadius。 |
三条真断言,一条一条见过就不慌
demo 里最后两档会真的「炸」,报错原文就是 Flutter 源码里那几句。三条最常撞的:
// ① color 和 decoration 同时给 Cannot provide both a color and a decoration. The color argument is just a shorthand for "decoration: BoxDecoration(color: color)". To use both a color and other decoration properties, set the color in the BoxDecoration instead. // ② 圆形又要圆角 A circle cannot have a border radius. Remove either the shape or the borderRadius argument. // ③ 要剪裁却没有 decoration Failed assertion: 'decoration != null || clipBehavior == Clip.none': is not true.
第 ② 条有个细节值得知道:它在 debugAssertIsValid() 里,也就是画的时候才检查,不是构造的时候。所以它可能等到那个 widget 真的滚进屏幕才炸——而且只在 debug build 里炸。
withOpacity:一个能量化的理由Color.withOpacity() 从 3.27 起废弃了,替代品是 withValues(alpha: …)。原因不是命名洁癖——3.27 把颜色通道换成了浮点数(为了支持 Display P3 这类广色域),而老方法会把你的 alpha 量化成 0–255 的整数。
实测差别:withOpacity(0.1) 得到的 alpha 是 0.10196078431372549(也就是 26/255),withValues(alpha: 0.1) 得到的是精确的 0.1。叠五层半透明阴影时,这点误差是看得出来的。
整个项目一次改完:dart fix --apply。
但更多时候:别手搓
上面讲的是机制,不是推荐做法。真做学校那个 App,八成的外观需求应该走现成的:
| 你想要 | 别写 | 写这个 |
|---|---|---|
| 一张卡片 | Container + 白底 + 圆角 + 阴影 | Card(M3 默认:elevation 1、圆角 12、margin 4、底色取 surfaceContainerLow) |
| 一行可点的列表项 | Container + GestureDetector | ListTile + onTap,自带水波纹和标准间距 |
| 纯色背景 | Container(color:) | ColoredBox(少绕九层判断),或者干脆 DecoratedBox |
| 只要内边距 | Container(padding:) | Padding |
| 胶囊形 | 算高度一半当圆角 | StadiumBorder(配 ShapeDecoration 或按钮的 shape) |
| 水波纹反馈 | InkWell 套在有底色的 Container 外面 | Material(或 Ink)负责底色,InkWell 放它里面 |
最后一行是个经典坑,值得展开一句:水波纹是画在 Material 这一层上的。如果你在 InkWell 里面放了一个不透明的 Container(color:),那层 ColoredBox 会把水波纹整个盖住——点下去毫无反应。解法是让底色由 Material(color: …) 或 Ink(decoration: …) 来提供,InkWell 放在它们里面。
把一张卡片从丑调到好看:四步
抽象讲完了,走一遍真实过程。目标是课表里的一行课程卡片,起点是「能显示信息但很难看」:
// 第 0 步:能跑,但像个 1998 年的网页
Container(
color: Colors.grey.shade300,
child: Column(children: [Text('INFOSYS 722'), Text('周三 14:00 · 401')]),
)
第一步:给内容留呼吸。八成的「丑」其实是没有留白,不是颜色不对。内边距和行间距一加,观感立刻不同:
Container(
color: Colors.grey.shade300,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // 左对齐,别居中
spacing: 4, // Flutter 3.27 起 Flex 自带
children: [Text('INFOSYS 722'), Text('周三 14:00 · 401')],
),
)
顺手记一个新东西:Column / Row 从 3.27 起有了 spacing 参数,再也不用在 children 里插 SizedBox(height: 8) 了。这是这两年最实用的小改进之一。
第二步:层次靠字号和颜色,不靠边框。新手爱用边框划分区域,但真正建立层次的是「主次对比」:
final t = Theme.of(context);
…
Text('INFOSYS 722', style: t.textTheme.titleMedium),
Text('周三 14:00 · 401', style: t.textTheme.bodySmall?.copyWith(
color: t.colorScheme.onSurfaceVariant, // 次要信息用「变体」色
)),
onSurfaceVariant 是 M3 配色里专门给次要文字的角色。用它而不是 Colors.grey 的好处是:切深色模式时它会自动跟着变。
第三步:底色换成能表达「一张卡」的东西。这一步才轮到 decoration:
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
),
child: …,
)
注意不要给阴影。M3 的默认审美是「用色阶而不是阴影表达层次」,一张列表里的卡片用一个比背景略深的 surfaceContainerLow 就够了,加阴影反而显脏。
第四步:如果确实要阴影,只调三个参数。
| 参数 | 物理含义 | 做「悬浮感」的经验值 |
|---|---|---|
offset | 光源方向。Offset(0, 2) 就是光从正上方来 | y 取 blurRadius 的四分之一到一半,x 一般给 0 |
blurRadius | 虚化范围,越大越「高」 | 贴着的卡片 6–10;对话框 20–30 |
color 的 alpha | 浓度 | 0.04–0.08。新手最常犯的错是给 0.3,看起来像盖了层灰 |
spreadRadius | 先向外扩再虚化 | 几乎永远给 0。它常被误当成 blur 用 |
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06), // 不是 0.3
blurRadius: 10,
offset: const Offset(0, 2),
),
],
要更快就别自己调:Card(elevation: 2) 已经是 Material 团队调好的一套阴影,而且深色模式下它知道该换成什么。
Flutter: Toggle Debug Painting(命令面板搜,或 DevTools 里的按钮)。它会把每个 RenderObject 的边界画成蓝线、padding 画成绿色、baseline 画成横线。
「这里为什么多了一段空白」「这两行为什么对不齐」——开一下它,答案通常是肉眼可见的:多了一层 Padding、某个 Container 撑满了、或者 Column 的 crossAxisAlignment 是默认的 center。用它找一次问题,比读十遍代码快。第 28 章会把这类工具列全。
文字:也别硬编码
// ✗ 硬编码:换主题、切深色模式全得改
Text('INFOSYS 722', style: TextStyle(fontSize: 16, color: Colors.black87))
// ✓ 从主题取,再局部改一两个字段
Text('INFOSYS 722', style: Theme.of(context).textTheme.titleMedium)
Text('周三 14:00', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.primary))
M3 的 textTheme 有十五档(displayLarge 到 labelSmall),日常够用的就四五个:titleMedium 做标题、bodyMedium 做正文、bodySmall 做副标题、labelLarge 做按钮文字。copyWith 是这里的关键动作——继承主题,只改你要改的那一个字段。
重复三次以上,就搬进主题
同样的卡片样式写第三遍时,不要复制粘贴,搬到 ThemeData 里去:
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF027DFD)),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48)),
),
),
)
之后全 App 的 Card 和 FilledButton 就统一了,而且深色模式只要再给一个 darkTheme。这是「一个人做的小 App」和「看起来像一个人随手做的小 App」之间最省力的那道差距。
和 Compose 的精确对照
Compose:外观是 Modifier 链,顺序由你写,从左到右由外到内。想让圆角剪掉 child,就把 .clip() 放在前面;想让背景不涂 padding,就把 .padding() 放在 .background() 前面。灵活,但你必须理解「链的方向」。
Flutter:外观是嵌套的 widget,顺序由嵌套结构写死。Container 是一份预设的顺序方案,你只能接受或者绕过它(自己写那几层)。
换来了什么:可推理性。Compose 里「这条链为什么是这个效果」需要在脑子里回放一遍链的方向;Flutter 里答案永远在树的结构里明写着,谁包着谁,谁就先作用。代价你也已经很熟了:缩进金字塔——而这正好是下一章那些快捷键要解决的问题。
一一对应表:Modifier.background ≈ ColoredBox/DecoratedBox;.padding ≈ Padding;.clip(RoundedCornerShape) ≈ ClipRRect;.border ≈ BoxDecoration.border;.shadow ≈ BoxDecoration.boxShadow(或 Material.elevation);.size ≈ SizedBox;MaterialTheme.typography ≈ Theme.of(context).textTheme。
这一章的一句话
没有 Modifier 链:Container 是一台按固定顺序组装十层 widget 的工厂,padding 在 decoration 里、margin 在外、边框往里吃、decoration 画在 child 背后——知道这个顺序,外观类的怪现象就没有一件是玄学。
到这里,你已经能写出两个类、搭出一个页面、把它变好看了。最后一章换个方向:这些代码你敲得够快吗?那些嵌套和样板,Flutter 的工具链本来就没打算让你手打——我们把 VS Code 里那十几个入口一次过完,并且亲手按一下 ⌘. 背后到底改了哪些字。