剩下的 20%:Stack、Sliver、LayoutBuilder
前三章那套(约束、Flex、无界)能盖住你日常布局的绝大部分。这一章是工具箱的抽屉——层叠、自定义滚动、响应式、以及那几件「贵但偶尔非它不可」的重型工具。不用背,扫一眼知道有什么、大概长什么样,等真撞上需求,翻回来就行。
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包的孩子,由Stack的alignment统一对齐(默认左上角)。这些孩子还决定了 Stack 的大小。 - 被
Positioned包的孩子,按你给的left/top/right/bottom定位,不参与决定 Stack 大小。同时给left和right就等于横向拉伸。
| Compose | Flutter |
|---|---|
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(...), // 网格段
],
)
ListView 不是什么独立的东西——它就是 CustomScrollView + 一个 SliverList 的便捷封装。同理 GridView = CustomScrollView + SliverGrid。
所以「什么时候上 CustomScrollView」的答案很清楚:当一个滚动里只有一种内容,用 ListView/GridView 就够;当你要在同一个滚动里混合顶栏、头图、列表、网格,就拆开写成 slivers。
常用的几个 Sliver:SliverAppBar(折叠顶栏)、SliverList / SliverGrid(懒加载列表/网格)、SliverToBoxAdapter(把普通 Widget 接进来)、SliverPersistentHeader(自定义吸顶头)、SliverPadding(给 Sliver 加边距)、SliverFillRemaining(占满剩余视口,做空状态页很好用)。
Compose 里你把整页塞进一个 LazyColumn,用 item { 头图 }、items(list) { 列表项 }、stickyHeader { } 分段——这就是 Flutter 的 CustomScrollView + slivers,逐个对应:item {} ↔ SliverToBoxAdapter,items {} ↔ SliverList,stickyHeader {} ↔ 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 要上教师端的平板,别用 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 到底把谁标脏了。