04 · Flutter 布局与滚动进阶
【通用进阶】
02章讲的是「能跑起来的布局」,这一章讲「复杂滚动效果怎么做」。 项目里列表都用ListView+ 分页骨架,一旦要做滚动时折叠的详情页头就会撞墙——这一章解决它。
开篇:前端概念对照
| 前端概念 | Flutter 对应 | 差异 |
|---|---|---|
overflow: scroll | ListView / SingleChildScrollView | |
| 虚拟滚动 | ListView.builder | Flutter 默认懒加载 |
position: sticky | SliverPersistentHeader | |
| 吸顶导航 | SliverAppBar(pinned: true) | |
| 视差滚动头部 | SliverAppBar(flexibleSpace) | |
@media (max-width: 600px) | LayoutBuilder / MediaQuery | Flutter 按约束而非屏幕尺寸 |
env(safe-area-inset-top) | SafeArea / MediaQuery.padding | |
100vh | MediaQuery.sizeOf(context).height | |
100dvh(动态视口) | MediaQuery.viewInsetsOf | 键盘弹起时视口变化 |
| 滚动容器嵌套 | NestedScrollView |
一、Sliver 体系
为什么需要 Sliver
ListView 能滚动,但它是一整块——里面所有 item 是一个整体,无法实现「头部滚动时折叠、然后吸顶」这种效果。
Sliver 是「可滚动区域的一个片段」。多个 Sliver 组合进 CustomScrollView,各片段可以有不同的滚动行为。
ListView = [全部内容作为一个整体滚动]
CustomScrollView = [Sliver A][Sliver B][Sliver C] ← 各自独立控制前端类比
最接近的是 CSS position: sticky + 多个段落的组合。但 Sliver 更强大——每个片段可以声明「我怎么被消费」。
核心 Widget
| Widget | 作用 | 前端类比 |
|---|---|---|
CustomScrollView | Sliver 的容器 | 滚动容器 |
SliverAppBar | 可折叠/吸顶的头部 | sticky header + 视差 |
SliverList | 线性列表 | ListView |
SliverGrid | 网格 | GridView |
SliverToBoxAdapter | 把普通 Widget 塞进 Sliver | (桥梁) |
SliverPersistentHeader | 自定义吸顶头 | position: sticky |
SliverFillRemaining | 填满剩余空间 | min-height: 100% |
SliverPadding | 给 Sliver 加内边距 |
基本结构
CustomScrollView(
slivers: [
const SliverAppBar(
title: Text('电站详情'),
expandedHeight: 200, // 展开高度
pinned: true, // 折叠后吸顶
flexibleSpace: FlexibleSpaceBar(
background: Image.network(url, fit: BoxFit.cover),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => DeviceTile(items[index]),
childCount: items.length,
),
),
],
)SliverAppBar 的三种行为
| 参数 | 效果 |
|---|---|
pinned: true | 折叠后吸顶保留(导航栏效果) |
floating: true | 向下滚动时立即出现(不用滚到顶) |
snap: true | 配合 floating,滚动停止时自动完全展开或收起 |
| 都不设 | 滚走就没了 |
SliverAppBar(pinned: true, floating: true, snap: true) // 常见组合⚠️ snap 必须配合 floating 使用,否则断言报错。
普通 Widget 怎么塞进 Sliver
SliverToBoxAdapter 是桥梁:
CustomScrollView(
slivers: [
const SliverAppBar(...),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(AppTokens.spacingMd),
child: StatCardsRow(), // 普通 Widget
),
),
SliverList(...),
],
)⚠️ 不要滥用:SliverToBoxAdapter 里的 Widget 会一次性全部构建,没有懒加载。长列表一定要用 SliverList。
自定义吸顶头:SliverPersistentHeader
SliverPersistentHeader(
pinned: true,
delegate: _MyHeaderDelegate(minHeight: 56, maxHeight: 56),
)需要实现 SliverPersistentHeaderDelegate:
class _MyHeaderDelegate extends SliverPersistentHeaderDelegate {
_MyHeaderDelegate({required this.minHeight, required this.maxHeight});
final double minHeight;
final double maxHeight;
@override
double get minExtent => minHeight;
@override
double get maxExtent => maxHeight;
@override
Widget build(context, double shrinkOffset, bool overlapsContent) {
// shrinkOffset:当前收缩了多少像素,可用于做渐变/缩放
final progress = shrinkOffset / maxExtent;
return Container(
color: Color.lerp(Colors.transparent, Colors.white, progress),
child: const Center(child: Text('吸顶栏')),
);
}
@override
bool shouldRebuild(covariant _MyHeaderDelegate oldDelegate) =>
oldDelegate.maxHeight != maxHeight;
}shrinkOffset 是精髓——它告诉你滚动进度,可以做透明度渐变、字号缩放等效果。
练习 4.1:折叠吸顶头
// 粘到 lib/main_playground.dart
class SliverDemo extends StatelessWidget {
const SliverDemo({super.key});
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
const SliverAppBar(
title: Text('电站详情'),
expandedHeight: 220,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: ColoredBox(color: Colors.blue),
),
),
SliverToBoxAdapter(
child: Container(
height: 100,
color: Colors.orange.shade100,
alignment: Alignment.center,
child: const Text('统计卡片区(随滚动)'),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) => ListTile(title: Text('设备 $i')),
childCount: 30,
),
),
],
);
}
}观察:
- 向上滚动,蓝色头部逐渐折叠,最后只剩导航栏吸顶
- 分别去掉
pinned、加上floating: true, snap: true,对比行为差异
自检
- [ ] 知道
ListView与CustomScrollView+ Sliver 的区别 - [ ] 会用
SliverAppBar做折叠吸顶头 - [ ] 知道普通 Widget 要用
SliverToBoxAdapter桥接,且它没有懒加载 - [ ] 知道
snap必须配floating
二、NestedScrollView:嵌套滚动
场景
外层滚动 + 内层 Tab 各自滚动,且滚动是连贯的(滚内层到底后接着滚外层)。
典型:详情页顶部大图 → 吸顶 TabBar → Tab 内容各自是列表。
┌─────────────────┐
│ 大图/头部 │ ← 外层,滚上去消失
├─────────────────┤
│ Tab1 │ Tab2 │ ← 吸顶
├─────────────────┤
│ │
│ Tab 内容列表 │ ← 内层,独立滚动
│ │
└─────────────────┘基本结构
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
const SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(title: Text('详情')),
),
],
body: TabBarView(
controller: _tabController,
children: [
ListView.builder(...), // Tab 1
ListView.builder(...), // Tab 2
],
),
)关键:headerSliverBuilder 放外层头部(Sliver),body 放可滚动的内容。
⚠️ 两个经典坑
坑一:内层列表要设 physics
如果内层滚动不想要独立惯性(希望完全由外层驱动):
ListView.builder(
physics: const NeverScrollableScrollPhysics(), // 禁止内层滚动
shrinkWrap: true,
...
)坑二:shrinkWrap: true 的性能代价
shrinkWrap 会让列表一次性测量所有子项,失去懒加载优势。长列表严禁使用。
只在这种情况用:子项极少(< 20)且必须嵌在非滚动容器里。
项目实例:为什么本项目没用 NestedScrollView
本项目详情页用的是 HeTabbedDetailScaffold(lib/he_components/src/widgets/),Tab 内容各自是独立列表,没有外层联动滚动需求——头部是固定不滚的。
这是个合理的简化:uni-app 老项目的交互本来就是「头部固定 + Tab 切换」,迁移时保持了原样。
所以你在本项目遇到的大概率是:
// 固定头 + Tab + 各自滚动(本项目主流)
Column(children: [
_buildFixedHeader(), // 不滚
TabBar(...),
Expanded(child: TabBarView(...)), // 只有内容区滚
])选型建议:只有当设计稿明确要求「头部跟着滚上去再吸顶」时,才上 NestedScrollView。否则 Column + Expanded 更简单可靠。
自检
- [ ] 知道
NestedScrollView用于「外层头 + 内层 Tab 列表」的联动滚动 - [ ] 知道
shrinkWrap: true会失去懒加载,长列表禁用 - [ ] 能判断什么时候不需要
NestedScrollView
三、LayoutBuilder:按约束而非屏幕尺寸响应
前端对照
| 前端 | Flutter |
|---|---|
@media (max-width: 600px) | LayoutBuilder 判断 constraints.maxWidth |
100% 宽度 | constraints.maxWidth |
核心理念差异
前端媒体查询看的是「屏幕/视口尺寸」;Flutter 的 LayoutBuilder 看的是「父给我的约束」。
后者更准确——同一个 Widget 在不同位置可能拿到完全不同的约束,用 LayoutBuilder 能正确响应。
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
return _buildWideLayout(); // 宽屏:左右两栏
}
return _buildNarrowLayout(); // 窄屏:上下堆叠
},
)项目实例
lib/ui/plant_detail/views/plant_equipment_tab.dart:371-374 —— 按父容器宽度计算卡片尺寸:
Widget _addCard(BuildContext context, RuntimeI18n l10n, AppTokens tokens,
double w, double cardWidth, bool hasPermission) {
final cardHeight = w * 245 / 750;
final iconSize = w * 50 / 750;
...
}这里 w 是外部传入的可用宽度,用比例(/750,即设计稿宽度)算出各元素尺寸——这是本项目适配的核心思路,与 ScreenUtil 的 designSize: Size(750, 1624) 一致。
⚠️ 陷阱:不要在 LayoutBuilder 里做重活
builder 可能被调用多次(每次约束变化)。保持轻量。
自检
- [ ] 理解
LayoutBuilder响应的是父约束,不是屏幕尺寸 - [ ] 能用它实现宽窄屏两套布局
四、MediaQuery:屏幕信息
常用取值
final mq = MediaQuery.of(context);
mq.size; // 屏幕尺寸(逻辑像素)
mq.padding; // 系统遮挡区域(刘海、状态栏、底部 Home 条)
mq.viewInsets; // 被系统 UI 遮挡的部分(**键盘弹起时非零**)
mq.viewPadding; // padding + viewInsets
mq.platformBrightness;// 系统亮度(light / dark)
mq.textScaler; // 系统字体缩放⚠️ 性能陷阱:MediaQuery.of(context) 会让 Widget 依赖整个 MediaQuery
任何 MediaQuery 属性变化(键盘弹起、旋转、系统字体变更)都会触发重建。
精准订阅(Flutter 3.7+ 推荐):
MediaQuery.sizeOf(context); // 只在尺寸变化时重建
MediaQuery.paddingOf(context); // 只在 padding 变化时重建
MediaQuery.viewInsetsOf(context); // 只在键盘等变化时重建
MediaQuery.platformBrightnessOf(context);前端类比:类似 useSyncExternalStore 的 selector,避免无关更新引起 re-render。
项目实例:精准取值
lib/main.dart:158-160:
ThemeMode.system =>
View.of(context).platformDispatcher.platformBrightness == Brightness.dark,这里刻意没有用 MediaQuery.platformBrightnessOf(context),而是直接读 platformDispatcher——因为该 Widget 位于 MaterialApp 之上,子树里没有 MediaQuery,用 MediaQuery.of 会 fallback 成错误值。
lib/main.dart:152-153 的注释解释了这个坑:
// 本组件位于 MaterialApp 之上、子树里没有 MediaQuery,不能用
// MediaQuery.platformBrightnessOf(会 fallback 成 light,跟随系统深色
// 模式时图标亮度会算错),改直接读窗口的平台亮度。这是一个很好的示范:知道 API 的适用边界比记住 API 更重要。
自检
- [ ] 知道
MediaQuery.of的重建范围过大,优先用xxxOf精准方法 - [ ] 知道
viewInsets是键盘高度 - [ ] 知道位于
MaterialApp之上时MediaQuery.of不可用
五、SafeArea 与刘海屏
作用
避开系统遮挡区域(刘海、状态栏、底部 Home 指示条)。
SafeArea(
child: MyContent(),
)前端类比:env(safe-area-inset-top) / viewport-fit=cover。
参数
SafeArea(
top: true, // 避开顶部(默认 true)
bottom: true, // 避开底部(默认 true)
left: true,
right: true,
minimum: EdgeInsets.zero, // 额外最小边距
child: ...,
)⚠️ 常见误用
① Scaffold 的 body 通常不需要 SafeArea 顶部——AppBar 已经处理了状态栏。
Scaffold(
appBar: AppBar(...),
body: SafeArea(child: ...), // ⚠️ 顶部多此一举,但底部有用
)更精准:
body: SafeArea(top: false, child: ...), // 只避底部② 本项目是沉浸式(edge-to-edge)
lib/main.dart:164-171 把系统栏刷成透明:
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: Colors.transparent,
...
),
child: child,
);沉浸式的意思是:内容绘制到系统栏下面(更美观),此时需要自己处理安全区——通常用 SafeArea 或手动 padding。
自检
- [ ] 知道
SafeArea用于避开刘海/Home 条 - [ ] 知道有
AppBar时顶部通常不需要 SafeArea - [ ] 知道本项目是沉浸式,系统栏透明
六、键盘遮挡(前端最容易踩的坑之一)
问题
页面上有个输入框在底部。点击输入 → 键盘弹起 → 输入框被键盘挡住。
前端对照
前端浏览器通常会自动滚动到聚焦元素。Flutter 不会自动处理,需要手动适配。
方案一:resizeToAvoidBottomInsets(最简单)
Scaffold(
resizeToAvoidBottomInsets: true, // 默认值,键盘弹起时自动压缩 body 高度
body: ...,
)设为 true 时,Scaffold 的 body 高度会减去键盘高度,内容自动上移。
⚠️ 失效场景:body 里有 Stack 定位的元素,或 resizeToAvoidBottomInsets: false 时。
方案二:手动加键盘高度 padding
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom, // 键盘高度(未弹起时为 0)
),
child: MyInputArea(),
)这个写法最可靠 —— 键盘没弹起时 viewInsets.bottom 为 0,弹起时自动撑开。
方案三:SingleChildScrollView 包裹
表单页常见做法:
SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: Form(...),
)键盘弹起时,页面可滚动,且底部留出了键盘空间。
⚠️ 陷阱:MediaQuery.of(context) 在这里反而有用
因为你希望键盘变化时重建。所以用 MediaQuery.of(context).viewInsets.bottom 或 viewInsetsOf 都可以,但要意识到这会让 Widget 在键盘弹起时重建(这正是我们想要的)。
项目实例
本项目是竖屏锁定的(lib/main.dart:83):
// 锁定竖屏:App 全局仅允许竖屏方向,不随设备旋转横屏。
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);所以不需要处理横屏适配,键盘处理也相对简单。
自检
- [ ] 知道 Flutter 不会自动处理键盘遮挡
- [ ] 知道
resizeToAvoidBottomInsets的作用与失效场景 - [ ] 会用
MediaQuery.viewInsetsOf(context).bottom手动加 padding
七、响应式 / 自适应
与前端的根本差异
| 前端 | Flutter |
|---|---|
| 媒体查询看视口宽度 | LayoutBuilder 看父约束 |
| 断点(breakpoint)驱动 | 约束驱动 |
| rem / vw 单位 | ScreenUtil(本项目 designSize: 750×1624) |
本项目的适配方案:ScreenUtil 等比缩放
配置 —— lib/main.dart:370-371:
ScreenUtilInit(
designSize: const Size(750, 1624), // 设计稿尺寸(750rpx 宽)
minTextAdapt: true,
...
)设计稿按 750px 宽出图,运行时按屏幕宽度等比缩放。
文字缩放被禁用 —— lib/main.dart:391-395:
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.noScaling, // 固定文字缩放为 1.0
),
child: content,
);为什么禁用:App 字体不跟随系统字体大小更改(否则用户把系统字体调大,UI 会崩)。
⚠️ 禁止:页面层直接用 .w / .h / .sp
SizedBox(height: 32.h) // ❌ 禁止
SizedBox(height: AppTokens.spacingMd) // ✅ 正确原因:所有尺寸统一收口到 AppTokens,便于全局调整与 Figma 对齐。详见 11-组件与主题。
ScreenUtil 双轨制(重要)
| 对象 | 处理方式 |
|---|---|
| 业务 UI | 走 AppTokens.*(内部已含 ScreenUtil 缩放) |
| TDesign 组件内部 | 固定 dp,不要再包 .w/.h |
TDesign 组件内部是固定 dp,在 ScreenUtil 等比环境下外层再缩放会双重缩放——这也是 02 章提到的「TDesign 假溢出」的根因。
自检
- [ ] 知道本项目设计稿基准是 750×1624
- [ ] 知道文字缩放被强制固定为 1.0
- [ ] 禁止在页面层用
.w/.h/.sp,一律走AppTokens - [ ] 知道 TDesign 组件不要再套 ScreenUtil
八、本章速查
| 需求 | 方案 |
|---|---|
| 折叠吸顶头 | SliverAppBar(pinned: true) |
| 滚动即显头 | SliverAppBar(floating: true, snap: true) |
| 普通 Widget 进 Sliver | SliverToBoxAdapter(无懒加载) |
| 懒加载 Sliver 列表 | SliverList + SliverChildBuilderDelegate |
| 自定义吸顶 | SliverPersistentHeader + delegate |
| 外层头 + 内层 Tab | NestedScrollView |
| 按可用宽度分栏 | LayoutBuilder |
| 取屏幕尺寸 | MediaQuery.sizeOf(context) |
| 取键盘高度 | MediaQuery.viewInsetsOf(context).bottom |
| 避开刘海 | SafeArea |
| 键盘顶起输入框 | resizeToAvoidBottomInsets 或手动 padding |
| 项目内所有尺寸 | AppTokens.*(禁用 .w/.h/.sp) |