flutter滚动视图之Scrollable源码解析(四)

139 阅读17分钟

ViewportBuilder

// 示例可以假设:
// late BuildContext context;

/// [Scrollable] 用来构建通过其显示可滚动内容的视口的签名。
typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);

  • Scrollable 是 Flutter 中用于处理滚动的控件。
  • 这个签名定义了如何通过传入的 BuildContextViewportOffset 来构建视口,该视口用于显示滚动内容。

TwoDimensionalViewportBuilder

/// 用于 [TwoDimensionalScrollable] 构建视口的函数签名,

/// 可滚动内容通过该视口进行显示。

typedef TwoDimensionalViewportBuilder = Widget Function(

BuildContext context,

ViewportOffset verticalPosition, // 垂直方向滚动位置控制

ViewportOffset horizontalPosition, // 水平方向滚动位置控制

);

_EnsureVisibleResults

// _performEnsureVisible 的返回类型。
//
// futures 列表表示每一个尚未完成的 ScrollPosition.ensureVisible 调用。
// 返回的 ScrollableState 的 context 被用来查找下一个可能的祖先 Scrollable。
typedef _EnsureVisibleResults = (List<Future<void>>, ScrollableState);

Scrollable

/// 一个在单一维度上管理滚动并通知 [Viewport] 来展示内容的组件。
///
/// [Scrollable] 实现了滚动组件的交互模型,包括手势识别,
/// 但它并不关心真正展示子组件的视口(viewport)是如何构建的。
///
/// 很少会直接构建一个 [Scrollable]。通常你会使用 [ListView] 或 [GridView],
/// 它们结合了滚动、视口以及布局模型。如果你想组合不同的布局模型
/// (或使用自定义的布局模式),可以考虑使用 [CustomScrollView]。
///
/// [Scrollable.of] 和 [Scrollable.ensureVisible] 这两个静态函数通常用于
/// 在 [ListView] 或 [GridView] 内与 [Scrollable] 小部件交互。
///
/// 想要进一步自定义 [Scrollable] 的滚动行为:
///
/// 1. 你可以提供一个 [viewportBuilder] 来定制子组件的模型。
///    例如,[SingleChildScrollView] 使用一个只显示单个 box 子组件的视口,
///    而 [CustomScrollView] 使用 [Viewport] 或 [ShrinkWrappingViewport],
///    它们都能展示一个 sliver 列表。
///
/// 2. 你可以提供一个自定义的 [ScrollController],并由它创建一个自定义的
///    [ScrollPosition] 子类。例如,[PageView] 使用 [PageController],
///    它会创建一个面向“页面”的滚动位置子类,
///    从而保证在 [Scrollable] 调整大小时,仍然保持同一页可见。
///
/// ## 在会话期间保持滚动位置
///
/// Scrollable 会尝试使用 [PageStorage] 来保存滚动位置。
/// 可以通过将 [controller] 的 [ScrollController.keepScrollOffset] 设置为 false 来禁用这一行为。
/// 如果启用,建议为此 widget(或其祖先,如 [ScrollView])提供一个 [PageStorageKey] 作为 [key],
/// 以帮助区分不同的 [Scrollable] 实例。
///
/// 参见:
///
///  * [ListView]:常用的 [ScrollView],显示一个线性滚动列表。
///  * [PageView]:显示一组与视口大小相同的子组件,可分页滚动。
///  * [GridView]:[ScrollView] 的一种,显示二维网格状的子组件。
///  * [CustomScrollView]:[ScrollView] 的一种,可以通过 slivers 实现自定义滚动效果。
///  * [SingleChildScrollView]:只有单个子组件的可滚动组件。
///  * [ScrollNotification] 和 [NotificationListener]:
///    不依赖 [ScrollController] 也能监听滚动位置。

class Scrollable extends StatefulWidget {
  /// Creates a widget that scrolls.
  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,
    this.scrollBehavior,
    this.clipBehavior = Clip.hardEdge,
    this.hitTestBehavior = HitTestBehavior.opaque,
  }) : assert(semanticChildCount == null || semanticChildCount >= 0);
}

作用

ScrollableFlutter 所有可滚动组件的底层基础类,它本身不直接定义 UI 布局,而是提供:

  • 滚动手势处理(比如拖动、惯性滚动)。
  • 滚动位置管理(通过 ScrollControllerScrollPosition)。
  • Viewport 的协作(由 viewportBuilder 决定具体如何渲染子组件)。

但是,它不直接决定:

  • 子组件的布局方式(列表、网格、单子项)。
  • 具体的视口实现(ViewportShrinkWrappingViewport 等)。

所以大多数时候不会直接用 Scrollable,而是用它的上层封装:

  • ListViewGridView(内置列表和网格布局)。
  • CustomScrollView(灵活组合 sliver,做复杂的滚动效果)。
  • SingleChildScrollView(单个子组件可滚动)。

controller

/// {@template flutter.widgets.Scrollable.controller}
/// 一个可以用来控制此组件滚动位置的对象。
///
/// [ScrollController] 有多个用途:  
/// - 可以用来控制初始滚动位置(参见 [ScrollController.initialScrollOffset])。  
/// - 可以用来控制滚动视图是否应该在 [PageStorage] 中自动保存和恢复滚动位置
///   (参见 [ScrollController.keepScrollOffset])。  
/// - 可以用来读取当前的滚动位置(参见 [ScrollController.offset]),
///   或者修改它(参见 [ScrollController.animateTo])。  
///
/// 如果为 null,[Scrollable] 会在内部创建一个 [ScrollController],
/// 以便创建和管理 [ScrollPosition]。  
///
/// 参见:  
///  * [Scrollable.ensureVisible]:它会通过动画滚动到指定的 [BuildContext],
///    以确保目标 widget 可见。  
/// {@endtemplate}
final ScrollController? controller;

viewportBuilder(重要)

/// 构建用于显示可滚动内容的视口(viewport)。
///
/// 一个典型的视口会使用提供的 [ViewportOffset] 来决定
/// 其内容的哪一部分实际处于可见范围之内。
///
/// 参见:
///
///  * [Viewport]:一种视口,用于显示一组 sliver。  
///  * [ShrinkWrappingViewport]:一种视口,用于显示一组 sliver,
///    并且会根据这些 sliver 的大小自动调整自身大小。
final ViewportBuilder viewportBuilder;

📌 解释

  • viewportBuilder:就是一个函数,用来构建 视口 (Viewport)
  • Viewport 的职责:决定在滚动的某一时刻,哪些内容应该被绘制(可见),哪些在可见范围外则不绘制(提升性能)。
  • ViewportOffset:代表滚动位置(比如滚动多少像素),Viewport 会用它来判断“现在内容的哪一段要显示”。

👉 换句话说:

  • Scrollable 负责滚动交互
  • viewportBuilder 决定“滚动时具体显示什么内容”

maybeOf

/// 返回包裹给定 [context] 的最近的该类实例对应的状态 [ScrollableState],
/// 如果找不到则返回 null。
///
/// 典型的用法如下:
///
/// ```dart
/// ScrollableState? scrollable = Scrollable.maybeOf(context);
/// ```
///
/// 调用此方法时,如果找到了 [ScrollableState],
/// 将会在返回的 [ScrollableState] 上创建依赖关系。
/// 通常这会是最近的 [Scrollable],
/// 但如果使用了 [axis] 参数来指定目标滚动方向,
/// 那么也可能是一个更远的祖先 [Scrollable]。
///
/// 当滚动视图(Scrollables)是嵌套的时,传入可选的 [Axis] 参数会很有用,
/// 因为目标 [Scrollable] 可能并不是离得最近的实例。
/// 如果提供了 [axis],则会返回该方向上最近的 [ScrollableState],
/// 如果没有则返回 null。
///
/// 注意,这个方法只会查找 `context` 的最近 **祖先** [Scrollable]。
/// 这意味着如果 `context` 本身就是一个 [Scrollable] 的话,
/// 它不会返回该 [Scrollable] 自己。
///
/// 另见:
///
/// * [Scrollable.of]:与此方法类似,但如果找不到任何 [Scrollable] 祖先会抛出异常。

_ScrollableScope

// 使得 Scrollable.of() 能像使用 InheritedWidget 一样工作,
// 通过依赖 _ScrollableScope 来访问 ScrollableState。
// ScrollableState.build() 总是会重新构建它的 _ScrollableScope。
class _ScrollableScope extends InheritedWidget {
  const _ScrollableScope({
    required this.scrollable,
    required this.position,
    required super.child,
  });

  /// 当前的 ScrollableState
  final ScrollableState scrollable;

  /// 对应的滚动位置 ScrollPosition
  final ScrollPosition position;

  @override
  bool updateShouldNotify(_ScrollableScope old) {
    // 当滚动位置改变时,通知依赖此 InheritedWidget 的子 widget 重建
    return position != old.position;
  }
}

📌 解释

  1. 作用

    • _ScrollableScope 是一个 InheritedWidget,包装了 ScrollableStateScrollPosition
    • 它的主要目的是让 Scrollable.of(context) / maybeOf(context) 可以通过 context.dependOnInheritedWidgetOfExactType 来找到最近的 ScrollableState
  2. 为什么要用 InheritedWidget

    • 通过 InheritedWidget,子 widget 可以 自动依赖滚动状态,当滚动位置变化时自动重建(通过 updateShouldNotify 判断)。
    • 保证了嵌套滚动场景中,子 widget 可以正确获取滚动信息。
  3. updateShouldNotify

    • position(滚动位置)变化时返回 true,通知依赖此 scope 的 widget 重新 build。
    • 这样可以保证需要知道滚动位置的子 widget 能够随滚动更新。

ScrollableState

/// [Scrollable] 小部件的 State 对象。
///
/// 要操作一个 [Scrollable] 小部件的滚动位置,
/// 可以使用从 [position] 属性获取到的对象。
///
/// 如果想要在 [Scrollable] 滚动时收到通知,
/// 可以通过 [NotificationListener] 来监听 [ScrollNotification] 通知。
///
/// 这个类不打算被继承(即不推荐自定义子类)。 
/// 如果想要定制 [Scrollable] 的行为,应通过提供一个 [ScrollPhysics] 来实现。
class ScrollableState extends State<Scrollable>
    // 混入 [TickerProviderStateMixin],允许该 State 提供 [Ticker],
    // 主要用于支持滚动动画(比如 [animateTo] 的动画会依赖 Ticker)。
    with TickerProviderStateMixin, RestorationMixin
    
    // 实现 [ScrollContext] 接口,ScrollContext 是一个协议接口,
    // 定义了滚动组件所需的上下文能力,例如获取 vsync、配置 ScrollPhysics 等。
    implements ScrollContext {}

  1. ScrollableScrollableState 的关系

    • Scrollable 是 Flutter 内部滚动体系的“最底层可滚动 widget”。
    • 它本身是一个 StatefulWidget,所以会有对应的 State → 也就是这里的 ScrollableState
    • 几乎所有 Flutter 的滚动组件(如 ListViewGridViewSingleChildScrollView)都是基于 Scrollable 封装的。
  2. position 属性的作用

    • ScrollableState 内部会管理一个 ScrollPosition 对象(即 position 属性)。

    • 通过 position,我们可以直接控制滚动条,比如:

      scrollableState.position.jumpTo(200);
      scrollableState.position.animateTo(500, duration: Duration(milliseconds: 300), curve: Curves.easeOut);
      
    • 这就是为什么文档说:要操作滚动位置,用 position

  3. 滚动事件的监听 (NotificationListener)

    • 滚动时,Scrollable 会分发 ScrollNotification
    • 我们可以用 NotificationListener<ScrollNotification> 包裹滚动组件,来获取滚动事件(比如监控滚动百分比,触发上拉加载)。
  4. 为什么不推荐继承 ScrollableState

    • ScrollableState 是 Flutter 的核心实现,不希望用户去修改。
    • 官方推荐的扩展方式是:通过 传入不同的 ScrollPhysics 控制滚动物理特性(比如惯性、回弹、边界处理等),而不是继承 ScrollableState
  5. TickerProviderStateMixin 的作用

    • 滚动动画(animateTo、惯性滑动、回弹效果)需要用到 Ticker 来驱动帧刷新。
    • TickerProviderStateMixinScrollableState 可以给动画控制器 (AnimationController) 提供 vsync,避免不必要的性能消耗。
  6. RestorationMixin 的作用

    • Flutter 的 State Restoration(状态恢复机制) ,用来在应用被杀掉或切后台时保存/恢复滚动位置。
    • 有了它,即使 App 重新启动,列表也能自动回到之前的滚动位置。
  7. ScrollContext 接口

    • 定义了滚动上下文环境,比如:

      • axisDirection(滚动方向:上下/左右)
      • notificationContext(发送滚动通知的 BuildContext)
      • vsync(动画驱动器)
      • userScrollDirection(用户滚动方向)
    • ScrollableState 实现了它,方便 ScrollPositionScrollable 进行解耦。

position

  /// 此 [Scrollable] 组件的视口位置(viewport position)的管理器。
  ///
  /// 如果要控制 [Scrollable] 创建哪种类型的 [ScrollPosition],
  /// 可以提供一个自定义的 [ScrollController],
  /// 并在其 [ScrollController.createScrollPosition] 方法中
  /// 创建并返回合适的 [ScrollPosition]。
  ScrollPosition get position => _position!;
  ScrollPosition? _position;
  • position 代表 这个 Scrollable 的滚动位置管理器

  • 如果开发者希望改变默认的 ScrollPosition 行为(比如分页滚动、特殊的边界处理),就需要自己实现一个 ScrollController,并在其中的 createScrollPosition 方法返回自定义的 ScrollPosition

resolvedPhysics

  /// [ScrollableState] 已解析(resolved)的 [ScrollPhysics]。
  ScrollPhysics? get resolvedPhysics => _physics;
  ScrollPhysics? _physics;
  • resolvedPhysics 表示 最终生效的滚动物理特性(ScrollPhysics)

  • ScrollPhysics 控制滚动的行为,比如:

    • 是否可以继续拖拽超出边界(overscroll/bounce)
    • 滑动的减速方式(摩擦力、惯性)
    • 滚动是否允许用户交互
  • Flutter 会把 Scrollable 配置的 physics 和系统默认的 physics 进行合并(resolve),得到一个最终版本 _physics,也就是 resolvedPhysics

deltaToScrollOrigin

  /// 一个 [Offset],表示 [ScrollPosition] 到原点(0)的绝对距离,
  /// 并且是根据关联的 [Axis](滚动方向轴)来表达的。
  ///
  /// 该属性被 [EdgeDraggingAutoScroller] 使用,
  /// 当拖拽手势到达 [Viewport] 边缘时,
  /// 用来推动(推进)滚动位置向前移动。
  Offset get deltaToScrollOrigin => switch (axisDirection) {
    AxisDirection.up => Offset(0, -position.pixels),
    AxisDirection.down => Offset(0, position.pixels),
    AxisDirection.left => Offset(-position.pixels, 0),
    AxisDirection.right => Offset(position.pixels, 0),
  };

_effectiveScrollController

ScrollController get _effectiveScrollController =>
    widget.controller ?? _fallbackScrollController!;

devicePixelRatio

@override
double get devicePixelRatio => _devicePixelRatio;
late double _devicePixelRatio;

_fallbackScrollController

ScrollController? _fallbackScrollController;

initState

  @protected
  @override
  void initState() {
    if (widget.controller == null) {
      _fallbackScrollController = ScrollController();
    }
    super.initState();
  }
  • if (widget.controller == null)
    👉 检查开发者是否在 Scrollable widget 外部传入了一个 ScrollController

  • _fallbackScrollController = ScrollController();
    👉 如果开发者没有提供 controller,那么框架会自己创建一个备用的 ScrollController,保证 Scrollable 总是有一个 ScrollController 可用。

didChangeDependencies

  @protected
  @override
  void didChangeDependencies() {
    // 从 MediaQuery 中获取手势相关的配置(如滚动灵敏度、触控设置等)
    // 如果 MediaQuery 不存在,则返回 null。
    _mediaQueryGestureSettings = MediaQuery.maybeGestureSettingsOf(context);

    // 获取当前设备的像素比(DPR, Device Pixel Ratio)
    // 优先从 MediaQuery 获取,如果没有 MediaQuery,就从 View 拿设备像素比。
    // 用于保证滚动位置、坐标计算在不同分辨率下保持正确。
    _devicePixelRatio =
        MediaQuery.maybeDevicePixelRatioOf(context) ?? View.of(context).devicePixelRatio;

    // 依赖环境可能发生了变化,需要刷新/更新 ScrollPosition。
    // 比如屏幕旋转、分辨率变化时,重新计算滚动位置。
    _updatePosition();

    // 调用父类 didChangeDependencies,保证框架自身逻辑也能正确执行。
    super.didChangeDependencies();
  }

_updatePosition(核心方法),创建的是 ScrollPositionWithSingleContext对象(抽象类ScrollPosition的具体实现)

  // 只在 **一定会触发重建** 的地方调用该方法。
  void _updatePosition() {
    // 获取 ScrollConfiguration 配置:
    // 1. 优先使用 widget.scrollBehavior(外部传入的配置)
    // 2. 否则使用 ScrollConfiguration.of(context) (全局默认配置)
    _configuration = widget.scrollBehavior ?? ScrollConfiguration.of(context);

    // 从 widget 上获取 ScrollPhysics(滚动物理特性),可能为空
    // 物理特性决定了滚动的惯性、边界回弹等行为。
    final ScrollPhysics? physicsFromWidget =
        widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context);

    // 基础 physics 来自 ScrollConfiguration
    _physics = _configuration.getScrollPhysics(context);

    // 如果 widget 提供了 physics,则将其叠加到基础 physics 上
    // (通过 applyTo 形成链式组合)
    _physics = physicsFromWidget?.applyTo(_physics) ?? _physics;

    // 保存当前旧的 ScrollPosition
    final ScrollPosition? oldPosition = _position;
    if (oldPosition != null) {
      // 先把旧的 position 从 controller 上分离
      _effectiveScrollController.detach(oldPosition);

      // ⚠️ 注意:不能立即 dispose 旧的 position
      // 因为 viewport 可能还没完全解除它的监听,
      // 所以延迟到 microtask 队列再释放,避免潜在的监听异常。
      scheduleMicrotask(oldPosition.dispose);
    }

    // 创建新的 ScrollPosition:
    // 传入物理特性(_physics)、上下文(this)、旧的 position(可能为 null)
    _position = _effectiveScrollController.createScrollPosition(_physics!, this, oldPosition);

    // 确保 position 一定不为空
    assert(_position != null);

    // 将新的 position 重新 attach 到 controller
    _effectiveScrollController.attach(position);
  }

  1. 重新解析滚动物理特性 (ScrollPhysics)
  2. 销毁旧的 ScrollPosition,创建并替换为新的 ScrollPosition
  3. 保证 controller 和新的 position 建立连接

👉 这个过程通常会发生在:

  • 依赖更新时(如 didChangeDependencies,屏幕旋转 / DPI 变化);
  • Widget 树重建时(比如 Scrollable 被重新构建)。

已经将position从_positions中移除了。那么scheduleMicrotask(oldPosition.dispose);里的oldPosition还存在吗?

_effectiveScrollController.detach(oldPosition) 会把 oldPosition_positions 集合中移除,那么紧接着 scheduleMicrotask(oldPosition.dispose) 时,这个 oldPosition 对象是不是就没了?

答案是:oldPosition 依然存在,而且不会是 null。原因如下:

1. Dart 的对象生命周期

在 Dart 里,一个对象是否还存在,取决于是否还有“强引用”指向它。

  • 当你调用 detach 时,_positions.remove(position) 只是把 controller 对该 position 的引用去掉
  • 但是在 _updatePosition 方法里,局部变量 oldPosition 依然强引用着它
  • 这意味着,在方法执行完之前,oldPosition 肯定不会被 GC(垃圾回收)掉。

所以 scheduleMicrotask(oldPosition.dispose) 里传递的 oldPosition 是一个有效的对象。

2. 为什么用 scheduleMicrotask

这里的关键点是:

  • 不能立刻 dispose,否则可能会打断 Viewport 等还在访问旧 position 的逻辑。
  • 使用 scheduleMicrotask,是把 dispose 延迟到 当前事件循环结束后,即所有本次布局/绘制相关的操作完成之后再执行。
  • 此时旧的 position 已经完全“没人用了”,再销毁就安全了。
3. dispose 时对象的状态
  • detach 后,oldPosition 已经不再属于 controller 的 _positions 集合了。
  • 但对象本身依然活着(因为 oldPosition 变量持有它)。
  • 到 microtask 执行时,oldPosition.dispose() 才真正清理内部资源(listener、controller reference 等)。
结论

所以流程是这样的:

  1. oldPosition 从 controller 中移除(不再被 _positions 持有)。
  2. oldPosition 依然存在,因为方法里局部变量持有它。
  3. scheduleMicrotask 延迟到本轮事件循环结束后调用 dispose
  4. dispose 才最终释放旧 position 的内部状态,GC 之后才能真正清掉对象。
补充微任务(scheduleMicrotask)
scheduleMicrotask 的执行时机

在 Dart(Flutter)中:

  • **微任务队列(microtask queue)事件队列(event queue)**是两个不同的队列。
  • 当前同步代码执行完毕后,微任务队列中的所有任务会依次执行。
  • **事件队列(比如定时器、UI事件等)**在微任务队列清空后才会执行。

关键点:

  • scheduleMicrotask 并不是“立刻执行”,它是安排在当前同步代码之后、下一次事件循环前执行。
  • 它的作用就是 延迟执行,确保当前函数 _updatePosition 的同步逻辑先走完。
_updatePosition 中为什么要用 scheduleMicrotask?
  • detach(oldPosition):把 oldPosition_effectiveScrollController._positions 中移除。

  • 此时 viewport 还可能有对 oldPosition 的监听器,例如 ScrollPositionListener

  • 如果你直接调用 oldPosition.dispose()

    • 会立即清理所有监听器。
    • 可能会导致 viewport 内部还在访问 oldPosition 时发生错误(比如 null pointer 或监听器未清理完毕)。
  • 使用 scheduleMicrotask 可以_updatePosition 的同步逻辑完成后再 dispose,确保安全。

所以微任务 并不会在“所有任务前”执行,而是在当前同步方法结束后、下一次事件循环之前执行。

执行顺序总结

假设 _updatePosition 被调用:

  1. 执行同步代码 _configuration = ..._physics = ...
  2. detach(oldPosition) 移除旧的 ScrollPosition。
  3. scheduleMicrotask(oldPosition.dispose)dispose 放入微任务队列。
  4. _position = createScrollPosition(...)attach(position) 执行。
  5. 当前 _updatePosition 函数返回,同步代码结束
  6. 微任务队列执行oldPosition.dispose() 被调用。
  7. 之后才处理事件队列中的其他任务(UI、Timer、I/O 等)。

✅ 所以这是 Flutter 官方安全做法,不会导致 dispose 太早

didUpdateWidget

@protected
@override
void didUpdateWidget(Scrollable oldWidget) {
  super.didUpdateWidget(oldWidget);

  if (widget.controller != oldWidget.controller) {
    if (oldWidget.controller == null) {
      // 旧的 controller 为 null,这意味着回退控制器不能为 null。
      // 释放回退控制器。
      assert(_fallbackScrollController != null);
      assert(widget.controller != null);
      _fallbackScrollController!.detach(position);
      _fallbackScrollController!.dispose();
      _fallbackScrollController = null;
    } else {
      // 旧的 controller 不为 null,则将其分离(detach)。
      oldWidget.controller?.detach(position);
      if (widget.controller == null) {
        // 如果新的 controller 为 null,则需要创建回退 ScrollController。
        _fallbackScrollController = ScrollController();
      }
    }
    // 绑定(attach)更新后的有效 ScrollController。
    _effectiveScrollController.attach(position);
  }

  if (_shouldUpdatePosition(oldWidget)) {
    _updatePosition();
  }
}

_shouldUpdatePosition

bool _shouldUpdatePosition(Scrollable oldWidget) {
  // 如果 scrollBehavior 是否为 null 与旧 widget 不一致,则需要更新位置
  if ((widget.scrollBehavior == null) != (oldWidget.scrollBehavior == null)) {
    return true;
  }

  // 如果 scrollBehavior 都不为 null,且新旧 scrollBehavior 的 shouldNotify 返回 true,则需要更新位置
  if (widget.scrollBehavior != null &&
      oldWidget.scrollBehavior != null &&
      widget.scrollBehavior!.shouldNotify(oldWidget.scrollBehavior!)) {
    return true;
  }

  // 获取新旧 ScrollPhysics(优先使用 widget.physics,如果没有则使用 scrollBehavior 提供的 physics)
  ScrollPhysics? newPhysics = widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context);
  ScrollPhysics? oldPhysics =
      oldWidget.physics ?? oldWidget.scrollBehavior?.getScrollPhysics(context);

  // 遍历 physics 链,比较 runtimeType 是否不同,有不同则需要更新位置
  do {
    if (newPhysics?.runtimeType != oldPhysics?.runtimeType) {
      return true;
    }
    newPhysics = newPhysics?.parent;
    oldPhysics = oldPhysics?.parent;
  } while (newPhysics != null || oldPhysics != null);

  // 最后,如果 controller 类型不同,也需要更新位置
  return widget.controller?.runtimeType != oldWidget.controller?.runtimeType;
}

build

Widget build(BuildContext context) {
    assert(_position != null);
    // _ScrollableScope 必须放在 notificationContext 返回的 BuildContext 之上,
    // 这样我们才能通过下面的方式获取到这个 ScrollableState:
    //
    // ScrollNotification notification;
    // Scrollable.of(notification.context)
    //
    // 由于 notificationContext 指向的是 _gestureDetectorKey.context,
    // 所以 _ScrollableScope 必须放在使用它的 widget(RawGestureDetector)之上。
    Widget result = _ScrollableScope(
      scrollable: this,
      position: position,
      child: Listener(
        onPointerSignal: _receivedPointerSignal,
        child: RawGestureDetector(
          key: _gestureDetectorKey,
          gestures: _gestureRecognizers,
          behavior: widget.hitTestBehavior,
          excludeFromSemantics: widget.excludeFromSemantics,
          child: Semantics(
            explicitChildNodes: !widget.excludeFromSemantics,
            child: IgnorePointer(
              key: _ignorePointerKey,
              ignoring: _shouldIgnorePointer,
              child: widget.viewportBuilder(context, position),
            ),
          ),
        ),
      ),
    );

    if (!widget.excludeFromSemantics) {
      result = NotificationListener<ScrollMetricsNotification>(
        onNotification: _handleScrollMetricsNotification,
        child: _ScrollSemantics(
          key: _scrollSemanticsKey,
          position: position,
          allowImplicitScrolling: _physics!.allowImplicitScrolling,
          axis: widget.axis,
          semanticChildCount: widget.semanticChildCount,
          child: result,
        ),
      );
    }

    result = _buildChrome(context, result);

    // 仅当存在父级 registrar 时才启用 selection。
    final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context);
    if (registrar != null) {
      result = _ScrollableSelectionHandler(
        state: this,
        position: position,
        registrar: registrar,
        child: result,
      );
    }

    return result;
  }

_receivedPointerSignal

void _receivedPointerSignal(PointerSignalEvent event) {
    if (event is PointerScrollEvent && _position != null) {
      if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) {
        // 处理器不会使用这个 `event`,因此允许平台触发任何默认的原生行为。
        event.respond(allowPlatformDefault: true);
        return;
      }
      final double delta = _pointerSignalEventDelta(event);
      final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
      // 只有当事件真正会引起滚动时,才对它表示感兴趣。
      if (delta != 0.0 && targetScrollOffset != position.pixels) {
        GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll);
        return;
      }
      // 这个 `event` 不会引起滚动,因此允许平台触发任何默认的原生行为。
      event.respond(allowPlatformDefault: true);
    } else if (event is PointerScrollInertiaCancelEvent) {
      position.pointerScroll(0);
      // 不使用 pointer signal resolver,所有经过命中测试的 Scrollable 都应该停止。
    }
  }

_pointerSignalEventDelta

// 返回在考虑了 ScrollBehavior 指定的轴、方向以及任何修饰键之后,
// 应该由 [event] 产生的滚动增量(delta)。
double _pointerSignalEventDelta(PointerScrollEvent event) {
  final Set<LogicalKeyboardKey> pressed = HardwareKeyboard.instance.logicalKeysPressed;
  final bool flipAxes =
      pressed.any(_configuration.pointerAxisModifiers.contains) &&
      // 只有“物理鼠标滚轮”的输入才会触发轴翻转。
      // 在某些平台(比如 Web)上,触控板(trackpad)的输入也通过 pointer signals 传递,
      // 但不应该参与这种轴翻转行为。
      // 这是因为触控板天然支持所有方向的滚动,而鼠标滚轮通常只在单一轴上滚动。
      event.kind == PointerDeviceKind.mouse;

  final Axis axis = flipAxes ? flipAxis(widget.axis) : widget.axis;
  final double delta = switch (axis) {
    Axis.horizontal => event.scrollDelta.dx,
    Axis.vertical => event.scrollDelta.dy,
  };

  return axisDirectionIsReversed(widget.axisDirection) ? -delta : delta;
}

可以总结为三步,算出 delta 的原理就是:

  1. 确定取哪个轴的增量

    • 默认按 widget.axisdy(竖直)或 dx(水平)。
    • 如果用户按下了配置的“翻转修饰键”(如 Shift)并且输入来自 鼠标,则横竖轴互换,改为取另一方向的分量。
  2. 读取事件中的原始滚动量

    • PointerScrollEvent.scrollDelta 里取对应方向的 dxdy
    • 这就是平台层提供的滚轮/触控板的原始偏移量。
  3. 根据 axisDirection 调整正负号

    • 如果 Scrollable 的滚动方向是反向(up/left),则取负值。
    • 这样保证无论正向还是反向,delta 的符号语义都与 Scrollable 一致。

👉 归纳一句话:
delta =(按是否翻转确定轴向)取事件的 dx/dy → 再按滚动方向(正/反向)修正符号

_targetScrollOffsetForPointerScroll

// SCROLL WHEEL
//
// 返回应用 [event] 到当前位置后应产生的偏移量,
// 同时考虑到最小/最大可滚动范围的限制。
double _targetScrollOffsetForPointerScroll(double delta) {
  return math.min(
    math.max(position.pixels + delta, position.minScrollExtent),
    position.maxScrollExtent,
  );
}

  • SCROLL WHEEL → 滚轮(表示这是处理鼠标滚轮的逻辑)
  • Returns the offset that should result from applying [event] to the current position, taking min/max scroll extent into account.
    → 返回将 [event] 应用到当前位置后应该得到的目标偏移量,同时会考虑最小/最大滚动范围。

方法逻辑解释

  1. 当前位置position.pixels
  2. 加上滚动增量position.pixels + delta
  3. 保证不小于最小滚动值math.max(..., position.minScrollExtent)
  4. 保证不大于最大滚动值math.min(..., position.maxScrollExtent)

最终得到一个 合法的目标滚动位置,不会出现“超出内容边界”的情况。

_handlePointerScroll

void _handlePointerScroll(PointerEvent event) {
  assert(event is PointerScrollEvent);
  // 计算滚动事件对应的增量(delta),
  // 这个值会根据滚动方向、轴、是否反转等因素计算出来。
  final double delta = _pointerSignalEventDelta(event as PointerScrollEvent);

  // 根据 delta 计算目标滚动位置,
  // 并确保结果在 minScrollExtent 和 maxScrollExtent 范围内。
  final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);

  // 只有当 delta 不为 0 且目标位置与当前位置不同的时候,
  // 才真正触发滚动。
  if (delta != 0.0 && targetScrollOffset != position.pixels) {
    position.pointerScroll(delta);
  }
}

position.pointerScroll(delta)真正滑动的方法

position.pointerScroll(delta) 是真正滑动到指定位置的方法。 position的对象是类型是ScrollPositionWithSingleContext对象