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

剩下的 20%:Stack、Sliver、LayoutBuilder

直觉过境 「Box 堆叠 = Stack,对齐用 alignment。」 直接放行

前三章那套(约束、Flex、无界)能盖住你日常布局的绝大部分。这一章是工具箱的抽屉——层叠、自定义滚动、响应式、以及那几件「贵但偶尔非它不可」的重型工具。不用背,扫一眼知道有什么、大概长什么样,等真撞上需求,翻回来就行。

Stack/PositionedSliverLayoutBuilderSafeArea

Stack:层叠,和 Compose 的 Box 一样

要把东西叠在一起(头像上的红点、图片上的文字、悬浮按钮),用 Stack。它和 Compose 的 Box 几乎一一对应:

Stack(
  children: [
    Image.network(url),                    // 底层
    Positioned(                            // 精确定位某一层
      right: 8, top: 8,
      child: Badge(count: 3),
    ),
    const Align(                           // 或用 Align 相对定位
      alignment: Alignment.bottomCenter,
      child: Text('封面'),
    ),
  ],
)

两个要点:

  • 没被 Positioned 包的孩子,由 Stackalignment 统一对齐(默认左上角)。这些孩子还决定了 Stack 的大小。
  • Positioned 包的孩子,按你给的 left/top/right/bottom 定位,不参与决定 Stack 大小。同时给 leftright 就等于横向拉伸。
ComposeFlutter
Box { }Stack(children: [])
Modifier.align(Alignment.TopEnd)Align(alignment: Alignment.topRight)
Modifier.offset(x, y)Positioned(left: x, top: y)
Modifier.matchParentSize()Positioned.fill

Sliver:滚动区里的「布局协议 2.0」

这是这一章最值得投资的一节,因为它解决第 12 章留的尾巴——一个滚动里混多种内容

普通 Widget 遵守「约束向下、尺寸向上」(第 10 章)。但在一个滚动视口里,这套不够用了——视口需要知道更多:「你在当前滚动位置露出了多少?」「你要不要吸顶?」于是滚动区里用的是一套更丰富的协议,遵守这套协议的组件叫 Sliver

CustomScrollView 是唯一直接吃 Sliver 的容器。你把整页的各段做成 Sliver,塞进一个滚动里:

CustomScrollView(
  slivers: [
    SliverAppBar(                          // 可折叠/吸顶的顶栏
      expandedHeight: 200,
      pinned: true,
      flexibleSpace: FlexibleSpaceBar(title: Text('课程')),
    ),
    SliverToBoxAdapter(                    // 把一个普通 Widget 塞进 Sliver 世界
      child: CourseHeader(),
    ),
    SliverList.builder(                    // 懒加载的列表段
      itemCount: courses.length,
      itemBuilder: (_, i) => CourseTile(course: courses[i]),
    ),
    SliverGrid.builder(...),               // 网格段
  ],
)
◆ 你其实一直在用 Sliver

ListView 不是什么独立的东西——它就是 CustomScrollView + 一个 SliverList便捷封装。同理 GridView = CustomScrollView + SliverGrid

所以「什么时候上 CustomScrollView」的答案很清楚:当一个滚动里只有一种内容,用 ListView/GridView 就够;当你要在同一个滚动里混合顶栏、头图、列表、网格,就拆开写成 slivers。

常用的几个 Sliver:SliverAppBar(折叠顶栏)、SliverList / SliverGrid(懒加载列表/网格)、SliverToBoxAdapter(把普通 Widget 接进来)、SliverPersistentHeader(自定义吸顶头)、SliverPadding(给 Sliver 加边距)、SliverFillRemaining(占满剩余视口,做空状态页很好用)。

⇄ Compose 对照 · 这是你熟的那个 LazyColumn

Compose 里你把整页塞进一个 LazyColumn,用 item { 头图 }items(list) { 列表项 }stickyHeader { } 分段——这就是 Flutter 的 CustomScrollView + slivers,逐个对应item {}SliverToBoxAdapteritems {}SliverListstickyHeader {}SliverPersistentHeader(pinned),可折叠大标题栏 ↔ SliverAppBar。你已经会这个心智模型了,只是换个 API 名。

LayoutBuilder:拿到父约束再决定长什么样

第 10 章说过「父问不到孩子」,但反过来——孩子可以知道父给了自己多大空间,用 LayoutBuilder。它是做响应式布局的主力(宽屏两栏、窄屏一栏):

LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth > 600) {
      return Row(children: [Sidebar(), Expanded(child: Content())]);  // 平板/横屏
    }
    return Content();                                                  // 手机竖屏
  },
)

它和 MediaQuery.of(context).size 的区别值得记:MediaQuery 给的是整个屏幕/窗口的尺寸,LayoutBuilder 给的是「我这个位置实际拿到的约束」。做局部响应式(比如一个卡片在不同容器里表现不同)用后者更准。

这个区别在真实场景里很重要。想象你的课程卡片有时候放在手机的单栏列表里(占满宽度)、有时候放在平板的两栏网格里(只占半宽)——同一个卡片,你希望它「窄的时候竖排图文、宽的时候横排」。用 MediaQuery 判断整屏宽度是错的(整屏很宽,但这个卡片只分到半宽);用 LayoutBuilder卡片自己实际拿到的约束才对。一个组件的响应式,应该基于它自己的可用空间,而不是整块屏幕——这条原则能让你的组件在任何容器里都表现正确,是构建可复用响应式组件的关键。

不过 LayoutBuilder 有个代价要知道:它的 builder布局阶段才被调用(因为要先知道约束),这意味着它比普通 build 稍晚、且约束变化时会重跑。别把它当普通容器到处套——只在真正需要「根据可用空间决定长什么样」时用它,否则一个固定布局硬包 LayoutBuilder 是白白增加开销。

✎ 学校 App 大概率要适配平板

如果 App 要上教师端的平板,别用 MediaQuery 到处判宽度——用 Flutter 自带的 breakpoints 思路,或者一个简单封装:把「手机/平板/桌面」抽成一个枚举,在顶层用一次 LayoutBuilder 决定,往下用 InheritedWidget(第 15 章)传下去。这样断点逻辑只有一处,改起来不满地找。

那些「贵但偶尔非它不可」的重型工具

这些平时别碰,但知道它们存在,能在真正需要时少走弯路:

工具能干什么代价 / 提醒
IntrinsicHeight / Width让 Row 里所有孩子等高之类O(N²),多一遍测量(第 10 章),能避则避
FittedBox把孩子缩放到刚好放进给定空间做「文字自动缩放不溢出」很方便
AspectRatio强制宽高比(如 16:9 视频位)轻量,放心用
FractionallySizedBox按父的百分比取尺寸(占 80% 宽)轻量
OverflowBox / UnconstrainedBox故意打破父约束,让孩子超出危险,容易造成溢出,明确知道自己在干嘛再用
CustomMultiChildLayout完全手写多孩子布局逻辑终极逃生舱,写自定义控件才用
Flow用矩阵变换手动摆放(高性能动画布局)很少用,但做花哨动效时是它

两个你每个页面都会用到的「边界」工具

这两个太常用,单独拎出来,免得你漏掉导致内容被刘海/状态栏挡住:

SafeArea

把内容挡开刘海、状态栏、底部手势条。几乎每个页面的顶层都该有它(或者用 Scaffold,它内部处理了大部分):

Scaffold(
  body: SafeArea(
    child: YourContent(),
  ),
)

MediaQuery 的 padding 与 viewInsets

需要精细处理键盘时会用到:MediaQuery.of(context).viewInsets.bottom 是键盘高度,padding 是系统安全区。第 11 章那个「键盘弹出导致溢出」的问题,深入解决就靠读这些值。

Wrap:会换行的 Row

一个高频需求 Row 做不了:一堆标签(课程分类、学生兴趣标签),一行放不下要自动换行Row 只会溢出报警(第 11 章)。用 Wrap

Wrap(
  spacing: 8,        // 主轴(横向)间距
  runSpacing: 8,     // 交叉轴(换行后行与行)间距
  children: tags.map((t) => Chip(label: Text(t))).toList(),
)

Wrap 放不下就换到下一行,永远不溢出。选课标签、筛选条件、相册九宫格式的自适应排布,都是它。它就是 Compose 的 FlowRow / FlowColumn

SingleChildScrollView + Column:表单页的标准结构

学校 App 里最常见的一类页面是表单(报名、请假、信息填写)——一堆输入框竖着排,内容可能比屏幕高(尤其键盘弹出后)。第 12 章说过 Column 不滚,装不下就溢出。表单页的标准骨架是:

Scaffold(
  body: SafeArea(
    child: SingleChildScrollView(          // 让整页能滚,键盘弹出也不溢出
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextField(decoration: const InputDecoration(labelText: '姓名')),
          const SizedBox(height: 16),
          TextField(decoration: const InputDecoration(labelText: '学号')),
          const SizedBox(height: 24),
          FilledButton(onPressed: _submit, child: const Text('提交')),
        ],
      ),
    ),
  ),
)

记住这个组合SingleChildScrollView 装少量、能滚的内容(表单、详情页正文);它和 ListView.builder(第 23 章,装长列表)分工明确——前者全量构建但内容不多,后者懒加载但要处理回收。表单用前者,名单用后者。

⚠ 别过度自定义布局

从 Android 过来的人有时会习惯性地想「自己画布局」(因为 Android 自定义 View 是家常便饭)。在 Flutter 里,99% 的界面用现成的 Row/Column/Stack/Sliver 组合就能拼出来,而且性能更好、别人更看得懂。

只有当你在做一个真正独特的视觉组件(一个环形进度、一个特殊的图表、一个物理感的拖拽)时,才下沉到 CustomPaint(自己画)或 CustomMultiChildLayout(自己排)。先穷尽组合,再考虑自定义。

这一章的一句话

剩下的 20% 是一抽屉专用工具——Stack 层叠(≈ Compose 的 Box)、Sliver 定制滚动(≈ 你熟的 LazyColumn 分段)、LayoutBuilder 做响应式,以及几件贵但偶尔必要的重型件;它们不用背,知道有什么、扣上 SafeArea,用到再翻回来。

布局卷到此结束。你现在能推理界面为什么长这样、为什么报错、怎么修。接下来是卷 IV——状态。第 6 章我们说过:Compose 有编译器帮你划重组范围,Flutter 让你自己划。下一章就从最原始的那把凿子开始:setState 到底把谁标脏了。