Flutter 基础体系 · 第 40/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 滚动与 Sliver:Viewport、懒构建、吸顶和自定义布局

滚动界面通常被理解为“一个可以上下移动的列表”,但 Flutter 的滚动体系实际上分成了几个职责不同的层次:

  • Scrollable 处理手势、滚动物理效果和 ScrollPosition
  • Viewport 根据当前滚动偏移决定可见区域;
  • Sliver 接收 Viewport 传来的约束,计算自己占据的滚动空间和当前可绘制区域;
  • SliverListSliverGridSliverAppBar 等组件实现具体布局;
  • RenderObject 层最终执行尺寸计算、绘制和命中测试。

理解这些边界后,ListViewCustomScrollView、懒构建、吸顶和自定义 Sliver 会变成同一套机制的不同用法,而不是互相独立的 API。


一、先建立滚动模型:滚动不是移动整个 Widget 树

1. 滚动位置描述的是内容坐标

设滚动内容在主轴方向上的总长度为:

CC

Viewport 在主轴方向上能够显示的长度为:

VV

滚动偏移量为:

SS

当内容长度大于 Viewport 时,理论上有效滚动范围是:

0Smax(0,CV)0 \leq S \leq \max(0, C - V)

例如:

  • 内容长度 C = 2000
  • Viewport 高度 V = 600
  • 最大滚动偏移为 2000 - 600 = 1400

如果第一个内容点在滚动前位于坐标 0,滚动后它的绘制位置可以近似表示为:

ypaint=ycontentSy_{\text{paint}} = y_{\text{content}} - S

因此滚动的本质不是修改每个子组件的业务状态,而是修改一个共享的视口偏移,让内容坐标映射到屏幕坐标。

2. ScrollableScrollPositionViewport 的职责

常见的组件关系可以抽象为:

手势 / 鼠标滚轮 / 键盘
          │
          ▼
     Scrollable
          │
          ▼
   ScrollController
          │
          ▼
    ScrollPosition
          │  pixels = 当前滚动偏移
          ▼
       Viewport
          │
          ▼
        Slivers

Scrollable 负责接收用户输入,并通过 ScrollPhysics 计算拖动、惯性和边界行为。

ScrollPosition 保存当前滚动状态,例如:

  • 当前偏移 pixels
  • 最大滚动范围 maxScrollExtent
  • Viewport 尺寸 viewportDimension
  • 是否正在滚动
  • 是否已经越过边界

Viewport 不负责“列表项如何排列”。它只负责:

  1. 接收可用的盒约束;
  2. 将滚动信息转换成 SliverConstraints
  3. 依次布局子 Sliver;
  4. 根据每个 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 在主轴上的尺寸
cacheOriginremainingCacheExtent 当前布局需要覆盖的缓存范围
overlap 前一个 Sliver 是否与当前 Sliver 产生视觉重叠

对于一个已经完全滚出顶部的 Sliver:

scrollOffset > 自身长度

它通常仍然需要参与布局,以便向后续 Sliver 报告正确的累计滚动长度,但它的 paintExtent 可以是 0

3. SliverGeometry 的核心因果关系

假设某个 Sliver 的内容长度为 L,当前滚动偏移为 S,Viewport 剩余可绘制长度为 R

它实际能够绘制的长度大致受以下条件限制:

P=min(max(LS,0),R)P = \min(\max(L-S, 0), R)

其中:

  • L - S 表示还有多少内容没有被滚走;
  • max(..., 0) 保证已经滚出 Viewport 后不会产生负绘制长度;
  • R 保证不会绘制到当前 Sliver 可用区域之外;
  • P 对应 paintExtent 的基本直觉。

scrollExtent 通常仍然是 L,因为它描述的是完整内容长度,不是当前可见长度。

这也是“滚出屏幕后仍占据滚动范围”和“当前不再绘制”可以同时成立的原因。


三、ListViewCustomScrollView 的关系

ListView 是一个针对常见列表场景的高层组件。其内部本质上会使用:

  • Scrollable
  • Viewport
  • SliverListSliverFixedExtentList
  • 适当的 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,
  ),
);

这不会因为 childCount100000 就立刻创建十万个 ListTilechildCount 主要用于告诉 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);
  }
}

当它离开缓存范围后,状态可能不再保留。需要区分三种情况:

  1. 状态属于数据
    例如输入内容、勾选状态、展开状态。应把它存到模型或状态管理层,而不是依赖某个暂时存在的 State。

  2. 需要在列表中保持 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();
      }
    }
    
  3. 子项被错误复用
    当列表数据插入、删除或重新排序时,应使用稳定的 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 和索引到像素位置之间的关系更明确:

yi=i×hy_i = i \times h

其中 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 为例:

  1. 它的子 Widget 先按 Box 规则得到高度 100
  2. 适配器把这个高度转换成 scrollExtent = 100
  3. Viewport 根据滚动偏移计算它是否还可见;
  4. 如果已经完全滚出屏幕,paintExtent 变为 0
  5. 后续 Sliver 仍然会把这 100 计入前置滚动长度。

SliverFixedExtentList 的布局更适合通过索引计算:

第 0 项:0   ~ 64
第 1 项:64  ~ 128
第 2 项:128 ~ 192

在不考虑分隔线的情况下,1000 项列表的滚动长度为:

1000×64=640001000 \times 64 = 64000

这使得远距离跳转和范围估算比任意高度的普通 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:头部折叠后仍保留的最小高度。

它们必须满足:

0minExtentmaxExtent0 \leq \text{minExtent} \leq \text{maxExtent}

例如:

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 时,理论收缩距离为:

12048=72120 - 48 = 72

shrinkOffset 表示已经消耗的收缩距离,通常需要将其限制在 [0, 72] 范围内再计算动画进度。

4. floatingpinned 不是同一个功能

SliverAppBar(
  pinned: true,
  floating: true,
);

两者作用不同:

  • pinned:滚过头部后,头部仍固定在边缘;
  • floating:用户反向滚动时,头部可以更早重新出现,而不必等内容滚回其原始位置。

因此可能出现:

配置 行为
都为 false 普通滚动头部
pinned: true 折叠后保留在顶部
floating: true 反向滚动时提前出现
两者都为 true 既吸顶,又能在反向滚动时提前出现

SliverAppBar 还提供 snap,用于让浮动头部以吸附动画出现。使用 snap 时必须满足其 API 约束,通常需要同时启用 floating;不能把 snap 当成普通 pinned 的替代品。

5. 多个吸顶头部的层叠

如果连续放置多个 pinnedSliverPersistentHeader,它们可能在顶部依次占据空间:

┌──────────────────────┐
│ 页面 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,
        ),
      ),
    ),
  ],
);

SliverPaddingSliverToBoxAdapterSliverFillRemainingSliverListSliverGrid 已经覆盖大多数页面需求。

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,
  );
}

逐项解释:

  1. extent 是完整内容长度;
  2. calculatePaintOffset 根据 scrollOffset 和 Viewport 剩余空间计算当前可见长度;
  3. calculateCacheOffset 计算缓存区域需要覆盖的长度;
  4. scrollExtent 必须报告完整滚动长度,否则后续滚动范围会错误;
  5. hitTestExtent: 0 表示这个间隔不应响应点击;
  6. 修改 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. jumpToanimateTo

  • 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(...),
    ),
  ],
);

它们分别拥有自己的:

  • Scrollable
  • ScrollPosition
  • 滚动范围
  • 手势竞争关系

如果需求是“顶部 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 中;
  • 是否缺少 ExpandedFlexible
  • 是否位于另一个竖直滚动视图内部;
  • 是否错误地使用了无限高度约束。

选择方案时遵循布局语义:

需要独立滚动区域 → 给内部列表有限高度,例如 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

其中最后一项应作为协议层扩展,而不是普通布局的默认起点。只要 SliverListSliverGridSliverPaddingSliverPersistentHeader 等现有组件能够表达需求,就不需要承担自定义渲染对象的维护成本。

Flutter 的滚动体系最终可以归结为一个稳定的协议:

SliverConstraints布局子内容SliverGeometry\text{SliverConstraints} \longrightarrow \text{布局子内容} \longrightarrow \text{SliverGeometry}

Viewport 根据滚动位置提供约束,Sliver 根据约束报告几何信息;懒构建决定哪些子项需要出现,吸顶通过几何和绘制规则改变头部的可见行为,自定义布局则直接参与这套约束—几何协议。理解这条数据流,才能准确判断一个滚动问题究竟属于数据状态、Box 约束、Viewport 管理,还是 Sliver 几何计算。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。