Row/Column:Expanded 与那条黄黑警戒条
这一章是卷 III 里最让你舒服的一章:Expanded(flex: 1) 基本是 weight(1f) 的逐字翻译,连底层「两趟分配」的算法都一样。但 Flutter 多了一个 Compose 没有的东西——那条黄黑相间的溢出警戒条。它不是报错弹窗,是布局系统在跟你说话,这一章教你听懂它。
先把词对上
Row 和 Column 是 Flex 的两个方向。它们有两根轴,术语和 Compose 略有出入,先对齐:
| 主轴(沿排列方向) | 交叉轴(垂直于排列) | |
|---|---|---|
Column | 竖直 · mainAxisAlignment | 水平 · crossAxisAlignment |
Row | 水平 · mainAxisAlignment | 竖直 · crossAxisAlignment |
| Compose 里 | verticalArrangement / horizontalArrangement | horizontalAlignment / verticalAlignment |
还有一个字段 mainAxisSize:max(默认,主轴上尽可能占满父给的空间)或 min(只包住孩子)。「为什么我的 Row 占满了整行」——因为 mainAxisSize 默认是 max。
亲手跑:Flex 两趟分配器
下面这台真的执行 RenderFlex 的分配算法。看四个场景,重点盯右边那条链路里的数字。
场景一:两趟分配,看清 100 / 100 / 200 怎么来的
一个固定 100 高的 A,加上 Expanded(flex: 1) 的 B 和 Expanded(flex: 2) 的 C,塞进 400 高的空间。引擎分两趟:
- 第一趟:只量非 flex 的孩子。A 拿到 100。此时 flex 孩子(B、C)先跳过。
- 第二趟:分剩下的。剩 400 − 100 = 300,按 flex 比 1:2 分 → B 得 100,C 得 200。
结果 100 + 100 + 200 = 400,正好填满。这就是 Compose weight() 底层做的事,一步不差。
因为「剩多少空间可分」取决于「固定的孩子占了多少」,所以必须先量完所有固定的,才知道剩多少给弹性的分。这也解释了一个常见困惑:Expanded 只能用在 Row/Column/Flex 的直接孩子上——因为「分配剩余空间」这个动作只有 Flex 父节点会做。放到别处(比如 Column 里再包一个 Center 再放 Expanded)就会报错。
场景二:Expanded 和 Flexible,只差一个字段
这两个新手常搞混。看引擎的结果:Flexible 里的「短」字只占了它需要的宽度,右边 Expanded 把剩下的全吃了。区别只在给孩子的约束:
Expanded | Flexible | |
|---|---|---|
| 本质 | Flexible(fit: FlexFit.tight) | Flexible(fit: FlexFit.loose)(默认) |
| 给孩子的约束 | 紧:必须占满分到的空间 | 松:最多这么大,可以更小 |
| 孩子比分到的小时 | 照样撑满(留白) | 缩到自己需要的大小 |
| 什么时候用 | 要填满、要背景铺满 | 要「最多占这么多,但内容少就缩」 |
一句话记:Expanded = 一定占满分到的份额;Flexible = 最多占这么多,内容小就缩。
场景三 & 四:对齐怎么用掉剩余空间
当孩子们没占满主轴时,mainAxisAlignment 决定剩余空间怎么分布。场景四里三个 60 高的盒子(共 180)放进 400 高,剩 220,spaceBetween 把它塞进两个缝里,每缝 110。
这一步发生在所有孩子都量完之后——正是第 10 章的第三句「父定位置」。孩子对自己最终在哪一无所知,位置完全由父亲说了算。常用的几个值:start/center/end(靠边/居中)、spaceBetween(两端顶格、缝均分)、spaceAround(每个两侧留半缝)、spaceEvenly(所有缝相等)。
那条黄黑警戒条:溢出
场景三是重点。三个 300 高的盒子塞进 400 高的空间,引擎算出溢出 500 像素,画上那条黄黑条。这个数字不是随便标的——它是 300×3 − 400 = 500,孩子要的总尺寸减去可用空间。
那条黄黑条(和控制台里的 A RenderFlex overflowed by 500 pixels on the bottom)传达一个精确信息:「你让我在有限的空间里放下超过它容量的东西,我放不下,多出来这么多。」
关键认知:Column 和 Row 本身不会滚动、不会裁剪、不会缩放。它们是「刚性容器」,装不下就溢出给你看。这和 Android 的 LinearLayout(会挤压)、Web 的 flexbox(默认会 shrink)都不同——Flutter 选择大声报错而不是悄悄变形,因为悄悄变形更难发现。
怎么修,取决于你的真实意图:
| 你其实想要的 | 解法 |
|---|---|
| 内容超屏时能滚动 | 换 ListView 或 SingleChildScrollView(第 12 章) |
| 让某几项分掉剩余空间、别超 | 把它们包 Expanded/Flexible |
| 文字太长时省略号 | Text(..., overflow: TextOverflow.ellipsis) + 外层 Expanded |
| 一行图标太多时换行 | 用 Wrap 代替 Row |
| 就是想裁掉多余的 | ClipRect(很少是真的想要) |
1. Row 里放长文字。Row(children: [Icon(...), Text('一段很长的课程名称……')])——文字想要它的自然宽度,Row 不给它约束,一超就右侧溢出。修法:给 Text 包一层 Expanded,它就会在剩余宽度里换行或省略。
2. 键盘弹出后 Column 溢出。页面本来正好放下,键盘一弹,可用高度骤减 → 底部溢出。修法:整个页面包 SingleChildScrollView,或用 resizeToAvoidBottomInset。
3. 小屏设备上溢出,你自己的大屏手机上看不到。这是最阴的——一定要在最小的目标设备上测,或者用 DevTools 的设备预览切几个尺寸。学校 App 会跑在各种便宜安卓机上,这条尤其重要。
交叉轴:stretch 是个隐藏的坑
crossAxisAlignment 大多好懂(start/center/end),但 stretch 值得单说:它让孩子在交叉轴上撑满。这本身没问题,问题在于它给孩子的是紧约束——如果孩子又想自己定交叉轴尺寸,就会冲突。
典型翻车:Column(crossAxisAlignment: stretch, children: [Image(...)])——你以为图片会保持比例,结果它被拉成了屏幕宽。因为 stretch 强制图片宽度等于 Column 宽度,这是紧约束,图片没得选(第 10 章的规则)。
还有一个你会天天用的:Spacer 和 Gap
两个小工具,让代码干净很多:
Row(
children: [
const Text('课程表'),
const Spacer(), // = Expanded(child: SizedBox()),把左右顶开
IconButton(icon: const Icon(Icons.add), onPressed: …),
],
)
Column(
children: [
const Text('第一行'),
const SizedBox(height: 12), // 固定间距的老实写法
const Text('第二行'),
],
)
Spacer 就是一个 Expanded 包着空盒子,用来「把两边推开」。固定间距目前还是 SizedBox 最常见(较新的 Flutter 给 Row/Column 加了 spacing 参数,能直接写 Column(spacing: 12, children: …),省掉一堆 SizedBox——你的 3.44 支持它)。
MainAxisSize:那个「Row 怎么占了整行」的真相
一个高频困惑:你在 Row 里放两个小图标,它却把整行撑满了、图标挤到两端或中间。原因是 mainAxisSize 默认是 max——Row/Column 默认在主轴上尽可能大,占满父给的空间。
// 默认 max:Row 占满整行宽
Row(children: [Icon(Icons.star), Text('4.8')])
// min:Row 只包住两个孩子,缩到内容宽度
Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(Icons.star), Text('4.8')],
)
什么时候要 min?当这个 Row 本身要被别人摆放时——比如放进 Center(想让「图标+文字」这一小组居中,而不是撑满再内部居中)、或者当作一个 Chip/按钮的内容。「我的 Row/Column 莫名其妙占满了」几乎总是 mainAxisSize 的事,记住这个默认值能省你很多染色调试(第 10 章)。
baseline 对齐:让文字底线对齐
一个容易忽略但很提升质感的细节:在 Row 里放不同字号的文字(比如「¥」小、金额大),默认按顶部或中线对齐会显得没对齐。用 baseline:
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic, // 用了 baseline 就必须给这个
children: [
Text('¥', style: TextStyle(fontSize: 14)),
Text('99', style: TextStyle(fontSize: 28)),
Text('.00', style: TextStyle(fontSize: 14)),
],
)
三段文字会按文字基线对齐,像排版软件里那样。学校 App 里显示学分、绩点、金额这类「大数字配小单位」的地方,用 baseline 立刻专业一档。注意:用了 CrossAxisAlignment.baseline 就必须同时给 textBaseline,否则报错——这是个小陷阱。
| Compose | Flutter |
|---|---|
Modifier.weight(1f) | Expanded(flex: 1) |
Modifier.weight(1f, fill = false) | Flexible(flex: 1) |
Arrangement.SpaceBetween | MainAxisAlignment.spaceBetween |
Spacer(Modifier.weight(1f)) | Spacer() |
Spacer(Modifier.height(12.dp)) | SizedBox(height: 12) / spacing: |
| 内容超界:默认裁剪或滚动看情况 | 默认溢出报警(黄黑条) |
你唯一要适应的新东西,是那条黄黑条。Compose 里 Column 内容过高一般是被裁掉或看你有没有加 verticalScroll;Flutter 更严厉——不滚就报警。把它当成好事:它逼你在开发期就想清楚「这里到底该不该滚」,而不是等用户在小屏上发现内容被吞了。
这一章的一句话
Row/Column 用两趟分配摆放孩子(先量固定的、再按 flex 比分剩余的),这正是 Compose 的 weight;而它们是刚性容器——装不下不会滚也不会缩,只会亮出那条黄黑条,告诉你差了多少像素,逼你当场决定该滚、该分、还是该省略。
那条黄黑条最常见的成因,其实是一个更深的东西:你把一个「想要无限空间」的可滚动组件,放进了一个「给无限空间」的父节点——两个无限撞在一起。下一章我们直面 Flutter 最著名的一条报错:Vertical viewport was given unbounded height,也就是「ListView 放进 Column 为什么炸」。