Flutter 基础体系 · 第 40/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 滚动与 Sliver:Viewport、懒构建、吸顶和自定义布局
滚动界面通常被理解为“一个可以上下移动的列表”,但 Flutter 的滚动体系实际上分成了几个职责不同的层次:
Scrollable处理手势、滚动物理效果和ScrollPosition;Viewport根据当前滚动偏移决定可见区域;Sliver接收Viewport传来的约束,计算自己占据的滚动空间和当前可绘制区域;SliverList、SliverGrid、SliverAppBar等组件实现具体布局;RenderObject层最终执行尺寸计算、绘制和命中测试。
理解这些边界后,ListView、CustomScrollView、懒构建、吸顶和自定义 Sliver 会变成同一套机制的不同用法,而不是互相独立的 API。
一、先建立滚动模型:滚动不是移动整个 Widget 树
1. 滚动位置描述的是内容坐标
设滚动内容在主轴方向上的总长度为:
Viewport 在主轴方向上能够显示的长度为:
滚动偏移量为:
当内容长度大于 Viewport 时,理论上有效滚动范围是:
例如:
- 内容长度
C = 2000 - Viewport 高度
V = 600 - 最大滚动偏移为
2000 - 600 = 1400
如果第一个内容点在滚动前位于坐标 0,滚动后它的绘制位置可以近似表示为:
因此滚动的本质不是修改每个子组件的业务状态,而是修改一个共享的视口偏移,让内容坐标映射到屏幕坐标。
2. Scrollable、ScrollPosition 和 Viewport 的职责
常见的组件关系可以抽象为:
手势 / 鼠标滚轮 / 键盘
│
▼
Scrollable
│
▼
ScrollController
│
▼
ScrollPosition
│ pixels = 当前滚动偏移
▼
Viewport
│
▼
Slivers
Scrollable 负责接收用户输入,并通过 ScrollPhysics 计算拖动、惯性和边界行为。
ScrollPosition 保存当前滚动状态,例如:
- 当前偏移
pixels - 最大滚动范围
maxScrollExtent - Viewport 尺寸
viewportDimension - 是否正在滚动
- 是否已经越过边界
Viewport 不负责“列表项如何排列”。它只负责:
- 接收可用的盒约束;
- 将滚动信息转换成
SliverConstraints; - 依次布局子 Sliver;
- 根据每个 Sliver 返回的
SliverGeometry确定绘制和滚动范围。
所以,Viewport 可以包含列表、网格、固定头部、剩余空间填充区域等完全不同的 Sliver。
二、Viewport 与 Sliver 的约束—几何协议
1. 为什么 Sliver 不能直接放进 Column
普通 Flutter Widget 遵循的是 Box layout 协议:
BoxConstraints → RenderBox → Size
例如,一个 Container 接收最大宽高约束,最终返回一个 Size。
Sliver 遵循的是另一种协议:
SliverConstraints → RenderSliver → SliverGeometry
RenderSliver 返回的不是普通二维尺寸,而是与滚动相关的几何信息,例如:
scrollExtent:该 Sliver 在完整滚动内容中占据的长度;paintExtent:当前 Viewport 内实际可绘制的长度;layoutExtent:当前阶段仍然影响布局推进的长度;maxPaintExtent:内容完全展开时可能达到的绘制长度;cacheExtent:为预加载而额外布局的长度;hitTestExtent:可参与命中测试的范围;hasVisualOverflow:是否可能绘制到当前可见范围之外。
因此,下面的代码是错误的:
Column(
children: [
SliverList(
delegate: SliverChildBuilderDelegate(...),
),
],
);
Column 期待的是 RenderBox 子节点,而 SliverList 创建的是 RenderSliver。两者协议不同,不能直接混用。
如果需要在 Sliver 中放普通 Widget,应使用对应的适配器,例如:
CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Container(
height: 120,
color: Colors.blue,
),
),
],
);
SliverToBoxAdapter 的作用不是简单“包一层”,而是把一个 Box 子树转换成一个 Sliver,使它能够向 Viewport 报告滚动长度和绘制范围。
2. SliverConstraints 中最重要的字段
在竖直滚动的典型场景中,Sliver 会收到类似信息:
| 字段 | 含义 |
|---|---|
scrollOffset |
当前 Sliver 顶部已经被滚走了多少 |
precedingScrollExtent |
前面 Sliver 的总滚动长度 |
remainingPaintExtent |
从当前绘制起点到 Viewport 末端还剩多少可绘制空间 |
crossAxisExtent |
横轴可用空间,竖直滚动时通常是宽度 |
viewportMainAxisExtent |
Viewport 在主轴上的尺寸 |
cacheOrigin、remainingCacheExtent |
当前布局需要覆盖的缓存范围 |
overlap |
前一个 Sliver 是否与当前 Sliver 产生视觉重叠 |
对于一个已经完全滚出顶部的 Sliver:
scrollOffset > 自身长度
它通常仍然需要参与布局,以便向后续 Sliver 报告正确的累计滚动长度,但它的 paintExtent 可以是 0。
3. SliverGeometry 的核心因果关系
假设某个 Sliver 的内容长度为 L,当前滚动偏移为 S,Viewport 剩余可绘制长度为 R。
它实际能够绘制的长度大致受以下条件限制:
其中:
L - S表示还有多少内容没有被滚走;max(..., 0)保证已经滚出 Viewport 后不会产生负绘制长度;R保证不会绘制到当前 Sliver 可用区域之外;P对应paintExtent的基本直觉。
而 scrollExtent 通常仍然是 L,因为它描述的是完整内容长度,不是当前可见长度。
这也是“滚出屏幕后仍占据滚动范围”和“当前不再绘制”可以同时成立的原因。
三、ListView 与 CustomScrollView 的关系
ListView 是一个针对常见列表场景的高层组件。其内部本质上会使用:
ScrollableViewportSliverList或SliverFixedExtentList- 适当的 padding 和默认行为
因此,以下代码:
ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text('Item $index'));
},
);
可以近似理解为一个只包含列表 Sliver 的滚动视图。
当页面需要多个不同类型的滚动区域时,应直接使用 CustomScrollView:
CustomScrollView(
slivers: [
const SliverAppBar(
title: Text('商品'),
pinned: true,
),
SliverToBoxAdapter(
child: BannerWidget(),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ProductTile(index: index),
childCount: 100,
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) => ProductCard(index: index),
childCount: 20,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1.2,
),
),
],
);
这里所有子节点都是 Sliver,因而可以由同一个 Viewport 按照统一的滚动坐标排列。
四、懒构建:为什么 builder 不等于“只构建一个”
1. 懒构建的定义
懒构建是指:列表不会在首次构建时立即创建所有子项,而是根据当前 Viewport 和缓存区域,按需调用 itemBuilder 创建一部分子项。
例如:
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return ListTile(
title: Text('第 $index 项'),
);
},
childCount: 100000,
),
);
这不会因为 childCount 为 100000 就立刻创建十万个 ListTile。childCount 主要用于告诉 Sliver:
- 列表什么时候结束;
- 如何估算滚动范围;
- 越界时何时停止调用 builder。
懒构建降低的是初始 Widget/Element/RenderObject 创建量,但并不意味着内存永远只保留当前屏幕上的几个子项。
2. 可见区域、缓存区域和 builder 调用范围
实际需要布局的范围通常不只有屏幕可见区域,还包括缓存区域:
┌─────────────────────────────┐
│ cache area │
├─────────────────────────────┤
│ viewport │
│ visible items │
├─────────────────────────────┤
│ cache area │
└─────────────────────────────┘
因此,以下判断是不可靠的:
“屏幕上看不到的 item,builder 一定没有被调用。”
Builder 可能因为以下原因提前创建子项:
- Sliver 的缓存区域;
- 滚动方向变化;
- 需要估算滚动范围;
- 子项尺寸未知;
- 键盘、辅助功能或程序滚动导致布局范围变化。
懒构建保证的是按需创建,而不是精确保证“只创建当前可见项”。
3. childCount 与无界列表
如果列表长度已知,应提供 childCount:
SliverChildBuilderDelegate(
builder,
childCount: items.length,
);
如果不提供,builder 必须在索引越界时返回 null:
SliverChildBuilderDelegate(
(context, index) {
if (index >= items.length) {
return null;
}
return ItemTile(item: items[index]);
},
);
不提供 childCount 的结果是 Sliver 只能通过 builder 返回 null 判断结束。若 builder 永远返回 Widget,滚动范围可能被视为无限,maxScrollExtent 也无法稳定确定。
4. 子项状态为什么会丢失
懒列表可能销毁离开缓存范围的子项。若子项内部有状态,例如:
class EditableRow extends StatefulWidget {
const EditableRow({super.key});
@override
State<EditableRow> createState() => _EditableRowState();
}
class _EditableRowState extends State<EditableRow> {
final controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(controller: controller);
}
}
当它离开缓存范围后,状态可能不再保留。需要区分三种情况:
-
状态属于数据
例如输入内容、勾选状态、展开状态。应把它存到模型或状态管理层,而不是依赖某个暂时存在的 State。 -
需要在列表中保持 State
可以使用AutomaticKeepAliveClientMixin,但这会增加内存占用:class KeepAliveRow extends StatefulWidget { const KeepAliveRow({super.key}); @override State<KeepAliveRow> createState() => _KeepAliveRowState(); } class _KeepAliveRowState extends State<KeepAliveRow> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); return const TextField(); } } -
子项被错误复用
当列表数据插入、删除或重新排序时,应使用稳定的Key,例如:ProductTile( key: ValueKey(product.id), product: product, );
Key 解决的是 Element 与数据身份匹配问题,不是让所有列表项永久驻留内存。
5. 固定尺寸可以改善布局估算
如果每个子项主轴尺寸固定,应明确告诉 Sliver:
SliverFixedExtentList(
itemExtent: 56,
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text('Item $index'),
),
childCount: 10000,
),
);
如果尺寸不是固定值,但所有子项可以用同一个原型估算,可以使用 SliverPrototypeExtentList。
如果使用普通 SliverList,子项高度可以不同,但 Viewport 在跳转到远处时可能需要更多布局或依赖估算。固定尺寸并不只是性能优化,也让 scrollExtent 和索引到像素位置之间的关系更明确:
其中 i 是索引,h 是固定主轴尺寸。
五、完整示例:混合 Sliver、懒列表、吸顶和自定义间隔
下面是一个可以直接运行的示例。它包含:
SliverAppBar;- 一个普通 Box 适配成的 Sliver;
- 自定义
SliverGap; - 可懒构建的
SliverFixedExtentList; SliverPersistentHeader吸顶;- 底部
SliverToBoxAdapter; ScrollController生命周期管理。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const ProductPage(),
);
}
}
class ProductPage extends StatefulWidget {
const ProductPage({super.key});
@override
State<ProductPage> createState() => _ProductPageState();
}
class _ProductPageState extends State<ProductPage> {
late final ScrollController _controller;
final List<String> products =
List<String>.generate(1000, (index) => '商品 $index');
@override
void initState() {
super.initState();
_controller = ScrollController();
_controller.addListener(_onScroll);
}
void _onScroll() {
if (!_controller.hasClients) {
return;
}
final position = _controller.position;
// 这里只演示读取滚动状态。
// 实际项目中可以根据 maxScrollExtent - pixels
// 判断是否接近底部并触发分页。
final remaining = position.maxScrollExtent - position.pixels;
if (remaining < 300) {
// 触发分页时必须自行防止重复请求。
// 例如使用 isLoading、请求序列号或取消机制。
}
}
@override
void dispose() {
_controller.removeListener(_onScroll);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
controller: _controller,
slivers: [
SliverAppBar(
title: const Text('商品列表'),
pinned: true,
expandedHeight: 180,
flexibleSpace: FlexibleSpaceBar(
background: Container(
color: Colors.indigo,
alignment: Alignment.bottomLeft,
padding: const EdgeInsets.all(20),
child: const Text(
'滚动到这里观察吸顶效果',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
),
),
SliverToBoxAdapter(
child: Container(
height: 100,
color: Colors.amber.shade100,
alignment: Alignment.center,
child: const Text('这是一个普通 Box Widget'),
),
),
const SliverGap(16),
SliverPersistentHeader(
pinned: true,
delegate: _SectionHeaderDelegate(
minExtentValue: 48,
maxExtentValue: 48,
child: Container(
color: Colors.white,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Text(
'推荐商品',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
),
),
),
),
),
SliverFixedExtentList(
itemExtent: 64,
delegate: SliverChildBuilderDelegate(
(context, index) {
final product = products[index];
return ListTile(
key: ValueKey(product),
leading: CircleAvatar(
child: Text('$index'),
),
title: Text(product),
subtitle: const Text('由 SliverChildBuilderDelegate 懒构建'),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('点击了 $product')),
);
},
);
},
childCount: products.length,
),
),
const SliverGap(24),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton(
onPressed: () {
if (!_controller.hasClients) {
return;
}
_controller.animateTo(
0,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
);
},
child: const Text('回到顶部'),
),
),
),
],
),
);
}
}
class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
_SectionHeaderDelegate({
required this.minExtentValue,
required this.maxExtentValue,
required this.child,
});
final double minExtentValue;
final double maxExtentValue;
final Widget child;
@override
double get minExtent => minExtentValue;
@override
double get maxExtent => maxExtentValue;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
bool shouldRebuild(covariant _SectionHeaderDelegate oldDelegate) {
return minExtentValue != oldDelegate.minExtentValue ||
maxExtentValue != oldDelegate.maxExtentValue ||
child != oldDelegate.child;
}
}
class SliverGap extends LeafRenderObjectWidget {
const SliverGap(this.extent, {super.key});
final double extent;
@override
RenderSliverGap createRenderObject(BuildContext context) {
return RenderSliverGap(extent);
}
@override
void updateRenderObject(
BuildContext context,
RenderSliverGap renderObject,
) {
renderObject.extent = extent;
}
}
class RenderSliverGap extends RenderSliver {
RenderSliverGap(this._extent);
double _extent;
double get extent => _extent;
set extent(double value) {
if (value == _extent) {
return;
}
_extent = value;
markNeedsLayout();
}
@override
void performLayout() {
final double paintExtent = calculatePaintOffset(
constraints,
from: 0,
to: extent,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: 0,
to: extent,
);
geometry = SliverGeometry(
scrollExtent: extent,
paintExtent: paintExtent,
layoutExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: extent,
hitTestExtent: 0,
hasVisualOverflow: extent > paintExtent,
);
}
}
这个示例的布局过程
以顶部的 SliverToBoxAdapter 为例:
- 它的子 Widget 先按 Box 规则得到高度
100; - 适配器把这个高度转换成
scrollExtent = 100; - Viewport 根据滚动偏移计算它是否还可见;
- 如果已经完全滚出屏幕,
paintExtent变为0; - 后续 Sliver 仍然会把这
100计入前置滚动长度。
SliverFixedExtentList 的布局更适合通过索引计算:
第 0 项:0 ~ 64
第 1 项:64 ~ 128
第 2 项:128 ~ 192
在不考虑分隔线的情况下,1000 项列表的滚动长度为:
这使得远距离跳转和范围估算比任意高度的普通 SliverList 更直接。
六、吸顶:pinned 到底改变了什么
1. 普通 Sliver 的默认行为
普通 Sliver 按内容坐标随滚动偏移移动。当顶部离开 Viewport 时,它也离开屏幕。
一个普通头部的状态可以简化为:
未滚动: 在内容顶部显示
向上滚动: 逐渐离开顶部
继续滚动: 完全不可见
2. pinned 的含义
pinned: true 表示该头部在正常滚动范围之外仍保持在 Viewport 的边缘。
例如:
SliverPersistentHeader(
pinned: true,
delegate: MyHeaderDelegate(),
);
它不是把头部从内容中复制一份,而是由 Sliver 在布局时报告特殊的 SliverGeometry,使头部即使原始内容位置已经滚出,也仍能在顶部保留一部分绘制区域。
对于 SliverAppBar:
SliverAppBar(
pinned: true,
expandedHeight: 200,
flexibleSpace: const FlexibleSpaceBar(
title: Text('标题'),
),
);
通常会经历:
展开状态
↓ 向上滚动
折叠状态
↓ 继续滚动
折叠后的最小高度仍停留在顶部
这里的 expandedHeight 是展开状态的最大高度,toolbarHeight、顶部安全区和其他配置共同影响折叠后的最小高度。
3. SliverPersistentHeaderDelegate 的两个尺寸
自定义吸顶头部时,必须正确理解:
double get minExtent;
double get maxExtent;
maxExtent:头部完全展开时的高度;minExtent:头部折叠后仍保留的最小高度。
它们必须满足:
例如:
class HeaderDelegate extends SliverPersistentHeaderDelegate {
@override
double get minExtent => 48;
@override
double get maxExtent => 120;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
final progress = shrinkOffset / (maxExtent - minExtent);
return Opacity(
opacity: 1 - progress.clamp(0, 1),
child: const SizedBox.expand(
child: ColoredBox(
color: Colors.blue,
),
),
);
}
@override
bool shouldRebuild(covariant HeaderDelegate oldDelegate) {
return false;
}
}
当头部从 120 收缩到 48 时,理论收缩距离为:
shrinkOffset 表示已经消耗的收缩距离,通常需要将其限制在 [0, 72] 范围内再计算动画进度。
4. floating 和 pinned 不是同一个功能
SliverAppBar(
pinned: true,
floating: true,
);
两者作用不同:
pinned:滚过头部后,头部仍固定在边缘;floating:用户反向滚动时,头部可以更早重新出现,而不必等内容滚回其原始位置。
因此可能出现:
| 配置 | 行为 |
|---|---|
都为 false |
普通滚动头部 |
pinned: true |
折叠后保留在顶部 |
floating: true |
反向滚动时提前出现 |
两者都为 true |
既吸顶,又能在反向滚动时提前出现 |
SliverAppBar 还提供 snap,用于让浮动头部以吸附动画出现。使用 snap 时必须满足其 API 约束,通常需要同时启用 floating;不能把 snap 当成普通 pinned 的替代品。
5. 多个吸顶头部的层叠
如果连续放置多个 pinned 的 SliverPersistentHeader,它们可能在顶部依次占据空间:
┌──────────────────────┐
│ 页面 AppBar │
├──────────────────────┤
│ 分组标题 A │
├──────────────────────┤
│ 分组标题 B │
├──────────────────────┤
│ 列表内容 │
└──────────────────────┘
每个头部的 minExtent 都会影响后续头部可以使用的顶部空间。若背景设置为透明,内容可能从头部下方透出;这不是 Sliver 几何错误,而是绘制顺序和背景颜色的结果。
七、shrinkWrap:为什么它经常导致性能问题
1. 普通 Viewport 的尺寸来源
如果 CustomScrollView 位于 Scaffold.body,它通常会获得有限高度:
父级提供有限高度
↓
Viewport 知道自己的高度
↓
只布局可见范围和缓存范围
这是懒列表最理想的环境。
2. shrinkWrap: true 改变了什么
当滚动视图位于 Column 中时,下面的写法经常产生约束错误:
Column(
children: [
const Text('标题'),
ListView.builder(
itemBuilder: ...,
),
],
);
错误的根本原因不是“ListView 不能放进 Column”,而是:
Column在主轴方向可能给子项不确定的高度;- 普通
ListView需要一个有限的 Viewport 高度; - 它无法判断自己应该占多高。
可以使用 Expanded:
Column(
children: [
const Text('标题'),
Expanded(
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return Text('Item $index');
},
),
),
],
);
Expanded 给列表一个有限高度,列表仍然拥有独立 Viewport。
另一种方式是:
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: 20,
itemBuilder: (context, index) {
return Text('Item $index');
},
);
这表示让内部列表根据全部子项计算自己的高度,并禁止内部滚动,通常作为外层滚动视图中的一个局部内容。
3. 为什么 shrinkWrap 可能破坏懒布局
shrinkWrap 的尺寸依赖内容总高度。为了知道总高度,框架可能必须布局更多甚至全部子项:
普通列表:
Viewport 高度已知 → 只需布局当前附近项目
shrinkWrap 列表:
需要知道全部内容高度 → 可能需要布局大量项目
因此,下面的结构对大数据量不利:
ListView.builder(
shrinkWrap: true,
itemCount: 100000,
itemBuilder: ...,
);
如果整个页面本来就应该由一个滚动区域组成,更合适的写法是把内容合并到一个 CustomScrollView:
CustomScrollView(
slivers: [
const SliverToBoxAdapter(child: Text('页面标题')),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Text('Item $index'),
childCount: 100000,
),
),
],
);
这样多个内容区共享同一个 Viewport,避免嵌套滚动和不必要的完整测量。
八、自定义布局:先选择高层 Sliver,再实现 RenderSliver
“自定义布局”可以有三个层次,不应一开始就直接写渲染对象。
1. 使用现有 Sliver 组合布局
例如:
CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Text('Item $index'),
childCount: 20,
),
),
),
],
);
SliverPadding、SliverToBoxAdapter、SliverFillRemaining、SliverList、SliverGrid 已经覆盖大多数页面需求。
2. 使用 SliverLayoutBuilder 根据约束动态选择布局
如果布局需要知道当前 Sliver 的宽度或剩余空间,可以使用:
SliverLayoutBuilder(
builder: (context, constraints) {
final columns = constraints.crossAxisExtent >= 700 ? 4 : 2;
return SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.2,
),
delegate: SliverChildBuilderDelegate(
(context, index) => ProductCard(index: index),
childCount: 40,
),
);
},
);
这里的 crossAxisExtent 对竖直滚动通常就是当前可用宽度。它比直接读取 MediaQuery.size 更接近实际布局约束,因为父级可能不是全屏宽度。
需要注意:窗口宽度变化会触发重新构建;如果布局切换后子项身份发生变化,仍应使用稳定 Key 管理状态。
3. 直接实现 RenderSliver
当需求本身不属于“现有 Sliver 的排列组合”,例如:
- 根据滚动偏移绘制特殊刻度;
- 只占据滚动空间但不包含 Box 子项;
- 实现固定规则的非标准可视化布局;
- 对子项进行自定义位置和裁剪;
才有必要实现 RenderSliver。
前面的 SliverGap 就是一个最小的自定义 Sliver。它没有子节点,只表示一段固定滚动空间。
其核心布局代码是:
@override
void performLayout() {
final double paintExtent = calculatePaintOffset(
constraints,
from: 0,
to: extent,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: 0,
to: extent,
);
geometry = SliverGeometry(
scrollExtent: extent,
paintExtent: paintExtent,
layoutExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: extent,
hitTestExtent: 0,
hasVisualOverflow: extent > paintExtent,
);
}
逐项解释:
extent是完整内容长度;calculatePaintOffset根据scrollOffset和 Viewport 剩余空间计算当前可见长度;calculateCacheOffset计算缓存区域需要覆盖的长度;scrollExtent必须报告完整滚动长度,否则后续滚动范围会错误;hitTestExtent: 0表示这个间隔不应响应点击;- 修改
extent后调用markNeedsLayout(),否则框架不会重新计算几何信息。
如果自定义 Sliver 包含 RenderBox 子节点,还必须额外处理:
- 子节点布局约束;
- 子节点在主轴和横轴上的位置;
paint时的偏移;- 命中测试;
- 语义树;
- 子节点变化后的重新布局;
- 负尺寸、无穷尺寸和非法几何值。
错误的 SliverGeometry 可能表现为:
- 内容突然跳动;
- 滚动范围不正确;
- 头部覆盖内容;
paintExtent超出允许范围;- 调试模式抛出 geometry 断言;
- 点击区域与视觉位置不一致。
自定义 RenderObject 的主要风险不在“能不能画出来”,而在于是否完整遵守布局、绘制、命中测试和语义协议。
九、程序滚动与生命周期
1. ScrollController 必须管理生命周期
如果在 State 中创建控制器,应在 dispose 中释放:
class PageState extends State<Page> {
late final ScrollController controller;
@override
void initState() {
super.initState();
controller = ScrollController();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
如果调用:
controller.animateTo(...);
必须注意它是否已经连接到 Scrollable:
if (controller.hasClients) {
controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
在 initState 期间,滚动视图通常还没有完成挂载,此时直接读取 position 可能失败。需要等到首帧之后:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !controller.hasClients) {
return;
}
controller.jumpTo(200);
});
2. jumpTo 与 animateTo
jumpTo立即修改偏移;animateTo在一段时间内产生滚动动画;- 两者都不能把偏移稳定地设置在有效范围之外;
- 动画期间,用户新的拖动可能影响或中断当前动画。
如果页面数据更新导致 maxScrollExtent 改变,之前保存的像素偏移不一定仍然有效。对于分页列表,不能只保存“上次像素位置”作为数据恢复依据,尤其在头部插入内容时,原有项目的像素位置会整体变化。
3. 滚动监听中的分页请求
一个常见触发条件是:
final remaining =
position.maxScrollExtent - position.pixels;
if (remaining < 300) {
loadMore();
}
但这个条件可能在同一次滚动过程中触发多次,必须增加状态控制:
bool isLoading = false;
bool hasMore = true;
Future<void> loadMore() async {
if (isLoading || !hasMore) {
return;
}
isLoading = true;
try {
final nextPage = await repository.fetchNextPage();
if (!mounted) {
return;
}
setState(() {
items.addAll(nextPage.items);
hasMore = nextPage.hasMore;
});
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
真实网络分页还需要处理:
- 请求失败后的重试;
- 页面销毁后的异步回调;
- 快速滚动导致的重复请求;
- 返回结果顺序错乱;
- 服务端没有更多数据;
- 数据插入导致当前滚动位置变化。
滚动监听只负责观察位置,不应直接假设网络请求一定成功。
十、嵌套滚动:两个 Viewport 不是一个滚动状态
下面的结构包含两个独立滚动区域:
Column(
children: [
Expanded(
child: ListView.builder(...),
),
SizedBox(
height: 200,
child: ListView.builder(...),
),
],
);
它们分别拥有自己的:
ScrollableScrollPosition- 滚动范围
- 手势竞争关系
如果需求是“顶部 AppBar 折叠后,内部 Tab 列表继续滚动”,不能简单堆两个 ListView。通常需要 NestedScrollView,并使用 headerSliverBuilder 放置外层 Sliver:
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
pinned: true,
expandedHeight: 200,
flexibleSpace: const FlexibleSpaceBar(
title: Text('首页'),
),
),
];
},
body: TabBarView(
children: [
ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text('Tab 1 - $index'));
},
),
ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text('Tab 2 - $index'));
},
),
],
),
);
NestedScrollView 会协调外层和内层滚动位置,但它引入了更复杂的联动状态,包括:
- 外层 Header 是否已经折叠;
- 内层列表是否位于顶部;
- 用户手势由哪一层消费;
- 吸顶头部与内层内容如何处理重叠;
- 不同 Tab 的滚动位置是否分别保存。
因此,只有确实需要联动折叠头部和内部滚动时才使用嵌套滚动;普通页面优先使用一个 CustomScrollView。
十一、常见失败表现与诊断方法
1. Vertical viewport was given unbounded height
通常表示竖直滚动组件没有获得有限高度。检查它的父级:
- 是否直接放在
Column中; - 是否缺少
Expanded或Flexible; - 是否位于另一个竖直滚动视图内部;
- 是否错误地使用了无限高度约束。
选择方案时遵循布局语义:
需要独立滚动区域 → 给内部列表有限高度,例如 Expanded
需要一个统一页面滚动 → 合并为 CustomScrollView
只是外层滚动中的小内容 → shrinkWrap + 禁止内部滚动
2. 列表显示不全或滚动条范围异常
常见原因包括:
- builder 没有正确返回
null; childCount与实际数据长度不一致;- 自定义 Sliver 的
scrollExtent错误; - 在列表构建后直接修改数据但没有正确触发状态更新;
- 子项使用了不稳定的 Key;
- 异步分页返回顺序错误。
诊断时应先确认:
debugPrint('pixels=${controller.position.pixels}');
debugPrint('max=${controller.position.maxScrollExtent}');
debugPrint('viewport=${controller.position.viewportDimension}');
如果 maxScrollExtent 明显小于预期,重点检查 Sliver 是否报告了完整内容长度,而不是只报告当前可见长度。
3. itemBuilder 被调用次数超出预期
不要把 builder 调用次数等同于屏幕可见数量。应分别检查:
- 是否启用了缓存;
- 是否在调试模式下观察,调试模式不能直接代表发布性能;
- 子项尺寸是否完全未知;
- 是否发生了尺寸变化或窗口变化;
- 是否使用了
shrinkWrap; - 是否因为 Key 或父节点变化导致子树重建。
builder 内不应执行不可逆副作用,例如:
itemBuilder: (context, index) {
repository.sendAnalytics(); // 不应这样做
return Text('Item $index');
}
因为 builder 可能提前调用、重复调用或在滚动过程中重新调用。数据请求和埋点应放在明确的状态事件中。
4. 吸顶头部覆盖内容
常见原因不是 pinned 本身,而是:
- 头部背景透明;
- 多个 pinned 头部的最小高度没有统一计算;
- 自定义绘制内容超出了自己的几何范围;
- 在
NestedScrollView中没有考虑内外层重叠; - 使用了错误的
overlapsContent逻辑。
如果头部需要遮挡下方内容,应提供明确的背景色;如果需要阴影,可以根据 overlapsContent 控制:
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Material(
elevation: overlapsContent ? 4 : 0,
color: Colors.white,
child: const Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('分组标题'),
),
),
);
}
十二、不同平台的差异
Android 与 iOS
默认滚动物理和边界视觉可能不同。典型差异包括:
- Android 常见边界效果偏向
ClampingScrollPhysics; - iOS 常见边界效果偏向带回弹的
BouncingScrollPhysics; - 系统滚动条和触摸反馈不同;
- 状态栏、安全区和导航栏处理方式不同。
这些是 Flutter 平台适配行为和主题配置共同影响的结果,不应在业务代码中假定某一种物理效果永远存在。
桌面端
桌面端除了触摸和触控板,还可能接收:
- 鼠标滚轮;
- 鼠标拖动滚动条;
- 键盘方向键;
- Page Up、Page Down;
- Home、End;
- 鼠标悬停滚动。
桌面端的可拖动行为受 ScrollBehavior 和平台指针设备配置影响。不要假设所有平台都支持“鼠标左键拖动内容滚动”。
如果自定义 ScrollBehavior,应明确知道自己修改了哪些输入设备和滚动物理,而不是为了统一视觉效果简单覆盖全部默认行为。
Web
Web 上还要考虑:
- 浏览器自身滚动和 Flutter Viewport 的关系;
- 鼠标滚轮事件的粒度;
- 键盘焦点是否在可滚动区域;
- URL、浏览器后退和页面状态恢复;
- 大量 DOM/Canvas 绘制带来的性能差异。
Flutter Web 中的懒构建仍然适用,但“少创建 Widget”不等于“浏览器一定少做所有绘制工作”。应使用 Flutter DevTools 和浏览器工具分别观察 Widget 构建、Frame、绘制和输入事件。
十三、性能取舍应从几何模型出发
1. 大列表优先使用真正的 Sliver 懒布局
适合大数据量的结构是:
CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => RowItem(index: index),
childCount: 100000,
),
),
],
);
不适合大数据量的结构是先创建完整列表:
SingleChildScrollView(
child: Column(
children: List.generate(
100000,
(index) => RowItem(index: index),
),
),
);
后者在进入滚动前就需要创建大量 Widget 子树,失去了 Sliver 的懒构建优势。
2. 统一滚动通常比嵌套滚动更容易估算
如果一个页面包含:
- 顶部横幅;
- 筛选条;
- 分组标题;
- 商品列表;
- 底部操作区;
通常可以统一放进一个 CustomScrollView。这样只有一个主滚动偏移,头部、列表和底部内容在同一个滚动坐标系中,吸顶和程序滚动也更容易推导。
3. 不要为了“看起来更快”盲目启用 KeepAlive
KeepAlive 会保留子项的 Element、State 和 RenderObject。它适合保存用户正在编辑的表单,但对几万条都启用 KeepAlive 的列表,会把懒回收变成长期内存占用。
正确判断标准不是:
“列表项有状态,所以全部 KeepAlive。”
而是:
“该状态是否能够从数据恢复?如果不能,是否值得为它支付长期内存成本?”
十四、选择 API 的判断路径
可以按下面的因果关系选择组件:
只有一个普通列表
└─ ListView.builder
列表项主轴尺寸固定
└─ ListView.builder(itemExtent: ...)
或 SliverFixedExtentList
列表和网格混排、需要吸顶头部
└─ CustomScrollView + 多个 Sliver
普通 Widget 需要放进 Sliver
└─ SliverToBoxAdapter
需要剩余空间填充
└─ SliverFillRemaining
需要根据 Viewport 约束切换布局
└─ SliverLayoutBuilder
需要自定义滚动几何、绘制或子节点排列
└─ 自定义 RenderSliver
其中最后一项应作为协议层扩展,而不是普通布局的默认起点。只要 SliverList、SliverGrid、SliverPadding、SliverPersistentHeader 等现有组件能够表达需求,就不需要承担自定义渲染对象的维护成本。
Flutter 的滚动体系最终可以归结为一个稳定的协议:
Viewport 根据滚动位置提供约束,Sliver 根据约束报告几何信息;懒构建决定哪些子项需要出现,吸顶通过几何和绘制规则改变头部的可见行为,自定义布局则直接参与这套约束—几何协议。理解这条数据流,才能准确判断一个滚动问题究竟属于数据状态、Box 约束、Viewport 管理,还是 Sliver 几何计算。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 手势系统:Hit Test、Arena、Recognizer 和冲突处理
- 下一篇:Flutter 响应式与自适应:约束、断点、平台和窗口尺寸
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论