写 Flutter 列表写了三年,真正让我对 Sliver 这个机制产生敬畏心的,是一次次照着 iOS 通讯录“抄作业”的过程。iOS 通讯录核心就那几件事:A-Z 分组、吸顶的当前字母、右侧一长条字母索引、点击或滑动索引条列表跟着走。听起来不难,真用 Flutter 做的时候,坑全埋在滚动体系里。这个系列已经写到第 13 篇,我这次只把“高级滚动”这件事拆开讲,目标是让已经会用 CustomScrollView 的人,也能做出一套接近 iOS 通讯录手感的分组索引列表。
1. 先拆解 iOS 通讯录的滚动和索引体验
1.1 通讯录到底做对了什么
通讯录的体验不是靠哪个炫酷动画撑起来的,而是三个细节叠加:第一,列表永远按字母分组,滑动时顶部吸着当前分组的字母;第二,右侧索引条一直在,长按或滑动可以快速改变当前分组;第三,列表滚动跟手,不管多少联系人,都不会出现点击索引后“等半天才动”的感觉。
把这三个细节拆成技术点,对应到 Flutter 里就是:
- A-Z 分组和吸顶标题:
SliverPersistentHeader - 列表主体与滚动控制:
CustomScrollView+SliverList - 右侧索引条:一个悬浮在列表上方的常规 Widget,配合
ScrollController做跳转
很多人一上来就用 ListView.builder 加 ScrollController,这不是不能用,但真要做吸顶分组头和索引联动,普通 ListView 的组合会让你不断给自己打补丁:一会儿加 Stack,一会儿算偏移,一会儿发现某个事件被列表滚动拦截。最后代码能跑,但手感总差一点。
1.2 Sliver 和普通 ListView 的分水岭
理解 Sliver 之前,先把思维转过来:ListView 是一个完整可滚动组件,里面已经帮你封装好了一个滚动视口;而 Sliver 是“可滚动内容的一部分”,它本身不拥有滚动视口,所有 Sliver 都被塞进同一个 CustomScrollView 里统一调度。
我习惯拿乐高积木类比。ListView 是一块拼好的整体模型,想在里面混入吸顶头、悬浮索引条、无限加载尾巴,就得拆外壳;CustomScrollView 是一块底板,上面每一个 Sliver 都是一块积木,头是头、列表是列表、底部 loading 是另一块积木,全部共享同一个滚动位置。做 iOS 通讯录这种多组件联动的场景,Sliver 天然就是对的方案。
dart复制CustomScrollView(
slivers: [
SliverToBoxAdapter(child: TopHeader()),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ContactRow(contact: contacts[index]),
childCount: contacts.length,
),
),
],
)
这段代码看起来简单,但它意味着顶部的 TopHeader 和下面的联系人列表在同一个滚动体系里,后面要做吸顶、索引跳转时,所有滚动的坐标系都是统一的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 Sliver 把通讯录主列表搭起来
2.1 数据模型与拼音分组
做通讯录第一步不是写 UI,是先把数据准备好。中文联系人必须转拼音,否则 A-Z 分组就是空的。工程里我一般直接用 lpinyin 或者 pinyin 这类包做转换,先确定每个联系人姓名的拼音首字母。
dart复制class Contact {
final String id;
final String name;
final String pinyin;
Contact({required this.id, required this.name, required this.pinyin});
String get initial {
if (pinyin.isEmpty) return '#';
return pinyin[0].toUpperCase();
}
}
这里有几个容易忽略的点:非字母开头联系人统一归到 #;多音字在转拼音时默认取常用读音,如果需要更准,需要额外维护一个多音字表;initial 的值在列表渲染过程中会高频访问,最好在构造时算好缓存,不要在 build 里反复调用 pinyin[0].toUpperCase()。
2.2 主列表用 SliverList 而不是 ListView.builder
主列表一旦决定用 CustomScrollView,内部就不能再直接放 ListView.builder,否则就会出现“一个滚动视口套另一个滚动视口”的经典错误。此时该用的是 SliverList 加 SliverChildBuilderDelegate,或者行高固定时用 SliverFixedExtentList。
通讯录的每一行高度通常是一致的,我建议直接上 SliverFixedExtentList,因为 Sliver 在懒加载时如果知道每一项的固定高度,就不需要反复做布局测算,滚动和索引跳转都会更稳。
dart复制SliverFixedExtentList(
itemExtent: 56,
delegate: SliverChildBuilderDelegate(
(context, index) {
final contact = contacts[index];
return ListTile(
leading: CircleAvatar(child: Text(contact.initial)),
title: Text(contact.name),
);
},
childCount: contacts.length,
),
)
这里有个经验:把分组的 section header 从列表行里拆出去。很多人喜欢在行 builder 里判断“当前联系人首字母和前一个不同,就多渲染一个标题”,这样确实能实现分组,但会引入两个问题:行高不再固定,索引跳转时偏移量变得很难算;吸顶分组头也要额外处理,容易和列表内的标题重复。iOS 通讯录的吸顶标题本来就是独立的,列表行里不需要再放一份。
2.3 给列表项一个稳定的 Key
通讯录列表经常会有删除联系人、分组变化、头像更新这些操作。在 Sliver 里,每一项最好都带上一个稳定的 ValueKey,用联系人 ID,不要用下标。
dart复制ListTile(
key: ValueKey(contact.id),
leading: CircleAvatar(child: Text(contact.initial)),
title: Text(contact.name),
)
稳定的 key 能让 Flutter 在 rebuild 时正确复用元素,避免因为列表顺序变化导致 TextField 状态串行、滑动位置错乱这些诡异问题。这个问题在普通 ListView 里也存在,但在 Sliver 组合场景下更容易被忽视,因为索引条和列表联动时,你会怀疑是自己坐标算错了,最后才发现是元素状态没对应上。
3. 右侧字母索引条的完整实现
3.1 索引条的布局和命中区域
右侧索引条本质上就是一个普通 Widget,悬浮在列表上方。最稳的做法是用 Stack:底下是 CustomScrollView,上面用 Positioned 把索引条放在右侧。
dart复制Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [...],
),
Positioned(
top: 80,
right: 2,
bottom: 80,
width: 28,
child: _IndexBar(
letters: _letters,
onSelect: _jumpToLetter,
),
),
],
)
_IndexBar 内部直接用 GestureDetector 处理点击和拖拽。这里最需要注意的是 behavior: HitTestBehavior.opaque,因为字母之间的空隙也需要能命中,不加这个参数,手指按在字母缝隙里事件就落到 CustomScrollView 上去了,体验会断断续续。
dart复制class _IndexBar extends StatelessWidget {
final List<String> letters;
final ValueChanged<String> onSelect;
const _IndexBar({required this.letters, required this.onSelect});
@override
Widget build(BuildContext context) {
const double itemHeight = 18;
return LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => _handlePosition(details.localPosition.dy),
onVerticalDragUpdate: (details) =>
_handlePosition(details.localPosition.dy),
child: Column(
children: [
for (final letter in letters)
SizedBox(
height: itemHeight,
width: 28,
child: Center(child: Text(letter)),
),
],
),
);
},
);
}
}
localPosition.dy 一定要用相对于索引条本身的位置,不要用全局坐标。否则屏幕上方有状态栏、有自定义导航栏时,坐标偏移一点,字母就按错了。
3.2 从字母到列表偏移的计算
当字母条被点击或滑动,关键是把字母映射成列表滚动偏移。我的做法是:提前把每个字母对应的第一个联系人下标算好,乘以固定行高,得到目标偏移。
dart复制int firstIndexForLetter(String letter) {
return contacts.indexWhere((contact) => contact.initial == letter);
}
void _jumpToLetter(String letter) {
final index = firstIndexForLetter(letter);
if (index == -1) return;
final target = index * _rowExtent;
_scrollController.animateTo(
target.clamp(0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
}
有人会问:为什么不用 GlobalKey 加 Scrollable.ensureVisible?我之前也试过,Sliver 是懒加载的,远处联系人的 context 根本还没有被构建出来,ensureVisible 并不总是可靠。除非每次跳转前把整段列表都构建一遍,否则性能会很差。固定的行高反而让计算变得很简单,这也是我把 section header 从列表行里拆出来的核心原因之一。
3.3 索引条滑动时避免过度抖动
onVerticalDragUpdate 是高频事件,手指稍微动一下就可能触发几十次回调。如果每次都调用 animateTo,动画还没播完又被下一个打断,列表就会抖。我实际处理是在回调里判断字母是否变化,只有字母真的变了才触发跳转。
dart复制String? _lastSelectedLetter;
void _handlePosition(double dy) {
final index = (dy / _indexBarItemHeight).floor().clamp(0, letters.length - 1);
final letter = letters[index];
if (letter == _lastSelectedLetter) return;
_lastSelectedLetter = letter;
_jumpToLetter(letter);
}
在拖拽结束时,再把 _lastSelectedLetter 重置,这样下一次点击同一个字母也能正常触发。这个细节对体验的影响特别大,不加限制的话,索引条滑动会变成“一顿一顿”。
4. 吸顶分组头与精确跳转的细节
4.1 SliverPersistentHeader 的 delegate 写法
吸顶分组头用的是 SliverPersistentHeader。通讯录场景下,我们不需要它展开或收起,所以 minExtent 和 maxExtent 保持一致即可。
dart复制class SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
final String title;
final double height;
SectionHeaderDelegate({required this.title, required this.height});
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
height: height,
color: const Color(0xFFF2F2F2),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16),
child: Text(title),
);
}
@override
double get minExtent => height;
@override
double get maxExtent => height;
@override
bool shouldRebuild(covariant SectionHeaderDelegate oldDelegate) =>
oldDelegate.title != title || oldDelegate.height != height;
}
注意 shouldRebuild 必须写对。它决定滚动时 Flutter 要不要重新构建这个 header,如果只返回 false,标题就永远不更新;如果每次都返回 true,又会在高频滚动时不停重建。正确做法是比较内容字段。
4.2 标题跟随滚动的更新策略
主列表里已经没有分组标题了,吸顶的这个 header 要显示“当前滚到哪个字母”。最直接的办法是监听 ScrollController,根据 offset 算出当前联系人下标,再更新标题。
dart复制_scrollController.addListener(() {
final index = (_scrollController.offset / _rowExtent)
.floor()
.clamp(0, contacts.length - 1);
_sectionTitle.value = contacts[index].initial;
});
这里我用的是 ValueNotifier<String>,而不是 setState。为什么?因为滚动监听器的回调频率很高,如果每帧都 setState 重建整个 CustomScrollView,列表会明显掉帧。ValueNotifier 只通知吸顶 header 那块 UI 更新,范围小,成本低。
如果你项目里已经用了 Provider,也很简单:把这个 ValueNotifier 放进一个 ChangeNotifier 里,字母条、吸顶头、列表之间通过同一个状态对象通信。这就是“flutter provider 怎么用”最常见的落地场景,解决的是跨组件共享状态,同时避免大范围 rebuild。
4.3 跳转偏移量容易被哪些因素带偏
行高固定的前提下,index * itemExtent 只是基准。实际项目里往往还有一些额外因素,我踩过的坑主要有三个:
- 列表顶部如果放了
SliverToBoxAdapter,比如搜索框、公告条,那么跳转目标必须再加上这部分高度。 - 吸顶 header 如果是
pinned: true,它自身会盖在列表上,跳转后第一个联系人可能被 header 挡住。我的处理是保持目标偏移计算一致,然后通过debugPrint打印当前 offset 和预期 offset,手工核对一遍。 - 联系人列表如果来自后端,排序规则、分组规则要和本地索引条完全一致,否则点“W”跳过去看到的是“V”开头的联系人,最容易被当成 bug。
这些因素不会出现在官方文档里,但几乎每个做通讯录联动的人都会遇到。我的建议是:先把“列表行高固定 + 标题拆出列表行”这个大原则守住,再逐个核对偏移量,问题就能收敛。
5. 性能调优与常见问题排查
5.1 大批量联系人下的滚动性能
联系人上千条是常态。Sliver 在滚动性能上的优势是 lazy build,但这不代表可以乱写。以下几个点是我在 iOS 真机上反复优化后的结论:
- 优先
SliverFixedExtentList,itemExtent尽量用常量,不要用MediaQuery动态计算出来的值。 - 行 Widget 保持简单,不要每行都创建新的
BoxShadow或大量Opacity。 - 把每一行包上
RepaintBoundary,可以避免滚动时相邻行被频繁重绘,但这会增加内存占用,通常只在行内动画较多时才值得用。 cacheExtent不是越大越好。调大能让远处的行提前构建,滚动更平滑,但也意味着更多 Widget 常驻内存。通讯录这种简单列表,默认值基本够用。
至于现在 Flutter 在 iOS 上默认使用的 Impeller 渲染方案,我的体感是:普通文字列表和滚动性能相比旧方案更稳定,尤其中文文字渲染和快速滑动场景下不容易出现原先那种模糊闪动。但 Impeller 不是万能药,真正决定掉不掉帧的,还是你列表行里画了什么、有没有做无意义的 rebuild。
5.2 索引条与列表状态的同步
索引条滑动时,吸顶标题要同步变化;吸顶标题变了,索引条当前高亮字母也要跟着变。这两个方向的数据流很容易写乱。
我的做法是这样的:滚动事件产生 sectionTitle,这个状态由 ValueNotifier 管理;_IndexBar 也监听同一个状态,高亮当前字母。这样索引条和吸顶头永远使用同一份数据,不会出现“索引条高亮在 B,吸顶标题已经跑到 F”的错位问题。
另外,索引条本身不要每次滚动都 rebuild。如果高亮字母变了,应该只更新高亮项,而不是整行索引条全部重建。做法可以把每个字母也拆成独立的 ValueListenableBuilder,或者在高亮判断时用 AnimatedContainer 只更新对应字母的背景。
5.3 常见问题速查
| 问题 | 典型现象 | 解决办法 |
|---|---|---|
| 索引条点不到 | 点击字母没反应,列表不跳 | 检查 GestureDetector 是否设置 HitTestBehavior.opaque,确认 Stack 中索引条在 CustomScrollView 上方 |
| 跳转总差一截 | 点击“M”后第一个联系人被吸顶头挡了一半 | 把顶部非列表区域的高度加进目标偏移,打印 _scrollController.offset 对比 |
| 吸顶标题不更新 | 滚动时标题永远显示“A” | 检查 SliverPersistentHeaderDelegate.shouldRebuild 是否比较了标题字段 |
| 滑动索引条一顿一顿 | 手指快速滑动时列表频繁跳转 | 在 onVerticalDragUpdate 里判断字母是否变化,只有变化才触发跳转 |
| 列表掉帧明显 | iOS 真机上滚动不跟手 | 改用 SliverFixedExtentList,减少行内阴影和半透明绘制,精简 setState 范围 |
5.4 最后想说的一个实现原则
这个方案我从 v1 到 v3 改了三次,最大转折点就是把分组标题从列表行里拆出来。之前为了省事,我在 SliverChildBuilderDelegate 里判断首字母变化后插入标题行,结果列表行高不固定,索引跳转一直靠 GlobalKey 补救,滚动倒是能走,但每次真机测试都能感受到那种“粘滞感”。
后来我狠下心把所有分组标题移出列表行,主列表变成纯粹的联系人行,吸顶标题单独用 SliverPersistentHeader 承担,索引跳转立刻变成 index * itemExtent 这种小学生级别的计算。现在再遇到字母索引跳不准的反馈,我一般先问对方:你的主列表是固定行高吗?如果不是,先把结构改成固定行高再谈后面的优化。
做 Flutter 高级滚动,很多时候不是缺少炫酷写法,而是没有把“数据结构”和“视觉结构”对齐。iOS 通讯录看起来简单,但它的每一层交互,本质上都是经过简化的数据定位。你要做的不是发明更多技巧,而是把不必要的变化从滚动坐标里清除干净。
