跟🤡杰哥一起学Flutter (三十五、玩转Flutter滑动机制📱)

1. 引言

上节《三十四、玩转Flutter手势机制✋》扒完 Flutter 的 "手势机制 ",有点意犹未尽,那就趁热打铁,本节来把 "滑动机制" 也冲了 🔥

✨"滑动机制 " 的出现是为了解决 "有限区域 " 显示 "超量/无限内容" 的问题。

🤔 打个比方:

想象下你面前有一幅延绵百米的巨型壁画,你的眼睛一次只能看到笔画的一部分 (如正前方2米宽的区域),这个"眼睛能看到的区域 " 可以称作你的 "视野窗口 (Viewport)",当你左右移动身体,"视口" 也会随之移动,让你看到壁画的其它部分。

😶 贴近现实,现代数字内容的丰富性 (如长文本、图片列表、网页、数据表格等) 远超 屏幕的物理显示范围 ,此时,用户也需要一种 "移动视口 " 的方式,以访问被屏幕边缘遮挡的内容。💁‍♂️ "滑动机制 " 应运而生,其本质是通过 用户交互 (如手指拖动、鼠标滚轮、触控板手势) 改变内容在屏幕上的显示位置 (视口偏移 ),从而让用户逐步 "扫过" 超量内容。技术实现通常分为这三个部分:

  • 内容容器与视口分离 :将内容渲染在一个更大的 "虚拟画布" 中,屏幕仅显示其中一部分。
  • 输入事件驱动偏移 :用户通过 滑动手势 触发系统计算位移量,调整内容容器的位置 (如向上偏移100像素),使得原本被遮挡的内容进入视口。
  • 边界反馈:当内容已完全显示 (到达顶部/底部) 时,通过阻力反馈或动画提示用户"无更多内容"。

😄 而在 Flutter 中,视口 (Viewport) 的概念不在局限于屏幕级别的显示区域,而是扩展到任意一个 "构建其可视范围内子项的可滚动区域 "。为此,Flutter 设计了一套 "滑动处理机制",其中的重要参与者:

Scrollable -专注交互,监听手势并驱动滚动、Viewport -管理并提供可见视口,Slivers-提供内容,在Viewport按需渲染自己的一部分。

这种清晰的职责划分 (分层设计) 使得 Flutter 在实现无限列表、复杂联动特效时,仍能够保持出色的性能与强大的灵活性。

😏 本节就来系统学习下 Flutter 的这套 "滑动机制 ",依旧是 "概念名词+API详解+源码剖析" 的叙述套路~

2. 三大核心构件

💡 这部分涉及到比较多的 API,可以选择性阅读 (跳着看),大概知道是都是干嘛的,用到再查也可以。🤔对 "Sliver协议的工作流程" 建立一个基础认知,在后面分析各种具体滚动视图时会更加得心应手。

2.1. Scrollable - 滑动控制器

所有 可滚动组件 的 "基石 ",作用是将 "用户的输入 (手势) 转化为滚动的视觉变化",具体功能:

  • 手势识别 :通过内置的 RawGestureDetector,识别垂直或水平方向的拖拽手势。
  • 滚动物理模拟 :通过 ScrollPhysics ,定义了滚动的 "感觉",比如:滚动到边缘时的效果 (Android-蓝色辉光-ClampingScrollPhysics、iOS-回弹-BouncingScrollPhysics)、滑动停止时的惯性动画 (Fling)。
  • 状态管理 :管理滚动的核心状态,如:当前滚动位置 (pixel)、滚动范围 (min/max scroll extent)。这个状态由一个叫做 ScrollPosition 的对象维护。
  • 外部控制接口 :通过 ScrollController,允许开发者从外部读取滚动位置、监听滚动事件或命令式地控制滚动 (如跳转到指定位置、执行动画)。
  • 构建视口 :Scrollable 本身不渲染任何可滚动的内容。它通过一个名为 viewportBuilder 的回调函数,将滚动的能力 (ViewportOffset) "嫁接" 给一个负责渲染部分内容的 Viewport 组件。

2.1.1. 属性/方法

构造方法:

dart 复制代码
class Scrollable extends StatefulWidget {
  const Scrollable({
    super.key,
    this.axisDirection = AxisDirection.down,        // 滚动方向
    this.controller,                                // 滚动控制器
    this.physics,                                   // 滚动物理效果
    required this.viewportBuilder,                  // 视口构建器(必需)
    this.incrementCalculator,                       // 增量计算器,
    this.excludeFromSemantics = false,             // 是否在语义树 (用于辅助功能,如屏幕阅读器) 中可见。
    this.semanticChildCount,                        // 向辅助功能提供一个提示,告知总共有多少个子项。
    this.dragStartBehavior = DragStartBehavior.start, // 拖拽开始行为
    this.restorationId,                            // 恢复ID
    this.scrollBehavior,                           // 滚动行为
    this.clipBehavior = Clip.hardEdge,             // 裁剪行为
  })
}

参数详解:

① axisDirection :AxisDirection

定义了【滚动轴的方向】& ScrollPosition 的 pixel 为 0 时,内容所处的位置,可选值:

  • down:垂直方向,内容从上到下排列 (0.0在顶部)
  • up:垂直方向,内容从下到上排列 (0.0在底部)
  • right::水平方向,内容从左到右排列 (0.0在左侧)
  • left:水平方向,内容从右到左排列 (0.0在右侧)

② physics :ScrollPhysics?

定义了【组件滚动时的物理特性 】,决定了滚动时的"手感",如果为null,Scrollable 会通过 ScrollConfiguration.of(context) 获取一个平台默认的 ScrollPhysics 。Android 上是ClampingScrollPhysics (边界钳制,有辉光),在 iOS 上是 BouncingScrollPhysics (边界回弹)。常见的还有:NeverScrollableScrollPhysics (禁止滚动)、AlwaysScrollableScrollPhysics (内容不足一屏也可滚动)。

③ incrementCalculator :ScrollIncrementCalculator?

用于计算非指针(如键盘箭头、鼠标滚轮)滚动事件的滚动增量。通常无需关注此参数,框架有默认实现,在需要自定义键盘/滚轮滚动行为时才使用。

④ dragStartBehavior :DragStartBehavior

定义了【拖拽开始行为】决定滚动操作何时开始被识别。

  • start:默认值,用户的手指按下并移动了一段微小的距离后才会被识别为滚动开始。
  • down:用户手指按下并开始移动的瞬间就被立即识别为滚动,除非有极致及时反馈的交互时才设置,否则建议还是保持默认,以保证最佳和最符合预期的用户体验。

⑤ clipBehavior :Clip

定义了【如何对超出边界的内容进行裁剪】,可选值:

  • hardEdge:默认,以最快的方式裁剪掉超出边界的内容。裁剪的边缘是硬的,可能会有锯齿,但GPU负载最低,性能最好,特别适合滚动视图是矩形且没特殊视觉效果的场景。
  • antiAlias:裁剪内容,并对裁剪的边缘进行抗锯齿处理,使其看起来更平滑。视觉效果更好,特别是当滚动视图有圆角时,可以避免边缘的锯齿感。性能开销比 hardEdge 稍高。
  • antiAliasWithSaveLayer:使用最高质量的抗锯齿裁剪,但也是性能开销最大的。它会创建一个临时的离屏缓冲区 (save layer) 来执行裁剪操作。能处理复杂的裁剪场景,提供最平滑、最准确的视觉效果。但严重影响性能。只有当Clip.antiAlias 仍然出现视觉问题 (如复杂的透明度和变换组合下) 时,才作为最后的手段使用。
  • none: 完全不裁剪,内容可以绘制到滚动视图的边界之外。 性能最差,因为它可能需要绘制更多内容,而且,溢出的内容可以会覆盖页面上其它UI元素,导致混乱的视觉布局。

⑥ restorationId :String?

用于【为滚动视图提供一个唯一的ID 】以便在应用被系统杀死并恢复后,能够自动恢复其滚动位置。属于 Flutter 状态恢复 (State Restoration) 框架的一部分。应用场景:包含长内容页面 (如文章),当应用被挂起时 RestorationManager 会找到所有带 restorationId 的 Widget,并向它们请求需要保存的数据,(对于滚动视图,就是当前的 scrollOffset )。这些数据被保存到系统中,当应用恢复时,RestorationManager 会找到具体相同 restorationId 的Widget,并将保存的数据交还给它,使其能够恢复到之前的状态 (即滚动到之前的位置)。

⑦ viewportBuilder :ViewportBuilder

这是 ViewportBuilder 的定义代码:

dart 复制代码
typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);

这个参数是【构建Viewport的回调 】,Scrollable.build() 时会创建一个 ViewportOffset 对象 (实际上是 ScrollPosition 的实例),并将其作为 参数 传递到这个回调中,回调执行后返回 Viewport 或其它自定义组件,需要通过这个 position 参数来决定其它子组件 (通常是 Sliver) 的布局偏移。

⑧ controller :ScrollController?

可选的【外部滚动控制器 】继承 ChangeNotifier ,可以通过它来监听滚动位置、驱动滚动。多个 Scrollable 还可以共享 同一个 controller 来实现同步滚动效果。不传这个参数 (null ),Scrollable 会在内部自动创建一个 ScrollController ,如果自己创建了 ScrollController ,记得在 State 的 dispose() 中销毁它。它的构造函数:

dart 复制代码
class ScrollController extends ChangeNotifier {
    ScrollController({
      double initialScrollOffset = 0.0,	// 初始滚动偏移量,默认为 0.0
      this.keepScrollOffset = true,
      this.debugLabel,
      this.onAttach,
      this.onDetach,
  })
}

typedef ScrollControllerCallback = void Function(ScrollPosition position);

参数详解:

  • keepScrollOffset :是否使用 PageStorage 保存滚动位置,默认true, 当 ScrollController 被附加到多个滚动视图时非常有用,它决定了控制器是否应该在切换附加对象时,尝试保持当前的 scrollOffset。
  • onAttach :ScrollControllerCallback? ,当一个 ScrollPosition 被附加到 ScrollController 时会触发这个回调,可在回调中可以获得刚附加的 ScrollPosition对象,进行一些初始化操作或记录。
  • onDetach:ScrollControllerCallback? ,当一个 ScrollPosition 从 ScrollController 中分离时会触发这个回调。可在回调中可以获得即将被分离的 ScrollPosition对象,进行一些清理或记录。

属性/方法:

dart 复制代码
// 当前附加的所有 ScrollPosition 对象
Iterable<ScrollPosition> get positions => _positions;

// 是否有附加的滚动视图
bool get hasClients => _positions.isNotEmpty;

// 获取唯一的 ScrollPosition(仅在单个视图时使用)
ScrollPosition get position {return _positions.single}

// 当前滚动偏移量 (滚了多少像素)
double get offset => position.pixels;

// ✨ 滚动控制方法
// 动画滚动到指定位置,参数:偏移量、动画时长和曲线
// 顶部 (position为0),底部 (最大滚动距离-_scrollController.position.maxScrollExtent)
Future<void> animateTo(double offset, {
  required Duration duration,
  required Curve curve,
});

// 立即跳转到指定位置 (没有动画效果)
void jumpTo(double value);

// ✨ 添加滚动监听
_scrollController.addListener(() {
  print('offset: ${_scrollController.position.pixels}');
});

💡 注:要精确地判断滑动状态,推荐使用 NotificationListener ,它比 ScrollController 的 addListener 提供了更丰富、更具体的事件信息,如滚动停止。另外,上拉加载更多 (滑动到底部,用户还往上拉),常见的错误做法:在 addListener 里判断 position.pixels == position.maxScrollExtent ,这只在到达底部时触发一次,而不是在到达底部后继续拉动时触发。正确的做法是:监听 OverscrollNotification ,当用户试图滚动超过 maxScrollExtent 时,就会触发这个通知。

ScrollController 内部维护了一个 ScrollPosition 列表 _positions ,ScrollPosition 存储了 "单个滚动视图 " 的 "状态信息 & 控制逻辑"。

dart 复制代码
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
  required this.physics,         // 滚动的物理模拟效果
  required this.context,         // 滚动上下文
  this.keepScrollOffset = true,  // 是否通过 PageStorage 保存和恢复滚动位置
  ScrollPosition? oldPosition,   // 用于在 Widget 重建时迁移状态
  String? debugLabel,            // 调试标签
}

参数详解:

  • context :ScrollContext ,充当 Scrollable 和 ScrollPosition 间的桥梁,为滚动操作提供必要的 上下文信息 。包括:分发ScrollNotification -notificationContext、搜索PageStorage -storageContext、动画支持 -vsync、滚动方向 -axisDirection、设备像素比 -devicePixelRatio。除此之外,还提供了交互控制相关的方法:是否忽略指针事件 -setIgnorePointer()、是否可以拖拽 -setCanDrag()、setPixels (double offset)-当 ScrollPosition 内部的 pixels 值改变时,用它来通知 Viewport 更新其偏移量,从而真正移动视图。
  • oldPosition :ScrollPosition? ,当 Scrollable 的配置发生变化 (如ScrollController 或 ScrollPhysics 被替换) 时,ScrollableState 会创建一个新的 ScrollPosition,oldPosition 参数允许新 Position 从 旧 Position "吸收(absorb) " 状态,如当前的 pixels值,从而实现平滑过渡。

属性/方法:

dart 复制代码
// ================ 🔔 存储滚动核心数据 ================ 
double pixels: 最核心的属性。表示当前滚动位置距离滚动起点的偏移量(逻辑像素)。
double minScrollExtent: 最小滚动范围。通常是 0.0。
double maxScrollExtent: 最大滚动范围。它由 (内容总高度 - 视口高度) 计算得出。
double viewportDimension: 视口(可见区域)在滚动轴上的尺寸。例如,对于一个垂直滚动的 ListView,这就是它的高度。
double extentBefore: 在视口之前(上方或左方)的不可见区域的长度。等于 pixels - minScrollExtent。
double extentInside: 在视口内部的区域长度,就是 viewportDimension。
double extentAfter: 在视口之后(下方或右方)的不可见区域的长度。
bool outOfRange: 当前 pixels 是否超出了 minScrollExtent 和 maxScrollExtent 的范围。
bool atEdge: 当前 pixels 是否正好等于 minScrollExtent 或 maxScrollExtent。
  
Axis axis: 滚动的轴(Axis.vertical 或 Axis.horizontal)。
- ScrollDirection userScrollDirection: 用户最近一次的滚动方向。有四个值:
- ScrollDirection.idle: 静止。
- ScrollDirection.forward: 向前滚动(例如,在垂直列表中向上滚动,内容向下移动)。
- ScrollDirection.reverse: 向后滚动(例如,在垂直列表中向下滚动,内容向上移动)。
- 这个属性对于实现"滚动时隐藏/显示AppBar或FAB"等效果至关重要。

ScrollActivity activity: 内部状态机。表示当前滚动正在进行的"活动"。它是一个抽象类,常见的实现有:
- IdleScrollActivity: 静止状态。
- DragScrollActivity: 用户手指按住并拖动时的活动。
- BallisticScrollActivity: 用户手指抬起后,列表根据速度进行惯性滚动的活动。

// ================ 🎪 核心滚动操作 ================ 
jumpTo(double value): 瞬间将滚动位置改变到指定值,不带动画。
animateTo(double to, ...): 以动画方式将滚动位置平滑移动到指定值。
moveTo(double to, ...): jumpTo 和 animateTo 的内部实现,负责应用新的像素值。
correctBy(double correction): 当滚动位置不正确时(例如,子元素增删导致 maxScrollExtent 变化),用于修正 pixels。
applyNewDimensions(): 当视口或内容尺寸变化时,被调用来更新 min/maxScrollExtent 等维度信息,并可能修正 pixels。

// ================ 🎪 驱动滚动物理和动画 ================ 
它内部持有一个 ScrollPhysics 对象,用于决定滚动的物理行为(如在滚动到边缘时是阻尼效果还是回弹效果)。
它使用 Ticker 来驱动滚动动画,如用户手指离开屏幕后的惯性滚动(Fling/Ballistic)。
它定义了如 goIdle()、goBallistic(double velocity) 等方法来控制滚动的动态行为。

// ================ 🎪 通知机制 ================ 
// 它继承自 ChangeNotifier,当 pixels 发生变化时,
// 会调用 notifyListeners()。ScrollController 就是通过监听这个通知来得知滚动位置的变化。
// 它负责向上冒泡派发 ScrollNotification
// (如 ScrollStartNotification, ScrollUpdateNotification, ScrollEndNotification 等),
// 让父组件(如 NotificationListener)可以监听到滚动事件。
  
// 💡 可以调用 controller.positions 属性来访问这个ScrollPosition列表
// 当控制器只附加到一个滚动视图上时,你可以直接调用 controller.offset 来获取ScrollPosition对象
// 实际上返回的:controller.position.pixels。

Scrollable 里还有一个 scrollBehavior 属性,也顺带提一嘴。ScrollBehavior 用于【统一整个App或子树中所有滚动组件的默认行为 】。当一个 可滚动的Widget 被创建时,它会调用 ScrollConfiguration.of() 向上查找离它最近的 ScrollConfiguration ,并返回其 behavior 对象,Scrollable 根据它来配置自己的滚动物理效果、滚动条演示、越界指示器、指针设备交互等。构造方法:

dart 复制代码
// 构造方法,没有任何参数,创建的是一个不可变(immutable)的对象。
ScrollBehavior copyWith({
  bool? scrollbars,           // 是否显示滚动条
  bool? overscroll,          // 是否显示过度滚动效果
  Set<PointerDeviceKind>? dragDevices,        // 支持拖拽的设备类型
  MultitouchDragStrategy? multitouchDragStrategy,  // 多点触控策略
  Set<LogicalKeyboardKey>? pointerAxisModifiers,   // 滚动轴修饰键
  ScrollPhysics? physics,     // 滚动物理特性
  TargetPlatform? platform,   // 目标平台
})

2.1.2. ScrollableState

Scrollable 的核心逻辑都在 ScrollableState 中,内部维护一个 ScrollPosition 类型的 _position 属性,所有对滚动的操作,最终都作用于这个属性。ScrollPosition 是抽象类,在 ScrollableState 里的具体实现是 ScrollPositionWithSingleContext,核心实现:

  • 与 Scrollable 建立连接 :"Single Context " 指的是它持有一个 "ScrollableState" 引用,通过它可以获取到:ScrollPhysics、BuildContext、TickerProvider 及 axisDirection。
  • 与 Viewport 通信:applyNewDimensions() 的具体实现就是通过 ScrollableState 找到对应的 Viewport,并从 Viewport 获取 minScrollExtent/maxScrollExtent,这是 ScrollPosition 获得滚动范围信息的关键。
  • 处理用户输入 :实现了 beginActivity() 和 applyUserOffset() 等方法,当用户在 Scrollable 上拖动时,Scrollable 会将 拖动偏移量(delta) 传递给 applyUserOffset ,然后 ScrollPositionWithSingleContext 会根据 ScrollPhysics 的规则更新 pixels 。并且在用户开始拖动时会创建一个 DragScrollActivity 来响应用手操作。当用户松开手指时,调用 goBallistic() 来启动一个 BallisticScrollActivity,实现惯性滚动。
  • 管理生命周期 :当一个 ScrollController 被附加到 Scrollable 上时,ScrollableState 会调用 attach() ,将自己作为参数传递给 ScrollPosition,从而完成绑定,解绑时则调用 death()。

ScrollPositionWithSingleContext 是实际工作的 滚动引擎 ,连接 ScrollController的意图 和 Scrollable视图表现 的桥梁与执行者。ScrollableState 的核心方法如下:

dart 复制代码
class ScrollableState extends State<Scrollable> with , RestorationMixin
    implements ScrollContext {
  
      // 类似于 Theme.of(context)。它允许子组件沿着组件树向上查找到最近的 ScrollableState 实例
      static ScrollableState? of(BuildContext context):

      // 手势识别器 (DragGestureRecognizer) 的回调,当手势发生时,这些方法被调用,然后它们会调用
      // position.drag() 和 position.dragEnd(),将手势的细节传递给 ScrollPosition。
      _handleDragStart, _handleDragUpdate, _handleDragEnd

      // 用户如果没提供controller,创建一个备用的ScrollController实例
      initState()

      // 调 _updatePosition() → controller.createScrollPosition() 创建
      // ScrollPositionWithSingleContext 实例。controller.attach(position)。
      didChangeDependencies()

      // 构建Viewport
      Widget build(BuildContext context){
        // 套_ScrollableScope (继承InheritedWidget,便于实现 Scrollable.of() 查找 ScrollableState)
        Widget result = _ScrollableScope(          
          scrollable: this,
          position: position,
          // 套Listener-监听指针信号、套 RawGestureDetector-手势识别
          child: Listener(                         
            onPointerSignal: _receivedPointerSignal,
            child: RawGestureDetector(             
              gestures: _gestureRecognizers,
              child: Semantics(
                // 套IgnorePointer-忽略指针事件让其直接穿透到下层
                child: IgnorePointer(              
                  ignoring: _shouldIgnorePointer,
                  child: widget.viewportBuilder(context, position), // ✨ 实际内容视口
                ),
              ),
            ),
          ),
        );
        result = _buildChrome(context, result);  // 添加滚动条、过度滚动指示器等
        ...
      }
}

😄 在 ScrollableState.build() 中调用了 Scrollable 构造方法中传入的 viewportBuilder 回调,在创建 Viewport Widget 的同时传入了 position ,使得 Viewport 可以根据 position.pixels 来计算子元素的偏移量,从而实现滚动效果。梳理下方法调用的流程,先是 初始化 (选择 ScrollController → 配置 Physics → 处理 ScrollPostion):

dart 复制代码
Widget 创建
    ↓
ScrollableState.initState()
    ↓
检查 widget.controller
    ├─ 如果为 null → 创建 _fallbackScrollController = ScrollController()  // 第629行
    └─ 如果不为 null → 使用提供的 controller
    ↓
super.initState()
    ↓
didChangeDependencies()
    ↓
获取 MediaQuery 设置和设备像素比
    ↓
_updatePosition()
    ↓
获取 ScrollConfiguration 和基础 ScrollPhysics
    ↓
检查自定义 physics
    ├─ 有 widget.physics → _physics = widget.physics!.applyTo(_physics)
    ├─ 有 widget.scrollBehavior → 应用 scrollBehavior 的 physics
    └─ 都没有 → 使用默认 physics
    ↓
检查旧的 _position
    ├─ 存在旧 position → detach 旧 position → scheduleMicrotask 销毁
    └─ 不存在 → 直接进入下一步
    ↓
_effectiveScrollController.createScrollPosition(_physics!, this, oldPosition)
    ↓
_effectiveScrollController.attach(position)
    ↓
初始化完成 ✅

用户手指拖动事件 (状态转换:空闲→Hold→Drag→结束,正常结束-可能惯性滚动、取消操作-立即停止)

dart 复制代码
用户手指按下屏幕
    ↓
GestureDetector 识别触摸事件
    ↓
_handleDragDown()
    ↓
状态检查 assert(_drag == null && _hold == null)
    ↓
_hold = position.hold(_disposeHold)  // 创建保持控制器
    ↓
停止当前滚动动画
    ↓
用户开始移动手指
    ↓
_handleDragStart()
    ↓
状态检查
    ├─ _hold 可能为 null(用户代码触发了其他活动)→ 直接返回
    └─ _hold 存在 → 继续处理
    ↓
_drag = position.drag(details, _disposeDrag)
    ↓
_hold 自动变为 null(转换为拖拽状态)
    ↓
用户继续拖拽移动
    ↓
_handleDragUpdate() (持续调用)
    ↓
状态检查
    ├─ _drag 为 null(拖拽已结束)→ 不处理
    └─ _drag 存在 → _drag.update(details)
    ↓
ScrollPosition.setPixels()
    ↓
应用 ScrollPhysics 约束
    ↓
更新滚动位置 + 发送 ScrollNotification
    ↓
触发 UI 重建
    ↓
检查用户操作
    ├─ 继续拖拽 → 回到 _handleDragUpdate()
    ├─ 松开手指 → _handleDragEnd()
    └─ 取消操作 → _handleDragCancel()

松开手指分支:
_handleDragEnd()
    ↓
状态检查
    ├─ _drag 为 null → 不处理
    └─ _drag 存在 → _drag.end(details)
    ↓
根据结束速度判断
    ├─ 速度足够大 → 开始惯性滚动 (BallisticScrollActivity)
    └─ 速度不够 → 停止滚动 (IdleScrollActivity)
    ↓
_drag 变为 null
    ↓
拖拽流程完成 ✅

取消操作分支:
_handleDragCancel()
    ↓
检查 _gestureDetectorKey.currentContext
    ├─ 为 null(组件被销毁)→ 直接返回
    └─ 存在 → 继续处理
    ↓
清理状态
    ├─ _hold?.cancel() → _hold = null
    └─ _drag?.cancel() → _drag = null
    ↓
取消流程完成 ✅

数据流向:

dart 复制代码
用户手指拖动
    ↓
DragUpdateDetails.delta (比如: Offset(0, -10))
    ↓
ScrollDragController.update()
    ↓
ScrollPosition.setPixels(oldPixels + delta)
    ↓
position.pixels 变化 (100.0 → 90.0)
    ↓
didUpdateScrollPositionBy(-10.0)
    ↓
dispatchScrollStartedNotification 发送通知
    ↓
notifyListeners 通知监听器,ViewportOffset 继承自 ChangeNotifier
    ↓
┌─────────────────────┬─────────────────────┬─────────────────────┐
│   Viewport重新布局  	│   Scrollbar更新位置 	│   子组件可见性变化   │
└─────────────────────┴─────────────────────┴─────────────────────┘
    ↓                     ↓                     ↓
RenderSliver计算可见范围   滚动条thumb位置更新     Widget build/dispose
    ↓                     ↓                     ↓
子组件的renderObject更新   滚动条重绘             新的UI呈现给用户

2.2. Sliver - 滑动片段

"Sliver" 不是具体的类,而是一个协议/概念,它是 RenderSliver 和它的容器 (通常是 RenderViewport ) 间沟通的方式。这个协议主要由 两个核心数据结构 + performLayout() 构成。

2.2.1. 输入-SliverConstraints

RenderViewport (滚动视口) 传递给 RenderSliver 的 "布局约束信息 "。它告诉 Sliver:"这是你当前所处的环境,请根据这些信息结算你的布局"。SliverConstraints 的关键属性:

dart 复制代码
// ================ 🔄 滚动相关 ================

// 当前滚动偏移量,Sliver 根据它来判断自己哪一部分是可见的。
final double scrollOffset;

// 前面所有 Sliver 消耗的滚动距离总和。用于计算当前 Sliver 在整个可滚动区域中的起始位置。
final double precedingScrollExtent;  

// 剩余可绘制的像素数量。Sliver 应该根据这个值来决定绘制多少内容,不应该超过这个限制
final double remainingPaintExtent; 

// 前一个 Sliver 重叠的像素数量,当前一个 Sliver 的 paintExtent > layoutExtent 时
// 会产生重叠,通常用于固定头部等效果。如:SliverAppBar 收起时会与列表重叠
final double overlap;

// ================ 🎯 视口和缓存相关 ================

// 视口在主轴方向上的像素数量,对垂直列表来说,就是视口的高度。
final double viewportMainAxisExtent;

// 缓存区域的起始位置,相对于 scrollOffset。总是负数或零,
// 表示需要在当前可见区域之前预渲染多少内容。
final double cacheOrigin;

// 剩余缓存区域的大小。Sliver 应该从 cacheOrigin 开始,
// 尽可能提供 remainingCacheExtent 范围内的内容以优化滚动性能。
final double remainingCacheExtent;

// ================ 🧭 坐标系统信息 ================

// 滚动方向,决定了 scrollOffset 和 remainingPaintExtent 的增长方向
final AxisDirection axisDirection;  

// Sliver 内容的排列方向,相对于 axisDirection 而言,forward-相同,reverse反向
final GrowthDirection growthDirection;  

// 用户滚动的方向,用于判断用户是在向前滚动还是向后滚动,某些Sliver(如浮动头部)会根据此信息调整行为
final ScrollDirection userScrollDirection; 

// 交叉轴的可用空间。对于垂直列表来说就是宽度,对于水平列表来说就是高度。
final double crossAxisExtent;

// 交叉轴的方向。通常用于垂直列表中描述文字方向是从左到右还是从右到左。
final AxisDirection crossAxisDirection; 

2.2.2. 输出-SliverGeometry

当 RenderSliver 的 performLayout() 被调用后,它必须计算并设置自己的 geometry 属性,这是它返回给 RenderViewport 的 "布局结果 "。SliverGeometry 的关键属性:

dart 复制代码
class SliverGeometry {
  // ================ 🎨 核心尺寸信息 ================

  // Sliver 总的可滚动范围,表示用户需要滚动多少距离才能从这个 Sliver 的开始滚动到结束。
  final double scrollExtent;

  // 当前实际绘制的像素范围,表示这个 Sliver 在当前滚动位置下实际占用的可见区域大小。
  final double paintExtent;

  // 布局占用的空间大小,决定下一个 Sliver 的布局位置,默认等于 paintExtent。
  // 当需要 "挤压"后续 Sliver 时会小于 paintExtent。
  final double layoutExtent;

  // 该 Sliver 能够绘制的最大范围。用于支持收缩包装的视口,
  表示如果有无限空间时这个 Sliver 最多能绘制多大。
  final double maxPaintExtent;

  // 当 Sliver 被固定在边缘时,能够阻挡内容滚动的最大范围,应用栏就是最典型的例子。
  final double maxScrollObstructionExtent;
  
  // ================ 📦 位置和交互信息 ================

  // 交叉轴占用的空间大小,如果为null,则使用约束中的 crossAxisExtent。
  // 用于某些需要自定义交叉轴大小的 Sliver。
  final double crossAxisExtent;
  
  // 绘制起始位置的偏移量,如果 Sliver 想要在其布局位置之前开始绘制 
  // (如阴影效果),这个值就是负数
  final double paintOrigin;

  // 可以响应点击事件的范围,默认等于 paintExtent,但某些情况下
  // 可能需要扩大或缩小点击区域。
  final double hitTestExtent;

  // 缓存区域消耗的大小。表示这个 Sliver 从剩余缓存区域中消耗了多少空间,用于优化滚动性能。
  final double cacheExtent;
  
  // ================ 🔧 状态信息 ================

  // 该 Sliver 是否应该被绘制。默认情况下,paintExtent > 0 时为 true,否则为 false。
  final bool visible;

  // 是否有视觉溢出。如果为 true,视口需要对子组件进行裁剪以避免内容溢出到视口边界之外。
  final bool hasVisualOverflow;

  // 滚动偏移修正值。如果不为 null,父组件会调整滚动位置并重新布局。
  // 用于处理滚动位置需要修正的特殊情况。
  final double? scrollOffsetCorrection;
}

2.2.3. 协议流程总结

  • RenderViewport 调 子RenderSliver.layout() ,并传入 SliverConstraints (输入)。
  • RenderSliver.performLayout() 被触发,RenderSliver 的具体子类 (如RenderSliverList) 会根据 SliverConstraints 计算出自己需要展示哪些子元素、它们的位置,并最终计算出一个 SliverGeometry (输出)。
  • RenderSliver 将计算好的 SliverGeometry 赋值给自己的 geometry 属性。
  • RenderViewport 读取 geometry,从而知道这个 Sliver 占了多少空间、下一个 Sliver 应该从哪里开始布局等信息,然后继续布局下一个 Sliver。

RenderSliver子类.performLayout() 中进行 布局计算 的示例代码:

dart 复制代码
void performLayout() {
  // 1. 接收约束
  final SliverConstraints constraints = this.constraints;
  
  // 2. 分析滚动状态
  final double scrollOffset = constraints.scrollOffset;
  final double remainingExtent = constraints.remainingPaintExtent;
  
  // 3. 计算内容布局
  // ... 具体的布局逻辑 ...
  
  // 4. 生成几何信息
  geometry = SliverGeometry(
    scrollExtent: totalContentHeight,      // 总内容高度
    paintExtent: visibleContentHeight,     // 可见内容高度
    layoutExtent: layoutContentHeight,     // 布局影响高度
    maxPaintExtent: maxContentHeight,      // 最大绘制高度
    hasVisualOverflow: hasOverflow,        // 是否溢出
  );
}

梳理下 RenderSliver 的子类们:

💡 Tips :Sliver 组件们:ListView → SliverList 、GridView → SliverGrid 、SliverToBoxAdapter (用于将一个普通Box组件进行Sliver适配)、SliverAppBar 、SliverPersistentHeader (吸顶头部)、SliverFillRemaining (填充视口剩余空间)、SliverPadding (为Sliver添加内边距)、SliverLayoutBuilder (可以根据 Sliver 的几何信息来构建其子组件) 等。

2.3. Viewport - 视口管理器

继承 MultiChildRenderObjectWidget (多子Widget),实现 "懒加载/按需渲染" 滚动视图的基石,主要职责:

  • 管理可见区域显示 :根据自身尺寸和给定的偏移量显示子组件的子集,只渲染在视口范围内可见的 Sliver子组件,而不是全部内容。
  • 协调滚动偏移 :接收并处理 ViewportOffset 传递的滚动偏移量信息,随着偏移量的变化,动态调整哪些子组件在视口中可见。
  • 实现高效的布局算法 :采用视口感知的布局协议,向 Sliver子组件 传递约束信息,包含可见空间剩余量等视口相关信息,使子组件能够智能地决定渲染内容。
  • 支持无限滚动机制:通过按需构建机制,只创建当前可见的 Widget,在布局阶段与构建阶段交错进行,实现高性能的无限列表。
  • 处理不同类型的 Sliver 组合 :统一管理线性列表、网格、可折叠头部等不同类型的 Sliver,通过 Sliver布局协议 协调各种滚动效果,如视差滚动、折叠头部等。
  • 维护渲染边界和裁剪:定义内容的可视边界,对超出视口的内容进行裁剪,管理重绘边界,优化渲染性能。

构造函数:

dart 复制代码
Viewport({
  super.key,
  this.axisDirection = AxisDirection.down,	// 主轴方向,决定了滚动的方向和布局的起点。
  this.crossAxisDirection,	// 交叉轴方向,它会影响子项在交叉轴上的布局顺序。
  this.anchor = 0.0,	// 锚点
  required this.offset,	// 滚动偏移控制器,通常由ScrollPosition实现。
  this.center,	// 中心子项的Key
  this.cacheExtent,	// 缓存区域大小
  this.cacheExtentStyle = CacheExtentStyle.pixel,	// 缓存区域计算方式
  this.clipBehavior = Clip.hardEdge,	// 超出Viewport边界内容的裁剪行为
  List<Widget> slivers = const <Widget>[],	// 子组件列表
})

属性详解:

  • anchor : double,表示视口中的"零点" (scrollOffset为0.0的点) 在视口自身中的位置比例。0.0-视口的顶部(或左侧) 是滚动偏移的零点,当 scrollOffset 为 0 时,内容的开头对齐视口的开头。1.0-视口的底部 (或右侧) 是滚动偏移的零点。0.5-视口的中心是滚动偏移的零点。anchor 的改变会影响内容如何从 center key 开始向两侧填充。对于反向列表(reverse:true),ListView会将其设置为1.0。
  • center :Key?, 中心子项的Key ,这是一个优化参数。当 Viewport 首次布局时,它会尝试找到这个 Key 对应的 Sliver,并假定它位于 scrollOffset 为 0.0 的位置。这主要有两个用途:快速定位 -在拥有大量数据时,可以快速定位到初始显示位置,而无需从头开始构建。布局稳定性-当 Viewport 的尺寸变化时 (如屏幕旋转) 通过 center key 可以保持同一个子项在视口中的相对位置,防止列表"跳动"。
  • cacheExtent:doube?,指定在视口可见区域之外,上下(或左右) 应该预先构建和布局的区域大小。如:如果视口高度为 600px,cacheExtent 为 200px,那么系统会渲染从 -200px 到 800px 这个范围内的列表项。
  • cacheExtentStyle :CacheExtentStyle ,缓存单位,pixel -默认,逻辑像素,viewport-视口大小的倍数,如:cacheExtent 为 1.0 意味着在视口上方和下方各缓存一个视口高度的区域。
  • slivers :List,只能放 Sliver 类型的 Widget, 如:SliverList、SliverGrid、SliverAppBar, SliverToBoxAdapter 等。

核心方法:

dart 复制代码
// Widget 层和 RenderObject 层的连接点。Viewport Widget 
// 调用此方法来创建一个 RenderViewport 实例,并将构造函数中的所有参数传递给它。
createRenderObject(BuildContext context)

// 当 Viewport Widget 的配置发生变化时(例如 axisDirection 改变),此方法会被调用,
// 用新的配置去更新已存在的 RenderViewport 对象。
updateRenderObject(BuildContext context, RenderViewport renderObject)

// 创建 MultiChildRenderObjectElement,这是 Widget 在元素树中的表示。
createElement()

Viewport 在 渲染树 中有两个主要实现:

  • RenderViewport :标准视口 RenderObject,会扩展填充整个主轴空间。
  • RenderShrinkWrappingViewport :收缩包装视口 RenderObject,会根据其子组件在主轴上的大小来调整自身大小。
dart 复制代码
// 主轴上占据所有空间
RenderViewport extends RenderViewportBase<SliverPhysicalContainerParentData>{}

abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMixin<RenderSliver>>
    extends RenderBox with ContainerRenderObjectMixin<RenderSliver, ParentDataClass>
    implements RenderAbstractViewport { ... }

// 主轴上所有以包裹其内容 (shrinkWrap: true)
class RenderShrinkWrappingViewport extends RenderViewportBase<SliverLogicalContainerParentData> { ... }

父类 RenderViewportBase 继承了 RenderBox ,这表明 RenderViewport 自身遵循 盒子模型 (父节点会给他一个 BoxConstraints ,它必须在此约束下确定自己的size),而管理的子节点列表则必须是 RenderSliver 类型,😄 可以把 RenderViewport 看作是 盒子协议 & Sliver协议 间的桥梁。具体的方法调用链路:

dart 复制代码
// ================ 🚀 初始化 ================

RenderViewport 构造函数
    ↓
调用父类 RenderViewportBase 构造
    ├─ 设置 axisDirection 和 crossAxisDirection // 设置主轴和交叉轴方向
    ├─ 绑定 ViewportOffset // 绑定滚动偏移控制器
    └─ 初始化 cacheExtent 和 clipBehavior // 设置缓存范围和裁剪行为
    ↓
设置 RenderViewport 特有属性
    ├─ _anchor = anchor // 设置锚点位置(0.0-1.0)
    └─ _center = center // 设置中心sliver
    ↓
addAll(children) // 批量添加子元素
    └─ adoptChild() // 为每个子元素建立父子关系
        ├─ setupParentData() // 设置 SliverPhysicalContainerParentData
        ├─ attach() // 将子元素附加到渲染管道
        └─ markNeedsLayout() // 标记需要重新布局
    ↓
设置默认center
    ├─ center == null && firstChild != null → _center = firstChild
    └─ 否则 → 保持原有center设置

attach() // 将视口附加到渲染管道
├─ super.attach(owner) // 调用父类attach方法
├─ _offset.addListener(markNeedsLayout) // 监听滚动偏移变化
└─ 递归调用子元素attach() // 确保所有子元素都正确附加

// ================ 📦 布局 ================

performLayout() // 布局入口
    ↓
应用视口尺寸到偏移控制器
    ├─ axis == Axis.vertical → offset.applyViewportDimension(size.height)
    └─ axis == Axis.horizontal → offset.applyViewportDimension(size.width)
    ↓
检查是否有子元素
    ├─ center == null → 设置滚动范围为0并返回
    │   ├─ _minScrollExtent = 0.0
    │   ├─ _maxScrollExtent = 0.0
    │   ├─ _hasVisualOverflow = false
    │   └─ offset.applyContentDimensions(0.0, 0.0)
    └─ 有子元素 → 继续布局流程
    ↓
计算布局参数
    ├─ (mainAxisExtent, crossAxisExtent) = switch (axis) // 计算主轴和交叉轴尺寸
    ├─ centerOffsetAdjustment = center!.centerOffsetAdjustment // 获取中心偏移调整
    └─ maxLayoutCycles = _maxLayoutCyclesPerChild * childCount // 设置最大布局循环次数
    ↓
执行布局循环
    ├─ _attemptLayout() // 尝试布局所有子元素
    │   ├─ 重置布局数据
    │   │   ├─ _minScrollExtent = 0.0
    │   │   ├─ _maxScrollExtent = 0.0
    │   │   └─ _hasVisualOverflow = false
    │   ├─ 计算中心偏移和绘制范围
    │   │   ├─ centerOffset = mainAxisExtent * anchor - correctedOffset
    │   │   ├─ reverseDirectionRemainingPaintExtent = clampDouble(centerOffset, 0.0, mainAxisExtent)
    │   │   └─ forwardDirectionRemainingPaintExtent = clampDouble(mainAxisExtent - centerOffset, 0.0, mainAxisExtent)
    │   ├─ 计算缓存范围
    │   │   ├─ _calculatedCacheExtent = switch (cacheExtentStyle) // 根据缓存样式计算实际缓存大小
    │   │   └─ 计算各方向的缓存范围
    │   ├─ layoutChildSequence(反向子元素) // 布局center之前的子元素
    │   │   ├─ child.layout(SliverConstraints) // 为每个子sliver执行布局
    │   │   ├─ updateChildLayoutOffset() // 更新子元素的布局偏移
    │   │   │   └─ childParentData.paintOffset = computeAbsolutePaintOffset()
    │   │   └─ updateOutOfBandData() // 更新滚动范围等额外数据
    │   │       ├─ GrowthDirection.reverse → _minScrollExtent -= scrollExtent
    │   │       └─ hasVisualOverflow → _hasVisualOverflow = true
    │   └─ layoutChildSequence (正向子元素) // 布局center及之后的子元素
    │       ├─ child.layout(SliverConstraints)	// ✨ 给每个子 Sliver下发约束,执行布局
    │       ├─ SliverGeometry childLayoutGeometry = child.geometry! // ✨ 获得Sliver 返回的几何信息
    │       ├─ updateChildLayoutOffset() // 记录每个 Sliver 的绘制位置
    │       └─ updateOutOfBandData() // 基于每个 Sliver 的反馈更新全局信息
    │           ├─ GrowthDirection.forward → _maxScrollExtent += scrollExtent	// 累计最大滚动范围
    │           └─ hasVisualOverflow → _hasVisualOverflow = true	// 判断是否有溢出
    ├─ correction != 0.0 → offset.correctBy(correction) // 修正滚动偏移量
    └─ correction == 0.0 → offset.applyContentDimensions() // 应用最终的内容尺寸范围

// ================ 🎨 绘制 ================

paint() // 绘制入口
    ↓
检查是否有子元素
    ├─ firstChild == null → 直接返回
    └─ 有子元素 → 继续绘制流程
    ↓
检查是否需要内容裁剪
    ├─ hasVisualOverflow && clipBehavior != Clip.none → 创建裁剪区域
    │   └─ _clipRectLayer.layer = context.pushClipRect() // 创建裁剪图层
    │       ├─ needsCompositing // 检查是否需要合成
    │       ├─ Offset.zero & size // 设置裁剪矩形
    │       ├─ _paintContents // 绘制内容回调
    │       └─ oldLayer: _clipRectLayer.layer // 复用旧图层
    └─ 无溢出或不裁剪 → 直接绘制内容
        ├─ _clipRectLayer.layer = null // 清理裁剪图层
        └─ _paintContents(context, offset) // 直接绘制内容
    ↓
_paintContents() // 绘制所有可见内容
    └─ 遍历 childrenInPaintOrder // 按绘制顺序处理子元素
        ├─ child.geometry!.visible → 检查子元素是否可见
        └─ context.paintChild(child, offset + paintOffsetOf(child)) // 绘制每个可见的子元素
            ├─ paintOffsetOf(child) // 获取子元素绘制偏移
            │   └─ return childParentData.paintOffset
            └─ 应用变换矩阵并绘制

😶 老规矩画个图帮助理解:

😏 原理学完,动手缝合下三个构件,实现一个 最简单 的滑动效果【--->c35/simple_scroll_demo.dart<---】:

运行效果:

😄 是的,就是这么简单,关于Flutter滑动机制 "三个核心构件" 就了解到这,接着开始学习具体的滑动组件。

3. 常用滑动组件

3.1. SingleChildScrollView

简介:

一个用于解决 "内容溢出 " 问题的简单 滚动容器Widget ,可以让 单个子Widget 在空间不足时进行滚动。

3.1.1. API 详解

继承 StatelessWidget ,大部分属性在 Scrollable 那里已经详细讲了,不再赘述,挑几个没讲到的:

  • padding :EdgeInsetsGeometry? ,在滚动区域内部添加内边距 (child外边),边距会随着内容一起滚动。
  • keyboardDismissBehavior :ScrollViewKeyboardDismissBehavior ,用户与滚动区域交互时,如何以及何时自动收起弹出的键盘。【manual -默认】滚动视图本身不会做任何事情来收起键盘,键盘的收起完全依赖于其他方式,如:回退键、FocusScope.of(context).unfocus() 等。【onDrag 】当键盘弹出时,用户在滚动视图上开始拖动 (滚动) 的那一刻,键盘就会自动收起 ✨。
  • primary :bool? ,是否使用主滚动控制器,默认null,由系统自动根据上下文自动选择最合适的控制器。为 true 时,使用 主滚动控制器-PrimaryScrollController (不能同时设置自定义controller),🤔 用于页面级别的滚动,需要与其它组件共享滚动状态,在移动平台上它会自动处理一些系统级的交互。如:Android 从屏幕边缘拖动可以触发返回操作,主滚动视图会优先响应滚动。在 iOS 上,点击状态栏可以快速滚动到顶部。在Scaffold中,如果body是一个可滚动组件,当键盘弹出时,会调整滚动区域以保证焦点输入框可见。为 false 时,当一个页面有多个滚动视图时,只能有一个可以是primary,其它都应该显示设置为false。一般用于独立的滚动区域:如对话框、侧边栏。

简单使用示例【--->c35/single_child_scroll_view_demo.dart<---】运行效果:

😄 非常简单,就切滚动物理效果、设置键盘随列表滚动消失、以及快速滑动到底部、中部和顶部。接着提下使用 SingleChildScrollView 的 两个注意事项:

① 与Column 配合使用的冲突

Column 试图占用尽可能多的空间,而 SingleChildScrollView 提供无限空间,这会导致冲突,需要对Column 进行高度约束。可以使用 LayoutBuilder + ConstrainedBox 设置最小高度,或者使用IntrinsicHeight 强制 Column 适应内容大小。

② 加载机制

SingleChildScrollView 会一次性将它的 child 全部渲染到内存中 ,而不管这个 child 有多大,它只是在 "视口 " 中移动显示。它更适合处理 内容相对固定且不太多 的场景,对于 大量动态内容 ,还是得选择具有 "懒加载 " 特性的滚动组件 (如 Listview ),混合布局可以考虑用 CustomScrollView。

3.1.2. 源码剖析

关于第一个注意事项 "SingleChildScrollView 提供无限空间",在源码中的体现 (移除了滚动方向的尺寸约束):

接着是第二个 "一次性将child全部渲染到内存中 ",跟下代码调用:SingleChildScrollView → build()

SingleChildScrollView 的 Viewport 具体实现 Widget 是 _SingleChildViewport ,对应的 RenderObject → _RenderSingleChildViewport

绘制方法:

上面通过 pushClipRect() 来显示显示区域 (视觉裁剪) 实现 "窗口效果":

🤔 那 "滚动效果 " 呢?通过改变 paintOffset 来移动 子组件的绘制位置:

😁 视觉效果 (滑动) 与实际移动反向是 "相反" 的 ❗️ 向下滚动时,子组件是向上移动的,Y轴负值表示向上移动。

dart 复制代码
初始状态 (position = 0):
┌─────────────────┐ ← 视口顶部
│  子组件内容A     │
│  子组件内容B     │
│  子组件内容C     │
└─────────────────┘ ← 视口底部
│  子组件内容D     │ ← 不可见
│  子组件内容E     │ ← 不可见

向下滚动 (position = 100):
                   ← 子组件内容A (不可见)
┌─────────────────┐ ← 视口顶部
│  子组件内容B     │
│  子组件内容C     │
│  子组件内容D     │
└─────────────────┘ ← 视口底部
│  子组件内容E     │ ← 不可见

😊 可以将 clipBehavior 属性 Clip.none 来验证是否 child 是否真的是 全部渲染 【--->c35/single_child_scroll_none_clip_demo.dart<---】运行效果:

3.2. ScrollView

抽象类 ,Flutter中绝大部分 "可滚动视图 " 的 顶层父类 ,核心思想是 "滚动机制 & 内容布局 " 的解耦,定义了一个可滚动区域的通用配置框架,具体如何排列子元素 (列表、网格或是其它形式) 则交由其子类实现。它的使命:

将用户的 滚动意图 (由Scrollable捕获) 转化为 视口内内容的平移 (由Viewport 和 Slivers 实现)。

核心方法:

dart 复制代码
// ❗️ 子类都必须实现,用于返回 Widget列表 (必须是Sliver类型,
// 如:SliverList, SliverGrid, SliverToBoxAdapter),
// ScrollView 会把这个 Sliver 列表交给 Viewport 去渲染.
@protected
List<Widget> buildSlivers(BuildContext context);

// 构建Viewport,根据 shrinkWrap 决定使用哪种 Viewport
@protected  
Widget buildViewport(
  BuildContext context,
  ViewportOffset offset, 
  AxisDirection axisDirection,
  List<Widget> slivers,
){
  if (shrinkWrap) {
      return ShrinkWrappingViewport(...);
  }
  return Viewport();
}

// 结合 scrollDirection 和 reverse,用于获取滚动方向。
@protected
AxisDirection getDirection(BuildContext context) {
  return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse);
}

// 实现了 StatelessWidget.build()
@override
Widget build(BuildContext context) {
  // 第1步:构建 slivers 列表
  final List<Widget> slivers = buildSlivers(context);
  
  // 第2步:确定滚动方向
  final AxisDirection axisDirection = getDirection(context);

  // 第3步:确定有效的 primary 属性
  final bool effectivePrimary = primary
      ?? controller == null && PrimaryScrollController.shouldInherit(context, scrollDirection);

  // 第4步:获取滚动控制器
  final ScrollController? scrollController = effectivePrimary
      ? PrimaryScrollController.maybeOf(context)
      : controller;

  // 第5步:创建 Scrollable 组件
  final Scrollable scrollable = Scrollable(
    dragStartBehavior: dragStartBehavior,
    axisDirection: axisDirection,
    controller: scrollController,
    physics: physics,
    scrollBehavior: scrollBehavior,
    semanticChildCount: semanticChildCount,
    restorationId: restorationId,
    // 💡 将 Slivers 喂给 Viewport
    viewportBuilder: (BuildContext context, ViewportOffset offset) {
      return buildViewport(context, offset, axisDirection, slivers);
    },
    clipBehavior: clipBehavior,
  );

  // 第6步:处理 PrimaryScrollController 继承
  final Widget scrollableResult = effectivePrimary && scrollController != null
      ? PrimaryScrollController.none(child: scrollable)
      : scrollable;

  // 第7步:处理键盘消失行为
  if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
    return NotificationListener<ScrollUpdateNotification>(
      child: scrollableResult,
      onNotification: (ScrollUpdateNotification notification) {
        final FocusScopeNode focusScope = FocusScope.of(context);
        if (notification.dragDetails != null && focusScope.hasFocus) {
          focusScope.unfocus();
        }
        return false;
      },
    );
  } else {
    return scrollableResult;
  }
}

😄 将通用的滚动逻辑 (视口、控制器、物理效果) 都进行了封装,子类只需实现 buildSlivers() 塞 Slivers。

3.3. ListView

简介:

用于创建 可滚动的线性列表布局 的高级滑动组件。

3.3.1. API 详解

继承 BoxScrollView,同样挑几个属性讲讲:

  • shrinkWrap :bool,默认false,尽可能占据父组件在滚动方向上提供的所有空间,如果父组件没限制 (如Column),就会导致无限高度/宽度错误。为true时,尺寸会收缩以包裹其内容的总高度/宽度,但这样会牺牲性能,因为它需要计算所有子项的尺寸 (即便不可见) 来确定自身的总尺寸。故仅在必要时使用,如:在Column 中嵌套一个 ListView 时。
  • itemExtent :double? ,子项固定固定高度,如果所有子项都有相同的高度/宽度,设置此属性能极大地提高性能。因为 Listview不再需要动态计算每个子项的尺寸,可以直接算出滚动偏移,从而简化布局过程。
  • itemExtentBuilder :ItemExtentBuilder? ,子项高度构建器 ,其实就一方法回调,有两个参数:index -当前子项索引 和 dimensions-当前滚动视口的尺寸信息,返回值double-子项高度。适用于:当你的列表项目高度是可预知的、有规律的,但又不完全相同的场景,如:奇数index,高度50,偶数index,高度100。
  • prototypeItem :Widget? ,列表项高度/宽度基本一致但又不想写死 itemExtent,可以提供一个 原型Widget,ListView 会测量这个原型一次,然后假设所有其他项都具有相同的尺寸。
  • cacheExtent :double? ,缓存范围,Viewport 的预加载区域大小 (默认250.0) 增加此值可以减少快速滚动时的空白,但因为会提前构建更多项,所以会增加内存消耗。
  • addAutomaticKeepAlives :bool ,默认true,当列表项滚动出视口时,是否自动使用 AutomaticKeepAlive 来保存它们的状态。列表项 的 State 也需要混入 AutomaticKeepAliveClientMixin 重写 wantKeepAlive 返回 true 才会有效,对列表项包含复杂状态 (如输入框内容、动画状态) 时很有用。更复杂的和状态,应使用外部状态管理方案 (如Provider、BLoC、Riverpod等),将状态与 UI 分离。
  • addRepaintBoundaries :bool,默认true,是否为每个列表项自动包裹一个 RepaintBoundary,用于隔离每个列表项的重绘,防止一个项的动画或变化导致整个列表重绘,从而优化性能。

提供了 四种构造方式:

dart 复制代码
// ✨ 默认构造函数,接收一个 List<Widget> 作为 children。
// 内部使用 SliverChildListDelegate,它会一次性构建所有的子 Widget,
// 所以仅适用于【少量、固定的子项】的场景
ListView(children: <Widget>[ Container(), ...])

// ✨ ListView.builder(),最常用、最高性能
// 内部使用 SliverChildBuilderDelegate,它不会立即创建所有列表项,而是通过
// itemBuilder 回调函数,在列表项即将进入视口时才进行构建。适用于【大量或无限子项】的场景
final List<String> entries = List<String>.generate(1000, (i) => 'Item $i');
ListView.builder(
  itemCount: entries.length,	// 列表项总数,如果为 null,则表示一个无限列表。
  itemBuilder: (BuildContext context, int index) {
    // 根据索引 index 返回对应的 Widget
    return ListTile(
      title: Text(entries[index]),
    );
  },
)

// ✨ ListView.separated(),builder() 变种,可以方便地在每个列表项之间插入一个分割线 Widget
ListView.separated(
  itemCount: 100,
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text('Item $index'));
  },
  // 根据索引 index 构建位于 item[index] 和 item[index + 1] 间的分割线。
  separatorBuilder: (BuildContext context, int index) {
    return const Divider(color: Colors.grey);
  },
)

// ✨ ListView.custom() 完全自定义,允许你提供一个自定义的 SliverChildDelegate
// 通过「childrenDelegate」参数传入,前三种构造方法其实都是这个构造方法的语法糖。
// 这种构造方式很少直接使用,适用场景:需对子项的创建、销毁、保活等行为进行更精细控制

3.3.2. 源码剖析

默认构造函数,内部使用 SliverChildListDelegate:

kotlin 复制代码
SliverChildListDelegate extends SliverChildDelegate { 
  SliverChildListDelegate(
    this.children, // 这里是 List<Widget>
    {
    this.addAutomaticKeepAlives = true,
    this.addRepaintBoundaries = true,
    this.addSemanticIndexes = true,
    this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
    this.semanticIndexOffset = 0,
  }) : _keyToIndex = <Key?, int>{null: 0};
}

看下 build() 方法:

"盐值(Salt) " 这个概念来自密码学,指的是在 加密过程中添加的额外随机数据 。在这里是给原有Key 添加额外信息 ,如:原始Key("item_1") 包装成 _SaltedValueKey(Key('item_1')),目的是让这个Key在delegate内部是唯一的,以避免Flutter混乱这些Key,导致状态错乱。

"KeyedSubtree " 是一个特殊的 Widget ,它的作用是:为整个子树提供一个稳定的身份标识,帮助Flutter的渲染系统正确追踪Widget,确保当Widget位置变化时,状态能正确保持。"Element复用机制 ":当Widget第一次显示时,Flutter创建对应的 Element 和 RenderObject ,当Widget重新构建时,Flutter会尝试 复用已有的Element ,复用条件是:Widget的runtimeType和key都相同。

😶 用的 children ,传入时就是已经创建的Widget,所以是一次性构建所有的子 Widget ,接着看下 builder() 构建的方式,用的 SliverChildBuilderDelegate:

dart 复制代码
class SliverChildBuilderDelegate extends SliverChildDelegate {
  const SliverChildBuilderDelegate(
    this.builder, // 这里是 NullableIndexedWidgetBuilder
    {
    this.findChildIndexCallback,
    this.childCount,
    this.addAutomaticKeepAlives = true,
    this.addRepaintBoundaries = true,
    this.addSemanticIndexes = true,
    this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
    this.semanticIndexOffset = 0,
  });
}

typedef NullableIndexedWidgetBuilder = Widget? Function(BuildContext context, int index);

看下 build() 方法:

💁‍♂️ separated() 也是用的 SliverChildBuilderDelegate ,custom() 则需要通过 childrenDelegate 参数传入自定义的 SliverChildDelegate 。继承关系:ListView → BoxScrollView → ScrollView ,BoxScrollView 在 ScrollView 的基础上增加了 "自动填充padding " (避免状态栏遮挡-垂直滚动自动添加顶部填充、避免底部导航栏-自动添加底部填充、刘海屏适配-自动处理异形屏幕的安全区域)。另外,还对布局构建进行了抽象,子类只需实现 buildChildLayout(BuildContext context) 方法。构建调用链路:

dart 复制代码
ScrollView.build()
  ↓
BoxScrollView.buildSlivers()
  ↓
ListView.buildChildLayout()	// 生成 SliverMultiBoxAdaptorWidget 对象
  ↓ 
BoxScrollView.buildSlivers() // 包装ListView生成的Sliver (如SliverPadding),返回[sliver]
  ↓
ScrollView.build() // 总装配
  ↓
@override
Widget build(BuildContext context) {
  final List<Widget> slivers = buildSlivers(context);  // ② 获取slivers
  final Scrollable scrollable = Scrollable(  // ① 创建Scrollable
    viewportBuilder: (context, offset) => buildViewport(context, offset, axisDirection, slivers),
  );
}

清楚明了,看下 ListView.buildChildLayout() ,根据不同情况,创建了四种类型的 Sliver Widget:

👀 跟下这四个 Sliver 到类的具体实现~

3.3.3. SliverList - 动态列表

每个子项都要调用 layout() 方法,需要缓存已测量子项的信息,核心源码:

dart 复制代码
class SliverList extends SliverMultiBoxAdaptorWidget {
  const SliverList({
    super.key,
    required super.delegate,
  });

  @override
  RenderSliverList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
    return RenderSliverList(childManager: element);
  }
}

// 底层 RenderObject 核心逻辑
class RenderSliverList extends RenderSliverMultiBoxAdaptor {
  // 🔥 关键:每个子项都需要单独进行布局计算
  @override
  void performLayout() {
    // 1. 遍历每个可见的子项
    // 2. 调用 child.layout() 计算每个子项的实际尺寸
    // 3. 累积计算总的滚动范围
    // 4. 确定每个子项的位置
    
    double scrollOffset = constraints.scrollOffset;
    double remainingExtent = constraints.remainingPaintExtent;
    
    // 💡 关键性能瓶颈:需要逐个测量每个子项的高度
    while (remainingExtent > 0) {
      RenderBox child = getChildAtIndex(index);
      child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
      
      // 📏 每次都要获取子项的实际高度
      double childExtent = getMainAxisExtent(child);
      // ... 位置计算和缓存逻辑
    }
  }
}

3.3.4. SliverFixedExtentList - 固定高度高性能列表

直接通过数学计算确定位置,跳过子项的layout过程,可以精确预测滚动范围,核心源码:

dart 复制代码
class SliverFixedExtentList extends SliverMultiBoxAdaptorWidget {
  const SliverFixedExtentList({
    super.key,
    required super.delegate,
    required this.itemExtent, // 👈 关键:固定高度
  });

  final double itemExtent; // 🔥 核心:所有子项的固定高度

  @override
  RenderSliverFixedExtentList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
    return RenderSliverFixedExtentList(
      childManager: element,
      itemExtent: itemExtent, // 传递固定高度
    );
  }
}

// 底层 RenderObject 核心逻辑
class RenderSliverFixedExtentList extends RenderSliverMultiBoxAdaptor {
  final double itemExtent;

  @override
  void performLayout() {
    // 🚀 性能优势:可以直接计算而不需要测量
    
    // 1. 🔥 直接计算可见区域内的项目数量
    double scrollOffset = constraints.scrollOffset;
    int firstVisibleIndex = (scrollOffset / itemExtent).floor();
    int lastVisibleIndex = ((scrollOffset + constraints.viewportMainAxisExtent) / itemExtent).ceil();
    
    // 2. 🚀 直接计算每个子项的位置(无需layout测量)
    for (int index = firstVisibleIndex; index <= lastVisibleIndex; index++) {
      RenderBox child = getChildAtIndex(index);
      
      // 💎 关键优化:强制设置子项高度,跳过子项自己的layout计算
      child.layout(
        constraints.asBoxConstraints(
          minHeight: itemExtent,
          maxHeight: itemExtent, // 强制固定高度
        ),
        parentUsesSize: false, // 💡 不需要获取子项尺寸
      );
      
      // 🎯 直接计算位置:索引 * 固定高度
      double childMainAxisPosition = index * itemExtent - scrollOffset;
      // 设置子项位置...
    }
    
    // 3. 🚀 直接计算总的滚动范围
    geometry = SliverGeometry(
      scrollExtent: childCount * itemExtent, // 直接计算总高度
      paintExtent: math.min(constraints.remainingPaintExtent, maxPaintExtent),
      maxPaintExtent: childCount * itemExtent,
    );
  }
}

3.3.5. SliverPrototypeExtentList - 原型高度列表

支持复杂的Widget作为原型,只需测量原型一次,后续使用固定高度算法,核心源码:

dart 复制代码
class SliverPrototypeExtentList extends SliverMultiBoxAdaptorWidget {
  const SliverPrototypeExtentList({
    super.key,
    required super.delegate,
    required this.prototypeItem, // 👈 关键:原型Widget
  });

  final Widget prototypeItem; // 🔥 核心:用于测量的原型Widget

  @override
  RenderSliverPrototypeExtentList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
    return RenderSliverPrototypeExtentList(
      childManager: element,
      prototypeItem: prototypeItem,
    );
  }
}

// 底层 RenderObject 核心逻辑:
class RenderSliverPrototypeExtentList extends RenderSliverMultiBoxAdaptor {
  Widget _prototypeItem;
  double? _prototypeExtent; // 缓存原型的高度
  
  @override
  void performLayout() {
    // 🎨 首次计算:测量原型Widget的高度
    if (_prototypeExtent == null) {
      _prototypeExtent = _measurePrototype();
    }
    
    // 💎 后续逻辑与SliverFixedExtentList类似
    double itemExtent = _prototypeExtent!;
    
    // 🚀 使用固定高度的高性能算法
    int firstVisibleIndex = (constraints.scrollOffset / itemExtent).floor();
    int lastVisibleIndex = ((constraints.scrollOffset + constraints.viewportMainAxisExtent) / itemExtent).ceil();
    
    for (int index = firstVisibleIndex; index <= lastVisibleIndex; index++) {
      RenderBox child = getChildAtIndex(index);
      
      // 🔥 强制使用原型高度
      child.layout(
        constraints.asBoxConstraints(
          minHeight: itemExtent,
          maxHeight: itemExtent,
        ),
        parentUsesSize: false,
      );
    }
  }
  
  // 🎨 原型测量方法
  double _measurePrototype() {
    // 1. 创建原型Widget的RenderObject
    RenderBox prototypeRenderBox = _createPrototypeRenderBox();
    
    // 2. 对原型进行layout测量
    prototypeRenderBox.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    
    // 3. 获取原型的主轴高度
    double extent = getMainAxisExtent(prototypeRenderBox);
    
    // 4. 清理原型RenderObject
    prototypeRenderBox.dispose();
    
    return extent;
  }
}

3.3.6. SliverVariedExtentList - 预定义不同高度列表

适用于高度已知,无需子项自己测量,itemExtentBuilder会被多次调用,计算过的高度会被缓存,核心源码:

dart 复制代码
class SliverVariedExtentList extends SliverMultiBoxAdaptorWidget {
  const SliverVariedExtentList({
    super.key,
    required super.delegate,
    required this.itemExtentBuilder, // 👈 关键:高度构建器
  });

  // 🔥 核心:根据索引和维度信息返回高度的回调
  final ItemExtentBuilder itemExtentBuilder;

  @override
  RenderSliverVariedExtentList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
    return RenderSliverVariedExtentList(
      childManager: element,
      itemExtentBuilder: itemExtentBuilder,
    );
  }
}

// 底层 RenderObject 核心逻辑:
class RenderSliverVariedExtentList extends RenderSliverMultiBoxAdaptor {
  final ItemExtentBuilder itemExtentBuilder;
  
  // 🔧 高度缓存机制
  final Map<int, double> _cachedExtents = <int, double>{};

  @override
  void performLayout() {
    double scrollOffset = constraints.scrollOffset;
    double remainingExtent = constraints.remainingPaintExtent;
    
    // 📊 混合策略:预计算 + 按需计算
    int currentIndex = 0;
    double currentOffset = 0;
    
    // 1. 🔍 找到第一个可见的子项(通过累积高度)
    while (currentOffset < scrollOffset) {
      double itemHeight = _getItemExtent(currentIndex);
      currentOffset += itemHeight;
      currentIndex++;
    }
    
    // 2. 🎯 渲染可见区域内的子项
    while (remainingExtent > 0 && currentIndex < childCount) {
      double itemHeight = _getItemExtent(currentIndex);
      
      RenderBox child = getChildAtIndex(currentIndex);
      // 💡 性能优化:使用预定义的高度约束子项
      child.layout(
        constraints.asBoxConstraints(
          minHeight: itemHeight,
          maxHeight: itemHeight,
        ),
        parentUsesSize: false,
      );
      
      remainingExtent -= itemHeight;
      currentIndex++;
    }
  }
  
  // 🔥 核心方法:获取指定索引的高度
  double _getItemExtent(int index) {
    // 缓存机制避免重复计算
    return _cachedExtents[index] ??= itemExtentBuilder(index, constraints);
  }
}

3.4. GridView

简介:

用于创建 二维可滚动网格布局容器 ,能够将一系列子Widget 排列成 多行多列的网格形式,支持横向和纵向滚动。

3.4.1. API 详解

继承 BoxScrollView ,属性和Listview差不多,核心是 "gridDelegate",它负责定义网格的几何结构。

dart 复制代码
// 控制网格布局的代理
final SliverGridDelegate gridDelegate;

// Flutter 提供了两个实现
SliverGridDelegateWithFixedCrossAxisCount
- crossAxisCount: 固定列数。
- mainAxisSpacing: 主轴间距。
- crossAxisSpacing: 交叉轴间距。
- childAspectRatio: 子项的宽高比。默认为 1.0。非常重要,用于计算子项在主轴上的高度。

SliverGridDelegateWithMaxCrossAxisExtent
- maxCrossAxisExtent: 子项在交叉轴上的最大尺寸
- 其它参数同上

提供了 五种构造方式:

dart 复制代码
// 模拟一些数据
final List<int> data = List.generate(50, (index) => index);

// ✨ ① GridView() - 默认构造函数
// 需要手动提供 gridDelegate 和 children 列表,适合少量、固定的子项。
GridView(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,	// 交叉轴子项数量
    crossAxisSpacing: 10,	// 交叉轴方向相邻子项间的间距
    mainAxisSpacing: 10,	// 主轴方向相邻子项间的间距
    childAspectRatio: 1.0, // 子项的宽高比
  ),
  children: data.map((i) => GridItem(index: i)).toList()
);


// ✨ ① GridView.count() - 最常用,用于创建固定列数的网格
GridView.count(
  // 核心参数:固定列数
  crossAxisCount: 4,
  crossAxisSpacing: 10,
  mainAxisSpacing: 10,
  children: data.map((i) => GridItem(index: i)).toList(),
);

// ✨ ③ GridView.extent() - 响应式布局,根据子项的最大宽度自动计算列数。
GridView.extent(
  // 核心参数:子项在交叉轴上的最大尺寸
  maxCrossAxisExtent: 120.0,
  crossAxisSpacing: 10,
  mainAxisSpacing: 10,
  children: data.map((i) => GridItem(index: i)).toList(),
);

// ✨ ④ GridView.builder() - 高性能懒加载,用于大量或无限数据的场景,按需构建子项。
GridView.builder(
  // 布局代理,与默认构造函数中的一样
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,
    crossAxisSpacing: 10,
    mainAxisSpacing: 10,
  ),
  // 核心参数:子项总数
  itemCount: data.length,
  // 核心参数:子项构建器
  itemBuilder: (context, index) {
    // 只有当 item 将要显示时,此方法才会被调用
    print('Building item for builder: $index');
    return GridItem(index: data[index]);
  },
);

// ✨ ⑤ GridView.custom() - 完全自定义,提供了最大的灵活性,可以自定义子项的构建和管理策略。
GridView.custom(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 150,
    mainAxisSpacing: 10,	// 主轴间距
    crossAxisSpacing: 10,	// 交叉轴间距
  ),
  // 核心参数:子项代理
  // SliverChildBuilderDelegate 行为与 GridView.builder 相同
  childrenDelegate: SliverChildBuilderDelegate(
    (context, index) {
      return GridItem(index: data[index]);
    },
    childCount: data.length,
  ),
);

简单使用示例【--->c35/gridview_demo.dart<---】运行效果:

3.4.2. 源码剖析

有了前面 ListView 的经验,看起源码来可谓是轻车熟路了,先是 构造方法:

dart 复制代码
// 😄 前三个的 Sliver 都是【SliverChildListDelegate】,全是一次性加载所有子控件
// 区别在于【网格布局代理类】的不同:
// 
//「SliverGridDelegateWithFixedCrossAxisCount」
// 固定交叉轴上的子项数量 (列数/行数),无论屏幕多大,始终显示固定的列数
// 子项宽度:childWidth = (totalWidth - spacing) / crossAxisCount 
//
//「SliverGridDelegateWithMaxCrossAxisExtent」
// 固定子项在交叉轴上的最大尺寸,根据屏幕大小自动计算合适的列数
// 开发者指定子项最大宽度固定,如:maxCrossAxisExtent = 150.0 
// 计算列数:crossAxisCount = ceil(totalWidth / (maxCrossAxisExtent + spacing))
// 计算子项宽度:childWidth = (totalWidth - spacing) / crossAxisCount

GridView()  → 需要自定义 SliverGridDelegateWithFixedCrossAxisCount 通过 gridDelegate 传入

GridView.count()  → 内部自动创建 SliverGridDelegateWithFixedCrossAxisCount

GridView.extend() → 内部自动创建 SliverGridDelegateWithMaxCrossAxisExtent
  
// 💡 懒加载,Sliver 是【SliverChildBuilderDelegate】,需传入自定义的 gridDelegate 参数。
GridView.builder()

// 既需要自定义 gridDelegate 参数,也需要自定义 childrenDelegate 参数。
GridView.custom()

GridView 也是继承 BoxScrollView ,直接搜 buildChildLayout() :

3.4.3. SliverGrid - 网格布局

用于在 二维网格中放置多个 box 子组件 的 Sliver 组件,专门为滚动视图设计的 网格布局组件。

RenderSliverGrid 的核心源码:

dart 复制代码
class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
  @override
  void performLayout() {
    // 🎯【准备阶段】获取滚动约束条件
    final SliverConstraints constraints = this.constraints;
    childManager.didStartLayout();
    childManager.setDidUnderflow(false);
  
    final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
    final double remainingExtent = constraints.remainingCacheExtent;
    final double targetEndScrollOffset = scrollOffset + remainingExtent;

    // 🎯【计算可见范围】获取布局策略、计算第一个/最后一个可见子项索引
    final SliverGridLayout layout = _gridDelegate.getLayout(constraints);
    final int firstIndex = layout.getMinChildIndexForScrollOffset(scrollOffset);
    final int? targetLastIndex = targetEndScrollOffset.isFinite ?
    layout.getMaxChildIndexForScrollOffset(targetEndScrollOffset) : null;

    // 🎯【垃圾回收】移除不在可见范围内的子组件,释放内存
    if (firstChild != null) {
      final int leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex);
      final int trailingGarbage = targetLastIndex != null ? calculateTrailingGarbage(lastIndex: targetLastIndex) : 0;
      collectGarbage(leadingGarbage, trailingGarbage);
    } else {
      collectGarbage(0, 0);
    }

    // 🎯【布局子组件】每个子组件都通过 SliverGridGeometry 获得精确的位置和尺寸
    // 双向构建-既向前也向后添加子组件,懒加载-只构建可见区域的子组件
    
    // 处理第一个子组件
    final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
    
    // 向前添加子组件
    for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
      final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index);
      final RenderBox child = insertAndLayoutLeadingChild(
        gridGeometry.getBoxConstraints(constraints),
      )!;
      final SliverGridParentData childParentData = child.parentData! as SliverGridParentData;
      childParentData.layoutOffset = gridGeometry.scrollOffset;
      childParentData.crossAxisOffset = gridGeometry.crossAxisOffset;
    }
    
    // 向后添加子组件
    for (int index = indexOf(trailingChildWithLayout!) + 1; targetLastIndex == null || index <= targetLastIndex; ++index) {
      // ... 类似的布局逻辑
    }

    // 🎯【计算几何信息】估算总滚动范围、计算绘制区域、计算缓存区域
    final double estimatedTotalExtent = reachedEnd
      ? trailingScrollOffset
      : childManager.estimateMaxScrollOffset(
          constraints,
          firstIndex: firstIndex,
          lastIndex: lastIndex,
          leadingScrollOffset: leadingScrollOffset,
          trailingScrollOffset: trailingScrollOffset,
        );
    final double paintExtent = calculatePaintOffset(
      constraints,
      from: math.min(constraints.scrollOffset, leadingScrollOffset),
      to: trailingScrollOffset,
    );
    geometry = SliverGeometry(
      scrollExtent: estimatedTotalExtent,
      paintExtent: paintExtent,
      maxPaintExtent: estimatedTotalExtent,
      cacheExtent: cacheExtent,
      hasVisualOverflow: estimatedTotalExtent > paintExtent || constraints.scrollOffset > 0.0 || constraints.overlap != 0.0,
    );
  }
}

代码有点多,梳理下执行链条:

dart 复制代码
RenderSliverGrid.performLayout()  // 开始网格布局
  ↓
childManager.didStartLayout()  // 初始化子组件管理器
  ↓
_gridDelegate.getLayout(constraints)  // 获取网格布局策略
  ↓
layout.getMinChildIndexForScrollOffset(scrollOffset)  // 计算第一个可见子项索引
  ↓
layout.getMaxChildIndexForScrollOffset(targetEndScrollOffset)  // 计算最后一个可见子项索引
  ↓
collectGarbage(leadingGarbage, trailingGarbage)  // 回收不可见的子组件
  ↓
layout.getGeometryForChildIndex(firstIndex)  // 获取第一个子项的几何信息
  ↓
for (index >= firstIndex; --index)  // 【循环1】向前布局缺失的子组件
  → insertAndLayoutLeadingChild()
  → 设置 layoutOffset 和 crossAxisOffset
  ↓
for (index <= targetLastIndex; ++index)  // 【循环2】向后布局新的子组件
  → insertAndLayoutChild()
  → 设置 layoutOffset 和 crossAxisOffset
  ↓
childManager.estimateMaxScrollOffset()  // 估算总滚动范围
  ↓
calculatePaintOffset() / calculateCacheOffset()  // 计算绘制和缓存区域
  ↓
geometry = SliverGeometry(...)  // 创建最终几何信息
  ↓
childManager.didFinishLayout()  // 完成布局,清理资源

😄 套下数据写个简单的例子 (垂直方向、一行4列,子元素高度为50,Viewport 为180,有24个子项):

dart 复制代码
// ① 计算可见范围
scrollOffset = 0 (初始位置)
targetEndScrollOffset = 0 + 180 = 180
layout.getMinChildIndexForScrollOffset(0) = 0     // 第一个可见项:索引0
layout.getMaxChildIndexForScrollOffset(180) = 15  // 最后一个可见项:索引15
// 计算详解
mainAxisCount = (180 / 50).ceil() = 3.6.ceil() = 4 行
maxChildIndex = max(0, 4 * 4 - 1) = 15

// ② 分析子项分布 (24个):
行1 (y=0-50):    [0] [1] [2] [3]     
行2 (y=50-100):  [4] [5] [6] [7]     
行3 (y=100-150): [8] [9] [10][11]    
行4 (y=150-200): [12][13][14][15]    
行5 (y=200-250): [16][17][18][19]    ← 不可见
行6 (y=250-300): [20][21][22][23]    ← 不可见

实际需要布局的子项: 索引 0-15 (只有前16个)
不会创建的子项: 索引 16-23 (后8个)

// ③ 布局执行
collectGarbage(): 清理超出范围的子项

第一个循环 (向前布局):
无需执行 (从索引0开始)

第二个循环 (向后布局):
for (index = 1; index <= 15; ++index) {  // 注意:只到15,不到23
  insertAndLayoutChild() // 只创建索引1到15的子项
  设置 layoutOffset 和 crossAxisOffset
}

索引16-23的子项: 完全不会被创建!

// ④ 最终几何信息
estimatedTotalExtent = childManager.estimateMaxScrollOffset()
// 会根据已知的16个子项来估算24个子项的总高度
// 估算结果: 6行 × 50px = 300px

paintExtent = 180px (viewport高度)
scrollExtent = 300px (估算的总滚动范围)

// 渲染结果
┌─────────────────────────┐ ← viewport top (y=0)
│ [0]  [1]  [2]  [3]     │ ← 行1: 已创建,完全可见
│ [4]  [5]  [6]  [7]     │ ← 行2: 已创建,完全可见
│ [8]  [9]  [10] [11]    │ ← 行3: 已创建,完全可见
│ [12] [13] [14] [15]    │ ← 行4: 已创建,部分可见
└─────────────────────────┘ ← viewport bottom (y=180)
未显示区域:
│ [16] [17] [18] [19]    │ ← 行5: 未创建,不可见
│ [20] [21] [22] [23]    │ ← 行6: 未创建,不可见

3.5. PageView

简介

可滚动的列表,特殊之处在于它的每个子组件 (称为"页面 ") 在滚动时都会强制占据整个视口 (Viewport)。常用于引导页、轮播图。TabBarView 也是基于它实现的,用来配置TabBar展示不同标签下的内容。

3.5.1. API 详解

继承 StatefulWidget,同样挑几个属性讲讲:

  • pageSnapping :bool,是否启用页面吸附,默认true,滚动停止时会自动吸附到最近的页面边界,设置 false,则可以停在任何位置。
  • padEnds :bool ,是否在列表的两端添加填充,默认true,会在第一页和最后一页添加额外的填充空间。使得第一页和最后一页能够居中显示在视口中。这个参数只有 viewportFraction < 1.0 时才生效。
  • onPageChanged :ValueChanged? ,当一个新页面完全显示时 (pageSnapping完成后) 调用,可以获取新页面的索引。
  • childrenDelegate :PageView 内部使用它来生成子组件,不同构造方法最终会创建不同类型的 SliverChildDelegate。

提供了 三种构造方式:

dart 复制代码
// ✨ ① PageView() - 默认构造函数,适合页面数量较少且固定的情况
PageView(
  children: <Widget>[
    _buildPage(1, Colors.pink),
    _buildPage(2, Colors.cyan),
    _buildPage(3, Colors.deepPurple),
  ],
)

// ✨ ② PageView.builder() - 构造器构造函数,最常用,懒加载,适合页面数量多或不确定的情况。
// 假设有100个页面
const int pageCount = 100;
PageView.builder(
  itemCount: pageCount,
  itemBuilder: (BuildContext context, int index) {
    // itemBuilder 会在页面即将进入视口时被调用
    // 这意味着只有当用户滑动到某个页面时,它才会被构建
  }
)

// ✨ ③ PageView.custom() - 自定义构造函数,对子项的构建、布局和回收逻辑进行高度自定义时。
PageView.custom(
  // childrenDelegate 是核心,它决定了如何提供子页面
  childrenDelegate: SliverChildBuilderDelegate(
    (BuildContext context, int index) {
      // 如:奇数页和偶数页显示不同的内容
      if (index.isEven) {
        return Container(
          color: Colors.green,
          child: Center(
            child: Text(
              'Even Page ${index + 1}\n(From PageView.custom())',
              textAlign: TextAlign.center,
              style: const TextStyle(fontSize: 22, color: Colors.white),
            ),
          ),
        );
      } else {
        return Container(
          color: Colors.orange,
          child: Center(
            child: Text(
              'Odd Page ${index + 1}\n(From PageView.custom())',
              textAlign: TextAlign.center,
              style: const TextStyle(fontSize: 22, color: Colors.white),
            ),
          ),
        );
      }
    },
    // 同样可以指定子项数量,也可以为 null 表示无限列表
    childCount: 20, 
  ),
);

页面的切换控制,用到的 → PageController ,它继承自 ScrollController ,在原有像素级别的基础上,新增了 "页面" 级别的滚动控制,构造方法 & 类成员:

dart 复制代码
PageController({
  this.initialPage = 0,           // 初始页面索引
  this.keepPage = true,           // 是否保存页面状态
  this.viewportFraction = 1.0,    // 视口占比,< 1.0 每页只占部分视口,可以看到相邻页面的一部分
                                  // > 1.0: 每页超出视口,会有padding效果
  super.onAttach,                 // 附加回调
  super.onDetach,                 // 分离回调
}) : assert(viewportFraction > 0.0);

double? get page	// 当前页面的精确位置,可能包含小数部分,如 1.5 表示在第1页和第2页之间
                  // 必须在 PageView 构建完成后才能访问

// 核心方法
  
// 动画地切换到指定页面
animateToPage(int page, {required Duration duration, required Curve curve})
  
// 无动画地直接跳转到指定页面
jumpToPage(int page)

// 动画地切换到下一页
nextPage({required Duration duration, required Curve curve})

// 动画地切换到上一页
previousPage({required Duration duration, required Curve curve})

简单使用示例【--->c35/pageview_demo.dart<---】运行效果:

💡 Tips :滑动几页后,切换Tab,再切回来,发现会从第一页开始,即重新创建。如果想 保存页面状态 ,子页面需混入 AutomaticKeepAliveClientMixin 并重写 wantKeepAlive 返回 true ❗️

3.5.2. 源码剖析

😄 没啥新意,默认构造是 SliverChildListDelegate ,builder() 则是 SliverChildBuilderDelegate ,前者一次性构建所有的子Widget,后者动态懒加载。PageView 继承 StatefulWidget ,直接看 _PageViewState.build() :

SliverFillViewport 继承 StatelessWidget ,直接看 build() :

  • _SliverFractionalPadding:负责根据视口大小动态计算并添加内边距,让SliverFillViewport的首尾子项能够居中显示。
  • _SliverFillViewportRenderObjectWidget :Widget 和 RenderObject间的桥梁,负责创建和管理RenderSliverFillViewport 渲染对象。

RenderSliverFillViewport 的父类 RenderSliverFixedExtentBoxAdaptor 实现了 performLayout() ,关键代码:

dart 复制代码
abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
  @override
  void performLayout() {
    // ① 初始化阶段
    
    // 获取约束信息
    final SliverConstraints constraints = this.constraints;
    childManager.didStartLayout();
    
    // 计算关键偏移量
    final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
    final double targetEndScrollOffset = scrollOffset + remainingExtent;

    // ② 索引计算阶段
    
    // 获取约束信息
    final SliverConstraints constraints = this.constraints;
    childManager.didStartLayout();
    
    // 计算关键偏移量
    final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
    final double targetEndScrollOffset = scrollOffset + remainingExtent;

    // ③ 垃圾回收阶段
    // 计算需要布局的子组件索引范围
    final int firstIndex = getMinChildIndexForScrollOffset(scrollOffset, -1);
    final int? targetLastIndex = getMaxChildIndexForScrollOffset(targetEndScrollOffset, -1);

    // ④ 子组件布局阶段
    // 向前布局:清理不再需要的子组件
    final int leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex);
    final int trailingGarbage = calculateTrailingGarbage(lastIndex: targetLastIndex);
    collectGarbage(leadingGarbage, trailingGarbage);
    // 向后布局:从 firstChild 向前插入和布局子组件
    for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
      final RenderBox? child = insertAndLayoutLeadingChild(_getChildConstraints(index));
      childParentData.layoutOffset = indexToLayoutOffset(-1, index);
    }

    // ⑤ 几何信息结算阶段:从 trailingChildWithLayout 向后插入和布局子组件
    for (int index = indexOf(trailingChildWithLayout!) + 1; index <= targetLastIndex; ++index) {
      RenderBox? child = insertAndLayoutChild(_getChildConstraints(index), after: trailingChildWithLayout);
      childParentData.layoutOffset = indexToLayoutOffset(-1, index);
    }

    // ⑥ 完成阶段
    // 计算各种范围信息
    final double paintExtent = calculatePaintOffset(constraints, from: leadingScrollOffset, to: trailingScrollOffset);
    final double cacheExtent = calculateCacheOffset(constraints, from: leadingScrollOffset, to: trailingScrollOffset);
    
    // 设置最终的几何信息
    geometry = SliverGeometry(
      scrollExtent: estimatedMaxScrollOffset,
      paintExtent: paintExtent,
      cacheExtent: cacheExtent,
      maxPaintExtent: estimatedMaxScrollOffset,
      hasVisualOverflow: ...,
    );
  }
}

4. 高级组件 & 自定义滚动

Sliver 是 Flutter 为了解决 大量内容滚动时的性能问题 而设计的 视口驱动渲染机制 ,它 只构建和渲染用户当前能看到的部分 ,相比 普通Widget 会一次性构建所有内容导致内存爆炸和性能问题要高效得多。

4.1. CustomScrollView

用于 构建自定义、复杂的滚动视图 ,它本身不直接决定其子项的布局,而是创建了一个可以容纳 Sliver 系列组件的滚动视口 (Viewport)。继承自 ScrollView ,只是重写了 buildSlivers() ,返回构造参数传入的 slivers - Sliver组件列表。

4.2. SliverPersistentHeader

Persistent -"持久化 ",该组件可以 根据滚动位置改变自身大小 ,并且可以选择性地 "钉 " 在视口(Viewport) 顶部 的 Sliver 。当它滚动到屏幕边缘时,它不会像普通列表项那样完全滚出屏幕,而是可以收缩到一个最小高度并"固定在那里",直到被下一个 SliverPersistentHeader 或者滚动会顶部所代替。😄 其实就很常说的"吸顶"组件。

dart 复制代码
class SliverPersistentHeader extends StatelessWidget {
  const SliverPersistentHeader({
    Key? key,
    required this.delegate,	 // 头部布局的委托对象,包括:最大/最小高度和构建逻辑
    this.pinned = false,	// 是否固定在视口顶部 (滚动也不消失)
    this.floating = false,	// 是否有浮动效果 (用户反向滚动会立即重新出现)
  });
}

SliverPersistentHeaderDelegate 是一个抽象类,需继承并实现下述方法:

dart 复制代码
@override
double get minExtent => 60.0; // 最小高度

@override
double get maxExtent => 200.0; // 最大高度

// 返回头部的 UI 组件,参数:
// 「shrinkOffset」-头部从最大高度 maxExtent 收缩的距离,可能范围[0.0, maxExtent-minExtent]
// 当 shrinkOffset 为 0 时,头部处于完全展开状态,当达到最大值时,头部处于完全收缩状态
// 可以利用这个值来实现各种动画效果,如 (透明度、位移、大小锁房)
//
//「overlapsContent」该 Header 当前是否与滚动视图中的主要内容重写。
// 通常在 pinned 为 true 时,Header 下方的内容开始滚动到其后面时,此值为 true。
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
  return Container(/* 你的头部内容 */);
}

@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
  // 返回是否需要重建
  return true;
}

写个简单使用示例【--->c35/sliver_persistent_header_demo.dart<---】运行效果:

😶 简单传几个参数就实现了吸顶和浮动效果,跟下源码,看下是怎么做到的,build() 根据不同的值情况返回不同的 渲染对象 ,跟下对应的 performLayout() 的核心代码:

从下往上看,先是 _SliverScrollingPersistentHeader → RenderSliverScrollingPersistentHeader:

接着是 _SliverFloatingPersistentHeader → RenderSliverFloatingPersistentHeader:

再接着 _SliverPinnedPersistentHeader → RenderSliverPinnedPersistentHeader:

最后是 _SliverFloatingPinnedPersistentHeader → RenderSliverFloatingPinnedPersistentHeader ,浮动效果来自于父类 RenderSliverFloatingPersistentHeader ,吸顶效果则是重写 updateGeometry() 实现:

4.3. SliverAppBar

基于 SliverPersistentHeader 实现的具体 应用栏组件 (内置应用的常见功能,如标题、操作按钮、背景等),开箱即用,相比起手写一坨 _SliverHeaderDelegate ,直接配置几个属性即可轻松实现相同的效果:floating 、pinned 就不用说了,还有这些:

  • snap:是否启用快速收缩/展开动画。
  • expandedHeight:完全展开时的高度,通常和 flexibleSpace 来显示背景内容,null 时使用默认工具栏高度。
  • collapsedHeight:收缩时的最小高度,不写默认为 toolbarHeight + bottom?.preferredSize.height。
  • stretch :bool,是否启用拉伸效果,默认false, 禁用拉伸效果,应用栏保持固定大小。设为true,当用户向下过度滚动时,应用栏会被拉伸放大。开启后的视觉效果:下拉时应用栏的 flexibleSpace 区域会被拉伸,拉伸后背景内容会按比例放大。
  • stretchTriggerOffset :double,设置触发 onStretchTrigger 回调 (拉伸回弹) 需要的过度滚动距离,默认100像素,计算方式: 从应用栏的自然位置开始计算向下的拖拽距离。
  • onStretchTrigger :Future ,拉伸触发的异步回调,用户拖拽超过阈值且松手时 (拉伸距离达到stretchTriggerOffset),注意,只在 stretch = true 时有效。

写个简单使用示例【--->c35/sliver_app_bar_demo.dart<---】运行效果:

跟下源码,内部使用 _SliverAppBarDelegate 来实现 SliverPersistentHeaderDelegate:

4.4. NestedScrollView

当你在一个可滚动组件中放入一个"同方向 "的可滚动组件,通常会遇到两大类问题:"手势冲突 " & "布局约束问题 "。比如最经典的例子:两个垂直滚动的ListView嵌套,会怎么样?

先是会报错:xxx has an unbounded height,尝试添加 固定高度 约束后。只有内部的列表在滑动,当它滚动尽头时,滚动事件并不会自动传递给外部的ListView。(🐶一种不太好的解法,内部ListView设置:shrinkWrap:true + physics: NeverScrollableScrollPhysics() ,即收缩+禁止滚动,仅适用于列表项少且固定的情况)。

而 NestedScrollView 就是为了解决这两个问题而设计出来的:

  • 通过 滚动协调器 ( _NestedScrollCoordinator) 统一管理和分配滚动事件,消除竞争.
  • 为 body 提供了有界约束,使其内部的可滚动组件可以正常布局。

4.4.1. API 详解

构造方法:

dart 复制代码
class NestedScrollView extends StatefulWidget {
  const NestedScrollView({
    super.key, // Widget的唯一标识符,用于性能优化和状态保持
    this.controller, // 外层滚动控制器,用于程序化控制滚动位置和监听滚动事件
    this.scrollDirection = Axis.vertical, // 滚动方向,默认垂直滚动(仅影响外层滚动)
    this.reverse = false, // 是否反向滚动,true时从底部开始滚动(仅影响外层滚动)
    this.physics, // 滚动物理效果,控制弹性、边界行为等滚动特性(仅影响外层滚动)
    required this.headerSliverBuilder, // 头部构建器,返回头部Sliver组件列表(如SliverAppBar)
    required this.body, // 主体内容Widget,通常是TabBarView或其他滚动组件
    this.dragStartBehavior = DragStartBehavior.start, // 拖拽开始行为,控制手势识别的起始时机
    this.floatHeaderSlivers = false, // 是否优先浮动头部Sliver,true时向下滚动优先展开头部
    this.clipBehavior = Clip.hardEdge, // 内容裁剪行为,控制超出边界内容的显示方式
    this.restorationId, // 状态恢复ID,用于应用重启时恢复滚动位置
    this.scrollBehavior, // 滚动行为配置,定义滚动样式、物理效果等平台相关行为
  });

核心属性:

  • headerSliverBuilder :NestedScrollViewHeaderSliversBuilder → List Function(BuildContext context, bool innerBoxIsScrolled),用于构建 头部 的 Sliver 组件列表。第二个参数的值代表"嵌套的滑动内容是否已经达到顶部,开始滑动"。
  • body :Widget ,主体部分,通常是一个包含可滚动内容的组件,最常见的是 TabBarView ,其每个子页面都是一个 ListView 或 CustomScrollView,这部分内容构成了 "内部滚动视图"。
  • controller :ScrollController ,控制外层滚动,内层滚动由 PrimaryScrollController 自动管理。
  • floatHeaderSlivers:是否优先浮动头部Slivers,默认 false,设为 true,向下滚动会优先展开头部。
  • 注:scrollDirection、reverse、physics 只影响外层滚动视图,内层滚动视图需要在body中单独配置。

最简单的使用代码示例 (注意headerSliverBuilder列表的元素必须为Sliver组件,如:SliverList):

dart 复制代码
 NestedScrollView(
  // 头部
  headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
    return <Widget>[
      SliverToBoxAdapter(
        child: Container(height: 200, color: Colors.orange),
      ),
    ];
  },
  // 主体
  body: Container(
    color: Colors.orange.shade50,
    child: ListView.builder(
      padding: const EdgeInsets.all(16),
      itemCount: 30,
      itemBuilder: (context, index) => Card(
        margin: const EdgeInsets.only(bottom: 8),
        child: ListTile(
          leading: CircleAvatar(
            backgroundColor: Colors.orange,
            child: Text('${index + 1}'),
          ),
          title: const Text('这是主体内容 (body)'),
          subtitle: Text('列表项 ${index + 1}'),
        ),
      ),
    ),
  ),
),

运行效果:

尝试 SliverAppBar:

dart 复制代码
 headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
  return <Widget>[
    // 🎨 可折叠的 SliverAppBar
    SliverAppBar(
      title: const Text('📱 SliverAppBar 示例'),
      backgroundColor: Colors.purple,
      foregroundColor: Colors.white,
      floating: true,
      pinned: true,
      snap: true,
      expandedHeight: 200.0,
      // 🎭 弹性空间 - 展开时显示的内容
      flexibleSpace: FlexibleSpaceBar(
        title: const Text('可折叠标题'),
        background: Container(
          color: Colors.purple,
          child: Center(
            child: Icon(Icons.star, size: 80, color: Colors.white.withOpacity(0.3)),
          ),
        ),
      ),
      // 根据内容滚动状态决定是否显示阴影
      forceElevated: innerBoxIsScrolled,
    ),
  ];
},

运行效果:

在 NestedScrollView 中,使用 floating、pinned、snap 等属性的 SliverAppBar 时,会产生 "重叠" 问题:

AppBar 在展开/收起过程中可能遮挡内容、内层滚动视图的内容可能显示在 AppBar 下方、滚动对齐不正确。

为了避免 body 的初始内容被 Header 遮挡,你需要使用这两对组合:

  • SliverOverlapInjector :将它作为 headerSliverBuilder 返回的 Sliver 列表中的一个父组件,包裹住其他 Sliver。它会捕获其子 Sliver (如 SliverAppBar) 所占据的重叠量。
  • SliverOverlapInjector :在 body 内部的可滚动视图,将它作为第一个Sliver,它会将SliverOverlapAbsorber 捕获到的重叠量作为内边距,应用到其内部,从而将 body内容往下挪,避免被遮挡。

用法示例 (注:两者必须使用同一个 SliverOverlapAbsorberHandle):

dart 复制代码
// 在 headerSliverBuilder 中,套Sliver组件
SliverOverlapAbsorber(
  handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
  sliver: SliverAppBar(...), // 包装会产生重叠的组件
)

// 在 body 的每个滚动视图中,在 TabBarView 的每个 Tab 中都要使用 SliverOverlapInjector
SliverOverlapInjector(
  handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
)

另外,你可能发现滑动后,且Tab到别的页面,回来后,第一页的 滑动距离没有保持 ,给 CustomScrollView 的 key 属性设置一个 PageStorageKey 的对象 (唯一标识) 即可解决。原理是 ScrollPosition 类中通过 PageStorage 组件进行滑动进度的读写。

💡 Tips: 在 body 的 TabBarView 中为每个 ListView 都提供了自己的 ScrollController,会导致NestedScrollView 的协调机制 失效!

4.4.2. 源码剖析

核心大脑 _NestedScrollCoordinator (协调器):

dart 复制代码
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { ... }

它管理两个独立的 ScrollController: _outerController 和 _innerController:

协调内外滚动位置的同步、处理手势分发和滚动事件。滚动分发的核心算法 (用户开始拖拽-drag() → 拖拽更新-applyUserOffset() ):

  • 向上拖拽 (delta < 0):优先消除内层 overscroll → 然后滚动外层 → 最后滚动内层。
  • 向下拖拽 (delta > 0):根据 floatHeaderSlivers 决定是否优先滚动外层。

然后是 惯性滚动处理,当手指离开屏幕:

然后是关键的 _getMetrics() ,它创建统一的滚动指标,让内外两个独立的滚动视图在物理模拟时表现得像一个整体。最后是为了解决 SliverAppBar 重叠问题的 SliverOverlapAbsorber/Injector 机制:

在 RenderSliverOverlapInjector.performLayout() 中:

画个调用时序图帮助理解:

用户手势 → NestedScrollCoordinator → 智能分析 → 分发给合适的滚动组件

4.5. Notification通知机制

ScrollNotification 是一个抽象类,用于 在滚动相关事件发生时发出通知,它是整个滚动通知系统的核心基类。

scala 复制代码
abstract class ScrollNotification extends LayoutChangedNotification with ViewportNotificationMixin {
  ScrollNotification({
    required this.metrics,
    required this.context,
  });
  
  final ScrollMetrics metrics;  // 滚动指标信息
  final BuildContext? context;  // 触发通知的组件上下文
}

核心属性:

  • metrics:ScrollMetrics,描述滚动视图内容的详细信息,包含:pixels(当前位置)、minScrollExtent(最小滚动范围)、maxScrollExtent(最大滚动范围)、viewportDimension(视口尺寸)等。
  • context:触发此通知的 Widget 的构建上下文,可用于查找滚动组件的渲染对象,确定视口大小等。
  • depth:继承自 ViewportNotificationMixin,表示通知冒泡经过的视口数量,通常监听器只响应 depth 为 0 的通知 (本地通知)。

它的几个字类:

dart 复制代码
// 开始滚动
class ScrollStartNotification extends ScrollNotification {
  final DragStartDetails? dragDetails; // 拖拽开始详情
}

// 滚动更新
class ScrollUpdateNotification extends ScrollNotification {
  final DragUpdateDetails? dragDetails; // 拖拽更新详情
  final double? scrollDelta; // 滚动距离增量
}

// 过渡滚动
class OverscrollNotification extends ScrollNotification {
  final double overscroll; // 过度滚动的像素数
  final double velocity;   // 滚动速度
}

// 滚动结束
class ScrollEndNotification extends ScrollNotification {
  final DragEndDetails? dragDetails; // 拖拽结束详情
}

// 用户滚动方向改变
class UserScrollNotification extends ScrollNotification {
  final ScrollDirection direction; // 滚动方向
}

// 用户停止交互
class UserScrollNotification extends ScrollNotification {
  final ScrollDirection direction;	// 处于 idle
}

使用示例:

dart 复制代码
// 使用 NotificationListener<ScrollNotification> 包裹滑动视图进行监听
NotificationListener<ScrollNotification>(
  onNotification: (ScrollNotification notification) {
    if (notification is ScrollUpdateNotification) {
      print('滚动位置: ${notification.metrics.pixels}');
    }
    return false; // 不消费通知,继续向上传播
  },
  child: ListView(...),
)

相比 ScrollController ,ScrollNotification 的优势在于 "解耦 " 和 "事件驱动",具体表现在:

  • 任何父组件都可以通过 NotificationListener 监听到其子孙树中任何滚动组件的事件,无需获取该滚动组件的 ScrollController,这使得父组件与子滚动组件间没有强依赖关系,代码更清晰、更易维护。
  • NotificationListener 提供了丰富且详细的事件类型,让你能精确地知道滚动的具体阶段。而ScrollController.addListener() 只有一个通用的"滚动变动"通知,你无法直接区分是用户拖动、惯性滑动还是代码驱动的滚动。
  • ScrollNotification 是一种标准的、自下而上 的"事件冒泡机制 ",而 ScrollController 则主要用于自上而下的 控制,监听只是其附加功能。

😁 ScrollNotification 的事件来源可以分为两种:

dart 复制代码
// 用户手势交互

1. 用户拖拽屏幕 -> GestureDetector 识别
2. 手势生成 DragStartDetails, DragUpdateDetails, DragEndDetails
3. 这些手势信息传递给 ScrollActivity

// ② 程序化滚动

// ScrollController 主动调用
controller.animateTo(100.0); // 程序控制滚动
controller.jumpTo(200.0);    // 立即跳转

然后 Scrollable 是所有滚动组件的基础,其内部事件生成流程:

  • 手势识别 : RawGestureDetector 识别用户手势
  • 活动创建 : 根据手势类型创建不同的 ScrollActivity (DragScrollActivity -用户拖拽、DrivenScrollActivity -程序滚动、BallisticScrollActivity-惯性滚动)。
  • 位置更新: ScrollPosition 根据活动更新滚动位置。
  • 通知分发 : ScrollPosition 创建并分发 ScrollNotification。

ScrollPosition 是事件创建的核心,关键源码:

dart 复制代码
abstract class ScrollPosition {

  /// 开始滚动时调用
  void didStartScroll() {
    // 实际上是通过 activity 来分发通知的!
    activity!.dispatchScrollStartNotification(copyWith(), context.notificationContext);
  }

  /// 滚动更新时调用
  void didUpdateScrollPositionBy(double delta) {
    // 通过 activity 分发滚动更新通知
    activity!.dispatchScrollUpdateNotification(copyWith(), context.notificationContext!, delta);
  }

  /// 滚动结束时调用
  void didEndScroll() {
    // 通过 activity 分发滚动结束通知,并保存偏移量
    activity!.dispatchScrollEndNotification(copyWith(), context.notificationContext!);
    saveOffset();
    if (keepScrollOffset) {
      saveScrollOffset();
    }
  }

  /// 过度滚动时调用
  void didOverscrollBy(double value) {
    assert(activity!.isScrolling);
    // 通过 activity 分发过度滚动通知
    activity!.dispatchOverscrollNotification(copyWith(), context.notificationContext!, value);
  }

  /// 用户滚动方向改变时调用
  void didUpdateScrollDirection(ScrollDirection direction) {
    // 直接创建 UserScrollNotification 并分发
    UserScrollNotification(
      metrics: copyWith(), 
      context: context.notificationContext!, 
      direction: direction
    ).dispatch(context.notificationContext);
  }

  /// 滚动指标更新时调用
  void didUpdateScrollMetrics() {
    assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
    assert(_haveScheduledUpdateNotification);
    _haveScheduledUpdateNotification = false;
    if (context.notificationContext != null) {
      // 分发滚动指标变化通知
      ScrollMetricsNotification(
        metrics: copyWith(), 
        context: context.notificationContext!
      ).dispatch(context.notificationContext);
    }
  }
}

接着是 事件传播的链路:

dart 复制代码
ScrollPosition.didUpdateScrollPositionBy()
   ↓
ScrollPosition._dispatch(ScrollUpdateNotification(...))
   ↓
BuildContext.dispatchNotification(notification)
   ↓
Element._notificationTree.dispatch(notification)
   ↓
向上遍历 Element 树
   ↓
查找 NotificationListener<ScrollNotification>
   ↓
执行 onNotification 回调
   ↓
根据返回值决定是否继续传播
相关推荐
蒿豪郝灬号26 分钟前
UniApp接入鸿蒙版阿里百川
华为·uni-app·harmonyos
大龄秃头程序员42 分钟前
【从一道面试题入手】深入理解 Dart 事件循环调度机制
flutter
mmsx1 小时前
Android 流式对话实现:分层门面与 SSE 手工解析
android·github·sse
墨狂之逸才1 小时前
Android扫码异常排查小白教程
android
恋猫de小郭1 小时前
Kotlin 2.5 的 Companion 改造,对 KMP 的重要升级
android·前端·flutter
Kapaseker2 小时前
写了这么久 Kotlin,你真的理解 in、out 和 * 吗?
android·kotlin
小七在进步3 小时前
C++内存管理:new/delete vs malloc/free深度解析
android·java·c++
云边有个稻草人3 小时前
鸿蒙远控电脑,ToDesk和向日葵各用一周,三个细节差距比想象中大
harmonyos·todesk
geovindu16 小时前
sql:Search-Optimized SQL Queries using mysql 9.0
android·数据库·sql·mysql·数据库开发·数据库架构
程序员老刘17 小时前
Flutter你看看人家RN
flutter·react native·ai编程