flutter滚动视图之ScrollPositionAlignmentPolicy、ScrollPosition源码解析(二)

ScrollPositionAlignmentPolicy

ruby 复制代码
/// 在应用 [ScrollPosition.ensureVisible] 的 `alignment` 参数时使用的策略。
enum ScrollPositionAlignmentPolicy {
  /// 使用 [ScrollPosition.ensureVisible] 的 `alignment` 参数来决定
  /// 目标对象在滚动后如何对齐。
  explicit,

  /// 找到滚动容器的底部边界,如果有需要则滚动容器,以保证对象的底部可见。
  ///
  /// 例如,找到滚动容器的底部边界。如果目标项的底部在容器的底部边界之下,
  /// 则滚动使该目标项的底部正好出现在容器的可见区域内。
  /// 如果该目标项已经完全可见,则不进行滚动。
  keepVisibleAtEnd,

  /// 找到滚动容器的顶部边界,如果有需要则滚动容器,以保证对象的顶部可见。
  ///
  /// 例如,找到滚动容器的顶部边界。如果目标项的顶部在容器的顶部边界之上,
  /// 则滚动使该目标项的顶部正好出现在容器的可见区域内。
  /// 如果该目标项已经完全可见,则不进行滚动。
  keepVisibleAtStart,
}

是一个 枚举 ,主要用于控制 ScrollPosition.ensureVisible 在滚动时如何对齐目标 Widget。

1. explicit

  • 行为 :完全依赖传入的 alignment 参数(0.0 表示顶部对齐,1.0 表示底部对齐,0.5 表示居中等)。
  • 应用场景 :你希望 自定义精确的对齐方式

2. keepVisibleAtEnd

  • 行为 :如果目标 Widget 的 底部超出容器的可见范围 ,就滚动让它的底部正好显示在容器底部;
    如果目标 Widget 已经完整可见,则不滚动。
  • 应用场景 :适合 聊天窗口 这类场景,新消息进来时保持在底部。

3. keepVisibleAtStart

  • 行为 :如果目标 Widget 的 顶部在容器上方不可见 ,就滚动让它的顶部正好显示在容器顶部;
    如果目标 Widget 已经完整可见,则不滚动。
  • 应用场景 :适合需要保持 列表头部元素可见 的情况,比如目录导航。

ScrollPosition

ScrollPosition 就是 记录和管理滚动视图当前滚到哪、能滚到哪、怎么滚的对象,它保存了偏移量、边界、滚动状态,并能通知监听器变化。

  • 存数据 :保存当前滚动位置(pixels)、最大/最小能滚多远(maxScrollExtent / minScrollExtent)。

  • 管滚动 :配合 physics 决定滚动方式(比如弹性、惯性)。

  • 控行为 :通过 activity 表示现在是拖拽、惯性还是静止。

  • 能监听Listenable,随时通知你「滚动发生了」。

  • 配套用 :常和 ScrollControllerNotificationListener 一起使用。

scala 复制代码
/// 确定在滚动视图中内容的哪一部分是可见的。
///
/// [pixels] 值决定了滚动视图的滚动偏移量,用于选择显示内容的哪一部分。
/// 当用户滚动视口时,这个值会发生变化,从而改变显示的内容。
///
/// [ScrollPosition] 会对滚动应用 [physics],并保存 [minScrollExtent] 和 [maxScrollExtent]。
///
/// 滚动由当前的 [activity] 控制,它是通过 [beginActivity] 设置的。
/// [ScrollPosition] 本身并不会启动任何 activity。
/// 相反,具体的子类(如 [ScrollPositionWithSingleContext])通常会在响应用户输入
/// 或 [ScrollController] 的指令时启动 activity。
///
/// 该对象是一个 [Listenable],当 [pixels] 发生变化时会通知监听器。
///
/// {@template flutter.widgets.scrollPosition.listening}
/// ### 获取滚动信息
///
/// 有几种方式可以获取有关滚动和可滚动组件的信息,
/// 但它们提供的信息类型各不相同,
/// 包括滚动行为、位置以及 [Viewport] 的尺寸等。
///
/// [ScrollController] 是一个 [Listenable]。
/// 当任意一个附加到它的 [ScrollPosition] 通知其监听器时,
/// 它也会通知自己的监听器(比如在滚动发生时)。
/// 这和使用 [ScrollNotification] 类型的 [NotificationListener]
/// 来监听滚动位置变化非常相似,但两者不同之处在于:
///
/// - [NotificationListener] 会提供有关"滚动活动"的信息;  
/// - [ScrollController] 则可以直接访问滚动位置对象本身。  
///
/// 另外,监听 [ScrollNotification] 还可以细分为监听其特定子类,
/// 比如 [UserScrollNotification]。
///
/// {@tool dartpad}
/// 这个示例展示了使用 [ScrollController] 与使用 [ScrollNotification] 类型的
/// [NotificationListener] 来监听滚动的区别。
/// 切换 [Radio] 按钮可以在两种方式间切换。
/// 使用 [ScrollNotification] 可以获取滚动活动的详细信息以及 [ScrollPosition] 的指标,
/// 但不能直接获取滚动位置对象本身;
/// 使用 [ScrollController] 则可以直接访问位置对象。
/// 这两种方式都只会在"滚动行为发生时"触发。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_notification.0.dart**
/// {@end-tool}
///
/// 注意:  
/// [ScrollController] 不会在附加到它的 [ScrollPosition] 列表发生变化时通知监听器。  
/// 如果需要监听滚动位置的附加和分离,可以使用 [ScrollController.onAttach] 和
/// [ScrollController.onDetach] 方法。  
/// 这在 [Scrollable] 的 build 方法中创建滚动位置时,
/// 想要为其 [ScrollPosition.isScrollingNotifier] 添加监听器时也很有用。
///
/// 当一个滚动位置刚附加上时,
/// 它的 [ScrollMetrics](例如 [ScrollMetrics.maxScrollExtent])还不可用,  
/// 因为这些值要等到 [Scrollable] 完成布局并计算内容范围后才会确定。  
/// 可以通过 [ScrollPosition.hasContentDimensions] 来判断指标是否已可用,  
/// 或者使用 [ScrollMetricsNotification](后面会详细讨论)。
///
/// {@tool dartpad}
/// 这个示例展示了如何通过 [ScrollController.onAttach]
/// 为 [ScrollPosition.isScrollingNotifier] 添加监听器。  
/// 它用于在滚动发生时改变 [AppBar] 的颜色。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart**
/// {@end-tool}
///
/// #### 从不同的上下文中获取
///
/// 当需要在"滚动组件内部的上下文"中获取滚动信息时,
/// 可以使用 [Scrollable.of] 获取 [ScrollableState],
/// 然后通过 [ScrollableState.position] 获取 [ScrollPosition]。  
/// 这个位置对象就是附加在 [ScrollController] 上的那个。
///
/// 当需要在"滚动组件外部的上下文"中获取滚动信息时,使用 [ScrollNotificationObserver]。  
/// 例如 [AppBar] 的"滚动置底效果"就是这样实现的。  
/// 因为 [Scaffold.appBar] 与 [Scaffold.body] 属于不同的子树,  
/// 滚动通知不会冒泡到 app bar。  
/// 可以通过 [ScrollNotificationObserverState.addListener] 监听在当前上下文之外发生的滚动通知。
///
/// #### 尺寸变化
///
/// 需要注意的是:  
/// 监听 [ScrollController] 或 [ScrollPosition] **不会**在滚动指标([ScrollMetrics])发生变化时触发,  
/// 比如窗口大小改变导致 [Viewport] 尺寸或可滚动范围发生变化。  
/// 如果需要监听滚动指标的变化,应使用 [ScrollMetricsNotification]。  
/// 与 [ScrollNotification] 不同,  
/// [ScrollMetricsNotification] 并不与"滚动活动"相关,  
/// 而是与滚动区域的尺寸变化有关(例如窗口大小)。
///
/// {@tool dartpad}
/// 这个示例展示了当 `windowSize` 改变时,
/// 如何派发一个 [ScrollMetricsNotification]。  
/// 点击浮动按钮可以增加可滚动窗口的大小。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart**
/// {@end-tool}
/// {@endtemplate}
///
/// ## 继承 ScrollPosition
///
/// 随着时间推移,一个 [Scrollable] 可能会有多个不同的 [ScrollPosition] 对象。  
/// 例如,当 [Scrollable.physics] 的类型发生变化时,  
/// [Scrollable] 会使用新的 physics 创建一个新的 [ScrollPosition]。  
/// 为了将旧实例的状态转移到新实例,子类需要实现 [absorb] 方法(详见 [absorb])。  
///
/// 此外,子类在 [userScrollDirection] 值变化时还需要调用 [didUpdateScrollDirection]。
///
/// 参见:
///
///  * [Scrollable]:使用 [ScrollPosition] 来决定显示内容的哪一部分。  
///  * [ScrollController]:可与 [ListView]、[GridView] 等可滚动组件一起使用,用于控制 [ScrollPosition]。  
///  * [ScrollPositionWithSingleContext]:最常用的 [ScrollPosition] 具体子类。  
///  * [ScrollNotification] 和 [NotificationListener]:在不使用 [ScrollController] 的情况下监听滚动位置。  
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {}

ScrollPosition

kotlin 复制代码
  /// 创建一个对象,用来确定滚动视图中哪一部分内容是可见的。
  ScrollPosition({
    required this.physics,        // 滚动物理特性(决定滚动行为,比如惯性、回弹等)
    required this.context,        // 上下文信息(通常是 Scrollable 的 BuildContext)
    this.keepScrollOffset = true, // 是否保持滚动偏移量(通常用于恢复滚动位置)
    ScrollPosition? oldPosition,  // 旧的滚动位置对象,用来在重建时继承状态
    this.debugLabel,              // 调试标签
  }) {
    if (oldPosition != null) {
      absorb(oldPosition);        // 如果有旧的滚动位置,吸收它的状态
    }
    if (keepScrollOffset) {
      restoreScrollOffset();      // 如果需要保持滚动偏移,就尝试恢复滚动位置
    }
  }

}

当创建一个 ScrollPosition 时,它会做三件事:

  1. 设置基础属性 :保存滚动物理特性(physics)、上下文(context)、是否保持偏移(keepScrollOffset)等。
  2. 继承旧状态 :如果传入了 oldPosition,会调用 absorb(oldPosition),把旧的滚动状态(如 pixels、方向等)转移过来。
  3. 恢复滚动偏移 :如果 keepScrollOffset = true,会尝试调用 restoreScrollOffset() 恢复之前的滚动位置(比如应用重建后保持列表滚动到原来的位置)。

假设有一个 ListView,滑到了中间,然后因为主题切换或屏幕旋转导致 Widget 重建:

  • Flutter 会创建一个新的 ScrollPosition
  • 它会把旧的 ScrollPosition 的状态吸收过来(所以列表不会跳到顶部)。
  • 如果设置了 keepScrollOffset = true,它还会自动恢复到之前的滚动位置。

physics、context、keepScrollOffset、minScrollExtent、maxScrollExtent

dart 复制代码
  /// 滚动位置应该如何响应用户输入。
  ///
  /// 例如:决定当用户停止拖动滚动视图后,
  /// 该组件应该如何继续执行动画。
  final ScrollPhysics physics;

  /// 滚动发生的上下文。
  ///
  /// 通常由 [ScrollableState] 实现。
  final ScrollContext context;

  /// 是否使用 [PageStorage] 保存当前滚动偏移,并在该滚动位置的
  /// 可滚动组件被重新创建时恢复它。
  ///
  /// 另见:
  ///
  ///  * [ScrollController.keepScrollOffset] 和 [PageController.keepPage],
  ///    它们会创建滚动位置并初始化这个属性。
  // TODO(goderbauer): 当状态恢复支持 PageStorage 的所有功能时,弃用这个属性。
  final bool keepScrollOffset;

  /// 一个用于 [toString] 输出的标签。
  ///
  /// 主要用于在调试输出中帮助区分不同的动画控制器实例。
  final String? debugLabel;

  @override
  double get minScrollExtent => _minScrollExtent!;
  double? _minScrollExtent;   // 最小可滚动范围

  @override
  double get maxScrollExtent => _maxScrollExtent!;
  double? _maxScrollExtent;   // 最大可滚动范围

  @override
  bool get hasContentDimensions => _minScrollExtent != null && _maxScrollExtent != null;
  // 是否已经有内容的滚动范围(即 min/max 都已确定)

  /// 在单帧内 [forcePixels] 改变时,额外添加的速度。
  ///
  /// 这个值和当前 [activity] 的 [ScrollActivity.velocity] 一起
  /// 用于 [recommendDeferredLoading],来询问 [physics] 是否需要延迟加载。
  ///
  /// 这是因为一次 [forcePixels] 调用可能对应的 [ScrollActivity] 速度为 0,
  /// 但实际上滚动视图瞬间从当前位置跳到一个很远的位置。
  /// 调用方需要考虑这种情况。
  ///
  /// 举例:
  /// 如果当前滚动位置在 5000 像素,我们 [jumpTo] 到 0(回到顶部),
  /// 则隐含速度为 -5000,而 `activity.velocity` 为 0。
  /// 在这个跳转过程中,可能会跨过大量资源消耗大的 Widget,
  /// 它们应该避免执行不必要的工作。
  double _impliedVelocity = 0;

  @override
  double get pixels => _pixels!;
  double? _pixels;   // 当前滚动偏移量

  @override
  bool get hasPixels => _pixels != null;   // 是否有有效的滚动偏移

  @override
  double get viewportDimension => _viewportDimension!;
  double? _viewportDimension;   // 视口(可见区域)的尺寸

  @override
  bool get hasViewportDimension => _viewportDimension != null; 
  // 是否已经有视口尺寸

  /// 是否已经有 [viewportDimension]、[minScrollExtent]、[maxScrollExtent]、
  /// [outOfRange] 和 [atEdge] 等值。
  ///
  /// 在第一次调用 [applyNewDimensions] 之前设置为 true。
  bool get haveDimensions => _haveDimensions;
  bool _haveDimensions = false;

  /// 该位置上的可滚动组件是否应该吸收指针事件。
  ///
  /// 该值取决于当前的 [ScrollActivity],
  /// 决定滚动视图或其子元素是否还能接收额外的触摸输入。
  /// 
  /// 比如:在 [BouncingScrollPhysics] 允许越界滚动时,
  /// 当滚动视图从越界状态回弹时,
  /// 子组件仍然可以接收指针事件。
  bool get shouldIgnorePointer => !outOfRange && (activity?.shouldIgnorePointer ?? true);
  • 核心属性

    • physics 👉 滚动规则(决定怎么滚)。
    • context 👉 滚动发生的环境。
    • keepScrollOffset 👉 要不要保存和恢复滚动位置。
    • debugLabel 👉 调试用的名字。
  • 滚动范围

    • minScrollExtent / maxScrollExtent 👉 最小、最大可滚动位置。
    • hasContentDimensions 👉 是否已经知道内容的滚动范围。
  • 滚动状态

    • pixels 👉 当前滚动偏移量。
    • viewportDimension 👉 可见窗口的大小。
    • haveDimensions 👉 是否已经知道了完整的滚动尺寸信息。
  • 特殊逻辑

    • _impliedVelocity 👉 处理「瞬间跳跃」时的隐含速度。
    • shouldIgnorePointer 👉 决定滚动时,子组件是否还能响应触摸事件。

absorb

scss 复制代码
  /// 从给定的 [ScrollPosition] 中接管所有当前可用的状态。
  ///
  /// 如果构造函数传入了 `oldPosition`,就会调用此方法。
  /// `other` 参数的 [runtimeType] 可能和当前对象不同。
  ///
  /// 这个方法可能会破坏另一个 [ScrollPosition] 的状态。
  /// 调用后,另一个对象必须立即被销毁(在相同的调用栈中,
  /// 在 microtask 执行前,由调用当前构造函数的代码负责销毁)。
  ///
  /// 如果旧的 [ScrollPosition] 对象和当前对象的 [runtimeType] 不同,
  /// 那么会在新接管的 [ScrollActivity] 上调用 [ScrollActivity.resetActivity]。
  ///
  /// ## 重写注意事项
  ///
  /// 如果子类要重写此方法:
  /// - 必须在设置任何和滚动指标或活动相关的状态后,调用 `super.absorb`,
  ///   因为此方法可能会重启 activity,而滚动活动通常依赖这些指标。  
  /// - 如果无法吸收另一个对象的 activity,可能需要手动启动一个 [IdleScrollActivity]。  
  /// - 如果子类本身充当了 [ScrollActivityDelegate],可能需要更新已吸收的
  ///   scroll activities 的 delegate。  


void absorb(ScrollPosition other) {
  assert(other.context == context);     // 确保两个位置的上下文一致
  assert(_pixels == null);              // 确保当前还没有设置滚动位置

  // 如果旧位置有内容范围,继承过来
  if (other.hasContentDimensions) {
    _minScrollExtent = other.minScrollExtent;
    _maxScrollExtent = other.maxScrollExtent;
  }

  // 如果旧位置有像素偏移,继承过来
  if (other.hasPixels) {
    _pixels = other.pixels;
  }

  // 如果旧位置有视口尺寸,继承过来
  if (other.hasViewportDimension) {
    _viewportDimension = other.viewportDimension;
  }

  // 接管 activity
  assert(activity == null);
  assert(other.activity != null);
  _activity = other.activity;
  other._activity = null;               // 旧的 activity 被"掏空"

  // 如果两个对象的类型不同,需要重置 activity
  if (other.runtimeType != runtimeType) {
    activity!.resetActivity();
  }

  // 更新 context 和 isScrollingNotifier 的状态
  context.setIgnorePointer(activity!.shouldIgnorePointer);
  isScrollingNotifier.value = activity!.isScrolling;
}

absorb 就是 把旧的 ScrollPosition 的状态(滚动范围、偏移、视口大小、activity 等)吸收过来,然后让旧的对象"失效",再用新的继续工作。

场景举例:

  • 你在 ListView 里滚动到一半。
  • 因为某些原因(比如 physics 改变)需要创建一个新的 ScrollPosition
  • 新的对象会调用 absorb(oldPosition),把旧的滚动位置和状态都继承过来。
  • 这样一来,用户不会看到"跳回到顶部"的情况,滚动是连续的。

setPixels(非常重要)

scss 复制代码
  /// 将滚动位置([pixels])更新为指定的像素值。
  ///
  /// 只能由当前的 [ScrollActivity] 调用,
  /// 要么是在临时回调阶段,要么是响应用户输入时调用。
  ///
  /// 返回值表示是否有越界(overscroll)。
  /// - 如果返回 0.0,说明 [pixels] 已成功更新为给定的 `value`。
  /// - 如果返回值 > 0,说明新的 [pixels] 小于请求值(超出了最大滚动范围)。  
  /// - 如果返回值 < 0,说明新的 [pixels] 大于请求值(超出了最小滚动范围)。  
  ///
  /// 越界量由 [applyBoundaryConditions] 计算。
  ///
  /// 实际应用的偏移变化量通过 [didUpdateScrollPositionBy] 上报。  
  /// 如果有越界,则通过 [didOverscrollBy] 上报。


double setPixels(double newPixels) {
  assert(hasPixels); // 必须已有有效的像素位置

  // 不能在 build/layout/paint 阶段修改滚动位置,否则会干扰渲染流程
  assert(
    SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks,
    "不能在 build/layout/paint 阶段修改滚动位置!"
  );

  // 如果新的像素和当前不同
  if (newPixels != pixels) {
    // 计算边界条件(检查是否超出 min/max)
    final double overscroll = applyBoundaryConditions(newPixels);

    // debug 校验:overscroll 不能比实际移动量还大
    assert(() {
      final double delta = newPixels - pixels;
      if (overscroll.abs() > delta.abs()) {
        throw FlutterError("applyBoundaryConditions 返回了非法的 overscroll 值!");
      }
      return true;
    }());

    // 保存旧值,并更新为「newPixels - overscroll」
    final double oldPixels = pixels;
    _pixels = newPixels - overscroll;

    // 如果确实有变化
    if (_pixels != oldPixels) {
      if (outOfRange) {
        context.setIgnorePointer(false); // 越界时允许子组件接收触摸事件
      }
      notifyListeners(); // 通知监听器滚动位置改变
      didUpdateScrollPositionBy(pixels - oldPixels); // 报告实际偏移变化量
    }

    // 如果存在越界(大于精度容忍范围)
    if (overscroll.abs() > precisionErrorTolerance) {
      didOverscrollBy(overscroll); // 报告越界
      return overscroll; // 返回越界值
    }
  }

  return 0.0; // 没有变化 / 没有越界
}

setPixels 就是 "把滚动条拉到某个位置" 的底层实现,流程是:

  1. 检查新位置 newPixels
  2. 看看会不会超出范围(用 applyBoundaryConditions 算出 overscroll)。
  3. 如果能更新,就更新 _pixels 并通知监听器。
  4. 如果越界了,调用 didOverscrollBy 上报越界情况。

📌 举个例子

  • 列表最大滚动范围 = 1000,当前滚动到 900

  • 调用 setPixels(1200)

    • 目标是 1200,但超过最大范围 1000,overscroll = 200
    • 实际 _pixels 设置为 1000,返回 200,并触发 didOverscrollBy(200)

correctPixels

arduino 复制代码
/// 将 [pixels] 的值直接改为新的值,但不会通知任何监听者。
///
/// 这个方法主要用于 **布局过程 (layout)** 中调整位置。
/// 特别是在 [applyViewportDimension] 或 [applyContentDimensions] 的响应中调用。
/// (在这两种情况下,如果调用了该方法,那么它们通常应该返回 `false`,
/// 以表明位置已经被调整过了。)
///
/// 在其他情况下调用这个方法几乎都是不正确的。
/// 因为它不会立即触发渲染变化 ------
/// 依赖这个对象的 widget 或 render object 不会立刻被通知,
/// 它们只有在下一次读取该值时才会发现变化(可能会很晚才发生)。
///
/// 因此,通常只有在 **布局阶段修正值** 时才适用,
/// 尤其是 [ScrollPosition] 只有一个 viewport 客户端时。
///
/// 如果你想让位置立即跳转或动画滚动到一个新值,
/// 请考虑使用 [jumpTo] 或 [animateTo],
/// 它们会遵循正常的滚动偏移更新规则。
///
/// 如果你想绕过正常规则,强制把 [pixels] 设为某个值,
/// 可以使用 [forcePixels](不过请注意它的注释,
/// 即便如此,这通常仍然不是一个好主意)。
///
/// 另见:
///
///  * [correctBy] ------ [ViewportOffset] 的一个方法,
///    用于在布局过程中修正 offset,但不会通知监听者。
///  * [jumpTo] ------ 在不处于布局阶段时,
///    立即修改位置并应用新的偏移。
///  * [animateTo] ------ 类似 [jumpTo],但会通过动画滚动到目标偏移。

void correctPixels(double value) {
  _pixels = value;
}

非常简单:就是直接改 _pixels,完全 不触发任何通知或回调

🌟 使用场景

  • 什么时候用?

    • 布局过程中,因为内容尺寸或视口尺寸变化,需要"强制修正"滚动位置。
    • 比如:屏幕旋转、父布局大小变化、列表内容突然减少等。
  • 为什么不能乱用?

    • 因为它不会调用 notifyListeners(),所以界面不会立刻更新。
    • 如果不是在布局阶段用,UI 可能"卡住",直到下一次系统主动读取 pixels
相关推荐
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼10 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax