跟🤡杰哥一起学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,             // 裁剪行为
  })
}

参数详解:

axisDirectionAxisDirection

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

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

physicsScrollPhysics?

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

incrementCalculatorScrollIncrementCalculator?

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

dragStartBehaviorDragStartBehavior

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

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

clipBehaviorClip

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

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

restorationIdString?

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

viewportBuilderViewportBuilder

这是 ViewportBuilder 的定义代码:

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

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

controllerScrollController?

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

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
  • onAttachScrollControllerCallback? ,当一个 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 ,它比 ScrollControlleraddListener 提供了更丰富、更具体的事件信息,如滚动停止。另外,上拉加载更多 (滑动到底部,用户还往上拉),常见的错误做法:在 addListener 里判断 position.pixels == position.maxScrollExtent ,这只在到达底部时触发一次,而不是在到达底部后继续拉动时触发。正确的做法是:监听 OverscrollNotification ,当用户试图滚动超过 maxScrollExtent 时,就会触发这个通知。

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

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

参数详解:

  • contextScrollContext ,充当 ScrollableScrollPosition 间的桥梁,为滚动操作提供必要的 上下文信息 。包括:分发ScrollNotification -notificationContext、搜索PageStorage -storageContext、动画支持 -vsync、滚动方向 -axisDirection、设备像素比 -devicePixelRatio。除此之外,还提供了交互控制相关的方法:是否忽略指针事件 -setIgnorePointer()、是否可以拖拽 -setCanDrag()、setPixels (double offset)-当 ScrollPosition 内部的 pixels 值改变时,用它来通知 Viewport 更新其偏移量,从而真正移动视图。
  • oldPositionScrollPosition? ,当 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

RenderSliverperformLayout() 被调用后,它必须计算并设置自己的 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 → SliverGridSliverToBoxAdapter (用于将一个普通Box组件进行Sliver适配)、SliverAppBarSliverPersistentHeader (吸顶头部)、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 这个范围内的列表项。
  • cacheExtentStyleCacheExtentStyle ,缓存单位,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 那里已经详细讲了,不再赘述,挑几个没讲到的:

  • paddingEdgeInsetsGeometry? ,在滚动区域内部添加内边距 (child外边),边距会随着内容一起滚动。
  • keyboardDismissBehaviorScrollViewKeyboardDismissBehavior ,用户与滚动区域交互时,如何以及何时自动收起弹出的键盘。【manual -默认】滚动视图本身不会做任何事情来收起键盘,键盘的收起完全依赖于其他方式,如:回退键、FocusScope.of(context).unfocus() 等。【onDrag 】当键盘弹出时,用户在滚动视图上开始拖动 (滚动) 的那一刻,键盘就会自动收起 ✨。
  • primarybool? ,是否使用主滚动控制器,默认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全部渲染到内存中 ",跟下代码调用:SingleChildScrollViewbuild()

SingleChildScrollViewViewport 具体实现 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,同样挑几个属性讲讲:

  • shrinkWrapbool,默认false,尽可能占据父组件在滚动方向上提供的所有空间,如果父组件没限制 (如Column),就会导致无限高度/宽度错误。为true时,尺寸会收缩以包裹其内容的总高度/宽度,但这样会牺牲性能,因为它需要计算所有子项的尺寸 (即便不可见) 来确定自身的总尺寸。故仅在必要时使用,如:在Column 中嵌套一个 ListView 时。
  • itemExtentdouble?子项固定固定高度,如果所有子项都有相同的高度/宽度,设置此属性能极大地提高性能。因为 Listview不再需要动态计算每个子项的尺寸,可以直接算出滚动偏移,从而简化布局过程。
  • itemExtentBuilderItemExtentBuilder?子项高度构建器 ,其实就一方法回调,有两个参数:index -当前子项索引 和 dimensions-当前滚动视口的尺寸信息,返回值double-子项高度。适用于:当你的列表项目高度是可预知的、有规律的,但又不完全相同的场景,如:奇数index,高度50,偶数index,高度100。
  • prototypeItemWidget? ,列表项高度/宽度基本一致但又不想写死 itemExtent,可以提供一个 原型Widget,ListView 会测量这个原型一次,然后假设所有其他项都具有相同的尺寸。
  • cacheExtentdouble?缓存范围,Viewport 的预加载区域大小 (默认250.0) 增加此值可以减少快速滚动时的空白,但因为会提前构建更多项,所以会增加内存消耗。
  • addAutomaticKeepAlivesbool ,默认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创建对应的 ElementRenderObject ,当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() 也是用的 SliverChildBuilderDelegatecustom() 则需要通过 childrenDelegate 参数传入自定义的 SliverChildDelegate 。继承关系:ListViewBoxScrollViewScrollViewBoxScrollViewScrollView 的基础上增加了 "自动填充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,同样挑几个属性讲讲:

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

😄 没啥新意,默认构造是 SliverChildListDelegatebuilder() 则是 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. 高级组件 & 自定义滚动

SliverFlutter 为了解决 大量内容滚动时的性能问题 而设计的 视口驱动渲染机制 ,它 只构建和渲染用户当前能看到的部分 ,相比 普通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() 的核心代码:

从下往上看,先是 _SliverScrollingPersistentHeaderRenderSliverScrollingPersistentHeader

接着是 _SliverFloatingPersistentHeaderRenderSliverFloatingPersistentHeader

再接着 _SliverPinnedPersistentHeaderRenderSliverPinnedPersistentHeader

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

4.3. SliverAppBar

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

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

核心属性

  • headerSliverBuilderNestedScrollViewHeaderSliversBuilder → List Function(BuildContext context, bool innerBoxIsScrolled),用于构建 头部Sliver 组件列表。第二个参数的值代表"嵌套的滑动内容是否已经达到顶部,开始滑动"。
  • bodyWidget ,主体部分,通常是一个包含可滚动内容的组件,最常见的是 TabBarView ,其每个子页面都是一个 ListView 或 CustomScrollView,这部分内容构成了 "内部滚动视图"。
  • controllerScrollController ,控制外层滚动,内层滚动由 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到别的页面,回来后,第一页的 滑动距离没有保持 ,给 CustomScrollViewkey 属性设置一个 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(...),
)

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

  • 任何父组件都可以通过 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 回调
   ↓
根据返回值决定是否继续传播
相关推荐
梦否2 小时前
Android 代码热度统计(概述)
android
Georgewu5 小时前
【HarmonyOS】元服务入门详解 (一)
harmonyos
ZhDan916 小时前
flutter知识点
flutter
xchenhao6 小时前
基于 Flutter 的开源文本 TTS 朗读器(支持 Windows/macOS/Android)
android·windows·flutter·macos·openai·tts·朗读器
消失的旧时光-19437 小时前
OkHttp SSE 完整总结(最终版)
android·okhttp·okhttp sse
ansondroider8 小时前
OpenCV 4.10.0 移植 - Android
android·人工智能·opencv
睿麒10 小时前
鸿蒙app 开发中的Record<string,string>的用法和含义
华为·harmonyos
hsx66611 小时前
Kotlin return@label到底怎么用
android
itgather12 小时前
安卓设备信息查看器 - 源码编译
android