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

无界约束:ListView 放进 Column 为什么炸

直觉过境 「滚动列表套滚动列表,最多是体验差一点。」 得扔掉

在别的框架里,把可滚区放进可滚区顶多是手势打架、体验难受。在 Flutter 里它直接抛异常、当场红屏。这一章要说服你:这不是 Flutter 娇气,是这道题在数学上真的无解——而一旦你理解「无界」这个词,两条最著名的报错会同时变成你一眼能修的小事。

unbounded heightviewportExpanded 修法shrinkWrap 的代价

先复现,再讲道理

下面这台求解器把两条经典报错和它们的解法并排放。先看第一个「✗ ListView 放进 Column」——它真的算出了 Vertical viewport was given unbounded height

为什么这道题真的无解

回忆第 10 章:Column 给它的非弹性孩子的主轴约束是「无界」的(高度 0 到无穷)。意思是:「孩子,你想多高都行,我不限制你。」对大多数孩子(Text、固定盒子)这没问题,它们有自己的自然高度,回一个有限值就行。

ListView 不一样。ListView 是一个「视口」(viewport)——它的整个存在意义就是「在一个有限的窗口里,滚动展示可能无限长的内容」。它必须知道自己那个「窗口」有多高,才能决定显示几项、滚动范围多大、回收哪些。

◆ 两个无限,撞在一起

Column 说:「你想多高都行(无界约束)。」
ListView 回答:「那我要无限高——因为我想把所有内容一次性展开。」

可「无限高的视口」是自相矛盾的:视口的定义就是「有限窗口 + 滚动」,无限高就没有窗口、没有滚动、也没法回收,它退化成了一个「把一千项全建出来的普通 Column」,那还要视口干嘛?框架检测到这个矛盾,与其给你一个悄悄卡死的界面,不如当场抛异常告诉你。

所以报错原文 Vertical viewport was given unbounded height 是精确的:「有人给了我这个垂直视口一个无界的高度,我没法工作。」它不是 bug,是这道题本身无解。

解法只有一句话:让主轴变得有界

看后面几个场景,你会发现所有解法都是同一句话的不同说法——给 ListView 一个确定的高度

✓ 解法一:Expanded(最常用)

Expanded 给孩子的是紧约束(第 11 章):「你就是 360 高,不多不少。」有界了,ListView 立刻能工作——它知道窗口 360 高,只建视口里那几项。

Column(
  children: [
    const Text('课程表'),           // 标题占它自己的高度
    Expanded(                       // ← 把剩下的高度全给 ListView
      child: ListView.builder(
        itemCount: courses.length,
        itemBuilder: (_, i) => CourseTile(course: courses[i]),
      ),
    ),
  ],
)

90% 的「ListView 报错」都是加这一行 Expanded 修好的。记住这个组合:Column 里,固定的东西(标题、按钮)照常放,那个要滚动的列表包一层 Expanded

✓ 解法二:SizedBox 给死高度

如果这个列表就该固定占某个高度(比如一个横向的推荐位、一个高度确定的小列表),直接 SizedBox(height: 200, child: ListView(...))。同样的道理:把无界变有界。

✗ 反过来的死法:无界里放 Expanded

最后一个场景是第二条经典报错,方向相反:SingleChildScrollView 里放 Column,Column 里放 Expanded

SingleChildScrollView 给孩子的高度是无界的(它要让内容自由伸展好滚动)。而 Expanded 的工作是「分掉剩余空间」——可无限的剩余空间没法分。报错原文说得明明白白:RenderFlex children have non-zero flex but incoming height constraints are unbounded

◆ 两条报错,一个口诀

看到 unbounded height(视口那条)→ 有个可滚组件没拿到确定高度 → 往它外面加 ExpandedSizedBox

看到 non-zero flex but ... unbounded→ 你在一个能无限伸展的地方(滚动区)用了 Expanded → 去掉 Expanded,让内容用自然高度。

两者是一体两面:可滚区提供无界,Expanded/视口需要有界,两者不能直接嵌套。中间要么加个定高的中介,要么改结构。

那「列表套列表」到底怎么做

真实需求确实存在:一个页面,上面一段图文介绍,中间一个横向滚动的推荐条,下面一个纵向的长列表——整个页面还要能一起纵向滚。这有三种正确做法,按推荐度排:

做法怎么用代价
CustomScrollView + Slivers把各段做成 SliverListSliverToBoxAdapter,共享一个滚动要学 Sliver(第 13 章),但这是「正统」答案
外层 ListView,内层横向 ListView 定高纵向大列表的某一项是 SizedBox(height: 120, child: 横向 ListView)几乎没有,横向的定高就行
shrinkWrap: true让内层 ListView「量出自己的真实高度」,就能放进 Column有性能代价,见下
⚠ shrinkWrap 是止痛药,不是解药

ListView(shrinkWrap: true) 会让报错消失——它让列表先把所有孩子量一遍,算出自己的总高度,于是有了确定尺寸,能塞进 Column。

但注意「把所有孩子量一遍」这句:它放弃了懒加载。视口的全部价值(只建可见项、回收滚出去的)没了——一千项会全部被布局。它适合项数很少、确定不长的内嵌小列表,绝不适合真正的长列表。

见到别人代码里 shrinkWrap: true + physics: NeverScrollableScrollPhysics()(禁掉内层滚动、让外层统一滚)这个组合,就知道他在「用小列表凑数」——项少时没问题,但这不该是你处理长列表的默认姿势。长列表请用 Slivers。

顺带认识一下:可滚组件家族

既然聊到滚动,把这几个常混的理清(第 23 章会展开列表性能):

组件什么时候用
SingleChildScrollView内容总量不大、就想整体能滚(如一个长表单)。不回收,全部建出来
ListView(children: [...])项数少且固定。也是全部建出来,别用于长列表
ListView.builder项数多或不确定时的默认选择。懒加载 + 回收
GridView.builder网格版的 builder
CustomScrollView要在一个滚动里混合多种内容(Sliver)
⇄ Compose 对照 · 为什么那边不炸

在 Compose 里,把 LazyColumn 放进一个可垂直滚动的 Column,你也会遇到麻烦——运行时会抛「Vertically scrollable component was measured with an infinity maximum height constraints」,本质是同一个错、同一个原因:可滚组件拿到了无限高约束。

差别在「常见程度」和「解法风格」。Compose 里你更常用一个 LazyColumnitem {} / items {} 把所有内容(头图、推荐条、长列表)塞进同一个懒列表——这恰好对应 Flutter 的 CustomScrollView + Slivers 那条「正统」路。所以:你在 Compose 里「一个 LazyColumn 装下整页」的习惯,迁到 Flutter 就是「一个 CustomScrollView 装下整页」,而不是「Column 里塞好几个 ListView」。想通这个映射,你就不会再写出会炸的结构了。

横向也一样:Row 里放横向 ListView

这道题不只发生在竖直方向。把一个横向 ListView 放进 Row,会撞上一模一样的墙——只是报错变成「horizontal viewport was given unbounded width」。因为 Row 在主轴(水平)上给非弹性孩子的是无界宽度,横向视口又要无限宽,同一个矛盾。

解法也一样:Expanded(横向列表占满剩余宽度)或 SizedBox(width: ...)(定宽)。你会在「首页横向推荐位」这类布局里高频遇到——一个纵向 ListView,其中某一项是定高的横向 ListView:

// 纵向大列表里,嵌一条横向滚动的推荐
SizedBox(
  height: 120,                            // 横向列表需要一个确定的高(它的交叉轴)
  child: ListView.builder(
    scrollDirection: Axis.horizontal,     // 横向滚
    itemCount: recommended.length,
    itemBuilder: (_, i) => RecommendCard(recommended[i]),
  ),
)

注意这里给的是高度(横向列表的交叉轴要定,主轴横向由外层的宽度界定,通常没问题)。「横向列表要给高、纵向列表要给高(用 Expanded)」——记住列表永远需要在某个方向上被界定,就不会再被这类报错绊住。

✎ 一条能救急的调试技巧

遇到 unbounded 报错,又一时想不清结构,先用 LayoutBuilder 把「父亲到底给了什么约束」打出来:

LayoutBuilder(
  builder: (context, constraints) {
    debugPrint('拿到的约束:$constraints');   // 看 maxHeight 是不是 Infinity
    return ListView(...);
  },
)

如果打印里 maxHeightInfinity,你就确认了病因,然后往上找那个给无界的父节点。LayoutBuilder 本身也是第 13 章的一个主角。

这一章的一句话

「ListView 放进 Column 就炸」不是 Flutter 娇气,是「无界约束」撞上「视口需要有界高度」这道无解题;解法永远是同一句——让可滚区拿到确定高度(Expanded / SizedBox),而真正的多段滚动页请用一个 CustomScrollView 装下,别在 Column 里塞好几个 ListView。

卷 III 的核心(约束三句话、Flex 分配、无界)到这里就齐了,它们解决你日常 80% 的布局问题。剩下那 20%——Stack 层叠、Sliver 定制、LayoutBuilder 响应式、以及 Intrinsic 的真实代价——是下一章的内容,那些你用到再翻回来即可。