资讯流适合 ListView.builder,但编辑式首页往往不是“同一种卡片重复 N 次”:顶部标题会收缩,导语占满首屏,专题横向展开,文章回到单列,章节标签吸附,结尾又需要大块留白。如果用外层 SingleChildScrollView 包 Column,再在里面塞禁用滚动的 ListView 与 GridView,视觉能拼出来,却同时失去懒构建、统一滚动物理和清楚的语义索引。

Sliver 解决的不是“做炫酷 AppBar”,而是让不同滚动布局通过同一 Viewport 协议协作。本文面向已经会使用 ListView/GridView、需要混合长内容和滚动转场的开发者。前置知识是 Flutter 约束布局、ScrollController 和基础语义组件。

版本快照

截至 2026-07-16,Flutter SDK 归档显示稳定版为 Flutter 3.44.6 / Dart 3.12.2,Beta 为 Flutter 3.47.0-0.1.pre / Dart 3.13 beta;本仓库本机基线是 Flutter 3.41.9 / Dart 3.11.5。Scrolling widgets 索引列出本文采用的滚动与 Sliver 组件。示例只使用这些稳定交集 API,不依赖 Beta;代码验证状态按 examples 的实际门禁标注。

最新 API 中 CustomScrollView.cacheExtent 已在 3.41 预发布周期后弃用,替代接口是 scrollCacheExtent。因为本文不需要手调预缓存距离,示例故意省略两者;迁移时按实际 SDK 的 analyzer 提示处理,不要为了消除文档差异升级全局工具链。

视觉资产记录:封面(1600 × 900)与文中几何图(1200 × 680)均为 WEB/SUN 于 2026-07-16 创作的程序化 SVG;来源/许可为本项目原创自有资产,未使用第三方图片。

从 Box 约束切换到 Sliver 几何

普通 RenderBox 的核心协商是父级向下传 BoxConstraints、子级向上返回 Size。滚动区域还需要回答:这一段总共贡献多少滚动范围?当前偏移下画多少?在 Viewport 中从哪里开始画?是否有溢出?Sliver 因而使用 SliverConstraintsSliverGeometry 协议。

CustomScrollView.slivers 文档指出,sliver 协议让浮动、伸缩、章节吸附等效果成为布局结果;SliverList 和 SliverGrid 只构建当前可见附近的孩子。Sliver 本身多半管理盒子“如何出现在 Viewport”,实际文字、图片与卡片仍是普通 Box Widget。

一个 Viewport 内的 Sliver 几何协议

图 1(1200 × 680):Viewport 向各段传滚动约束,各 Sliver 返回可绘制与可滚动几何。原创程序化 SVG,WEB/SUN,2026-07-16。

这解释了为什么 SliverToBoxAdapter 应当是桥,而不是默认容器。它适合一段孤立导语、横幅或结尾;若把数百项目先组装为 Column 再塞进一个 Adapter,Viewport 只能把整个 Column 当成单个孩子,懒构建优势就消失了。

把页面拆成滚动章节,而不是 Widget 类型

先按内容叙事切段,再选择 Sliver:

内容角色 合适组件 说明
会缩放或吸附的刊头 SliverPersistentHeader / SliverAppBar 高度随滚动几何变化
长文章索引 SliverList builder 按可见范围创建
稳定比例的作品阵列 SliverGrid 与同一滚动轴协作
区块内边距 SliverPadding 不必转回 Box 世界
单段导语或尾声 SliverToBoxAdapter 一次性桥接 Box
填满余下视口的空状态 SliverFillRemaining 空状态仍占据可用区域
CustomScrollView(
  key: const PageStorageKey('writing-index'),
  semanticChildCount: articles.length,
  slivers: [
    SliverPersistentHeader(
      pinned: true,
      delegate: EditionHeaderDelegate(
        minExtent: 72,
        maxExtent: 280,
      ),
    ),
    const SliverToBoxAdapter(child: EditorialIntroduction()),
    SliverPadding(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
      sliver: SliverList.builder(
        itemCount: articles.length,
        itemBuilder: (context, index) => ArticleRow(article: articles[index]),
      ),
    ),
    const SliverToBoxAdapter(child: EditionColophon()),
  ],
)

这一结构保持一条主滚动轴,ScrollController、Overscroll、键盘滚动与返回位置只需管理一次。PageStorageKey 帮助区分多个滚动视图的会话位置;CustomScrollView API说明其默认会通过 PageStorage 尝试保存偏移。需要跨进程状态恢复时仍应使用 restorationId 或业务层持久化,不能把 PageStorage 当数据库。

用 shrinkOffset 导出视觉状态

自定义 SliverPersistentHeaderDelegate 提供 minExtent、maxExtent 和 build。其 build API保证 shrinkOffset 位于 0 到 maxExtent - minExtent,因此可以稳定归一化为 0→1,而不必监听全局像素偏移。

@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
  final range = maxExtent - minExtent;
  final progress = range == 0 ? 1.0 : (shrinkOffset / range).clamp(0.0, 1.0);

  return ColoredBox(
    color: Color.lerp(const Color(0xFFF3F0E9), const Color(0xFF11110F), progress)!,
    child: Align(
      alignment: Alignment.lerp(Alignment.bottomLeft, Alignment.centerLeft, progress)!,
      child: Text('WRITING', textScaler: MediaQuery.textScalerOf(context)),
    ),
  );
}

@override
bool shouldRebuild(EditionHeaderDelegate oldDelegate) =>
    oldDelegate.minExtent != minExtent || oldDelegate.maxExtent != maxExtent;

progress 应驱动少量可合成或轻量属性,不要在 delegate.build 里随偏移解析 Markdown、请求图片或重排大段文本。overlapsContent 只表示后续 Sliver 是否画在其下方,可用于选择边界或阴影;它与 shrinkOffset 并不总是一一对应,尤其在 NestedScrollView 中。

Header 的 min/max 高度应从构造参数确定,生命周期内保持一致。若文字受 Dynamic Type 影响,硬编码 72 可能裁掉内容;更安全的方式是为放大文本设计独立断点、允许标题换行或在极大字号下关闭收缩,而不是缩小字体。

懒构建不等于免费滚动

SliverList 只创建可见附近的孩子,但每个孩子仍可能昂贵。图片解码、复杂文本布局、同步 JSON 转换或全宽阴影都会占用帧。固定主轴高度的行可以考虑 SliverFixedExtentList;高度可预测时,Viewport 更容易推算位置。不可预测内容则保留 SliverList,不要为了“固定高度更快”裁掉可访问字体。

列表 key 要表达内容身份,而非当前 index。筛选或插入后用 index key 会把状态、焦点与图片加载错误地复用到另一篇文章。若行内有可保持状态的输入控件,明确决定 keepAlive;不要让所有离屏行永久存活,也不要让用户正在编辑的内容滚出屏幕后意外丢失。

滚动监听也应最小化。只需要进度指示时可用 ScrollNotification 或 controller;不要在每个像素变化时 setState 重建整个页面。可见性加载交给 Sliver 懒构建与图片缓存,章节吸附交给 SliverPersistentHeader,让布局系统承担它已经知道的几何。

语义、焦点与滚动位置

CustomScrollView 文档说明,读屏器需要当前可见范围和总语义子项数;分隔线等不贡献语义的 Widget 会让视觉 index 与语义 index 不同。自定义混合列表时,用 IndexedSemantics 标注真实内容项,并让 semanticChildCount 与它们的数量一致。

Pinned header 不应遮住键盘焦点。通过 Scrollable.ensureVisible、足够的 showOnScreen 空间与真机读屏测试验证;不要只看触摸滚动。横向 Sliver 也要提供清楚的组标题与“查看全部”路径,避免焦点在不可见方向无限移动。

编辑式留白要在 320px、200% 字号和横屏下检查。固定大高度可能把正文推到几屏之外;根据 LayoutBuilder 或 MediaQuery 约束调整节奏,但保持内容顺序一致。视觉错位不应以改变语义顺序修补。

更复杂的轴线:center、anchor 与双向增长

CustomScrollView 的 center 可以指定哪一个 Sliver 对应零偏移,其他 Sliver 从它向相反方向增长;聊天记录在顶部插入历史消息、同时保持当前视口稳定时,这种能力比手工修正 offset 更接近问题本质。anchor 则控制零偏移在 Viewport 中的相对位置。二者适合明确的双向数据模型,不应只为视觉新奇使用。

编辑式博客通常保持从顶部向下的单向阅读,默认 center 更符合浏览器式预期;聊天、时间轴或以“今天”为中点的日程才可能需要双向增长。使用前要验证滚动条范围、语义顺序、恢复位置与“回到顶部”的产品含义。视觉上的上方不一定是数据 index 更小的一方,测试和埋点命名也要避免混淆。

章节吸附同样需要节制。连续多个 pinned header 会层层占据视口,尤其在小屏和放大字体下挤压正文。可以只吸附当前一级章节,把二级信息放入正文;或在 header 达到最小高度后替换为简短标签。无论怎样,真实标题仍应出现在语义树中,不能只剩画布绘制的装饰文字。

建立滚动回归矩阵

Sliver 页面至少要在空数据、单项、长列表、图片失败和动态插入五种数据状态下测试。尺寸覆盖 320px 手机、平板分栏和桌面宽窗;输入覆盖触摸惯性、鼠标滚轮、键盘 PageDown/Home/End 与读屏滑动。返回页面后要检查位置恢复,筛选后要检查焦点是否落在仍存在的项目。

性能测试用固定长数据集在 profile 模式录制:快速 fling、慢速阅读和反向滚动各跑一次,观察 UI 与 Raster 两侧;同时记录构建的行数,确认不是因为 shrinkWrap 或 Adapter 让全部孩子提前创建。不要用 debug 模式滚动“看起来不卡”作为证据,也不要用一台高端设备证明最低目标设备。

视觉回归则在几个有意义的 shrinkOffset 采样:完全展开、中间态、完全收缩、下一章节刚进入。若动画在 reduced motion 下被简化,单独保存静态终态。这样测试关注的是滚动协议的边界,不需要对每个像素偏移生成脆弱截图。

常见失败模式

SingleChildScrollView 套多个 shrinkWrap 列表

它让内层列表先测量大量孩子,扩大布局与内存;统一为 CustomScrollView 中的多个 SliverList/SliverGrid。

为所有内容使用 SliverToBoxAdapter

协议形式上正确,实际仍一次构建整段 Column。重复内容必须使用 lazy delegate。

NestedScrollView 只为吸顶

内外滚动位置协调、浮动头与 Tab 状态更复杂。单轴页面优先 SliverPersistentHeader;只有确实存在独立内层滚动体时再承担 NestedScrollView 的成本。

用全局 offset 写大量魔法阈值

设备尺寸、文字缩放和内容变化会让阈值失效。优先从各 Sliver 的 shrinkOffset 和自身 extent 推导局部进度。

自动滚动劫持阅读

滚动吸附、强制分页与视差若改变用户输入,会损害查找、选文和可访问性。Sliver 应编排布局,不应夺走滚动控制;减少动态效果时移除非必要的伸缩与视差。

结论

Sliver 的价值不是提供更多花式组件,而是让不同内容段共享同一滚动几何协议。Viewport 分发约束,Header、List、Grid 和 Box 桥各自返回滚动与绘制范围,长内容才能同时获得编辑式节奏和懒构建。

实现前先按叙事切章节,再为每段选择 Sliver;用 shrinkOffset 导出局部视觉状态,用语义索引、稳定 key 和 PageStorage 保护阅读连续性。最终用 320px、放大字体、键盘、读屏和长数据集验收。好的编辑式滚动不是让用户注意到动画,而是让不同密度的内容在同一条轴上自然接力。

Sources