卷 III · 约束CH 11深度 11/24

Row/Column:Expanded 与那条黄黑警戒条

直觉过境 「weight(1f) 那套按比例分配空间的写法能带过来。」 直接放行

这一章是卷 III 里最让你舒服的一章:Expanded(flex: 1) 基本是 weight(1f) 的逐字翻译,连底层「两趟分配」的算法都一样。但 Flutter 多了一个 Compose 没有的东西——那条黄黑相间的溢出警戒条。它不是报错弹窗,是布局系统在跟你说话,这一章教你听懂它。

两趟分配Expanded vs Flexible溢出主轴/交叉轴

先把词对上

RowColumnFlex 的两个方向。它们有两根轴,术语和 Compose 略有出入,先对齐:

主轴(沿排列方向)交叉轴(垂直于排列)
Column竖直 · mainAxisAlignment水平 · crossAxisAlignment
Row水平 · mainAxisAlignment竖直 · crossAxisAlignment
Compose 里verticalArrangement / horizontalArrangementhorizontalAlignment / verticalAlignment

还有一个字段 mainAxisSizemax(默认,主轴上尽可能占满父给的空间)或 min(只包住孩子)。「为什么我的 Row 占满了整行」——因为 mainAxisSize 默认是 max

亲手跑:Flex 两趟分配器

下面这台真的执行 RenderFlex 的分配算法。看四个场景,重点盯右边那条链路里的数字。

场景一:两趟分配,看清 100 / 100 / 200 怎么来的

一个固定 100 高的 A,加上 Expanded(flex: 1) 的 B 和 Expanded(flex: 2) 的 C,塞进 400 高的空间。引擎分两趟:

  1. 第一趟:只量非 flex 的孩子。A 拿到 100。此时 flex 孩子(B、C)先跳过
  2. 第二趟:分剩下的。剩 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 把剩下的全吃了。区别只在给孩子的约束:

ExpandedFlexible
本质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)传达一个精确信息:「你让我在有限的空间里放下超过它容量的东西,我放不下,多出来这么多。」

关键认知:ColumnRow 本身不会滚动、不会裁剪、不会缩放。它们是「刚性容器」,装不下就溢出给你看。这和 Android 的 LinearLayout(会挤压)、Web 的 flexbox(默认会 shrink)都不同——Flutter 选择大声报错而不是悄悄变形,因为悄悄变形更难发现。

怎么修,取决于你的真实意图

你其实想要的解法
内容超屏时能滚动ListViewSingleChildScrollView(第 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 对照 · 几乎一比一
ComposeFlutter
Modifier.weight(1f)Expanded(flex: 1)
Modifier.weight(1f, fill = false)Flexible(flex: 1)
Arrangement.SpaceBetweenMainAxisAlignment.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 为什么炸」。