flutter滚动视图之ScrollDirection、ViewportOffset源码解析(一)

153 阅读11分钟

ScrollDirection

/// 滚动的方向,相对于由 [AxisDirection] 和 [GrowthDirection] 定义的
/// 正向滚动偏移轴。
///
/// 它和 [GrowthDirection] 类似,但不同的是它多了一个第三个值 [idle],
/// 表示当前没有滚动的状态。
///
/// 例如,在 [RenderSliverFloatingPersistentHeader] 中,它会用这个枚举
/// 来判断只有当用户滚动方向与检测到的滚动偏移变化方向一致时才会展开。
///
/// {@template flutter.rendering.ScrollDirection.sample}
/// 下面是一个示例:
/// 一个带有 [CustomScrollView] 的页面,
/// 在 [AppBar.bottom] 中用 [Radio] 按钮改变 [AxisDirection],
/// 以演示不同的配置。
/// 同时通过 [NotificationListener] 监听 [UserScrollNotification],
/// 当 [ScrollDirection] 发生变化或停止时,会触发通知。
/// {@endtemplate}
///
/// 相关内容:
///
///  * [AxisDirection]:方向性更强的枚举(值是 left/right/up/down)
///  * [GrowthDirection]:表示 Sliver 内容的增长顺序
///  * [UserScrollNotification]:当 [ScrollDirection] 变化时会发通知
enum ScrollDirection {
  /// 没有滚动(静止状态)
  idle,

  /// 向“负滚动偏移”方向滚动。
  ///
  /// 举例:对于一个垂直方向向下 [AxisDirection.down]、
  /// 且 [GrowthDirection.forward] 的列表(Flutter 默认情况),
  /// 这个方向意味着内容向下移动,显示出**更早的内容**(靠近 0 位置)。
  ///
  /// 可以记成:forward(前进)是“朝向”零位置。
  forward,

  /// 向“正滚动偏移”方向滚动。
  ///
  /// 举例:同样对于一个垂直方向向下 [AxisDirection.down]、
  /// 且 [GrowthDirection.forward] 的列表,
  /// 这个方向意味着内容向上移动,显示出**更晚的内容**(离 0 位置更远)。
  ///
  /// 可以记成:reverse(后退)是“远离”零位置。
  reverse,
}

原理解释

ScrollDirection 是一个非常小但核心的枚举,作用是抽象化“滚动的方向”:

  • 它不是简单的 up/down 或 left/right,而是基于“滚动偏移轴”的正负方向来判断。

  • 依赖的两个关键概念

    • AxisDirection:轴的物理方向(上/下/左/右)
    • GrowthDirection:内容在这个轴上排列的增长方向(forward/backward)
  • Flutter 的滚动体系中,实际滚动时偏移量是关键指标。

    • 偏移量增大 → ScrollDirection.reverse
    • 偏移量减小 → ScrollDirection.forward
    • 偏移量不变 → ScrollDirection.idle
  • 它是逻辑方向,不是物理方向
    物理上可能是“向上滑手指”,但逻辑上却可能是 forward 或 reverse,这取决于 AxisDirection 和 GrowthDirection 的组合。

ViewportOffset

ViewportOffset 是个抽象类,它是整个滚动体系的“偏移量接口”。继承ChangeNotifier

整体原理

ViewportOffset 是 Viewport(视口)和 ScrollPosition(滚动位置)之间的抽象契约:

  • 它管理 pixels(滚动偏移量)
  • 定义了如何在布局时应用视口尺寸和内容范围
  • 提供了跳转、动画滚动、修正等 API
  • Listenable(可以监听滚动偏移变化)

RenderViewportBase 会依赖这个对象来确定可视区域显示哪一部分内容。滚动手势、惯性动画、代码跳转都会修改它的 pixels,并触发重新布局和重绘。

ViewportOffset

abstract class ViewportOffset extends ChangeNotifier {}

定义抽象类 ViewportOffset,继承自 ChangeNotifier,意味着它可以被监听,当 pixels 改变时通知监听者(如 Scrollable)。

ViewportOffset

  ViewportOffset() {
    if (kFlutterMemoryAllocationsEnabled) {
      ChangeNotifier.maybeDispatchObjectCreation(this);
    }
  }

  • 构造函数,子类可以直接调用。

  • 在 Flutter 的内存调试模式下,记录这个对象的创建信息,用于分析内存分配。

工厂方法

  factory ViewportOffset.fixed(double value) = _FixedViewportOffset;
  factory ViewportOffset.zero() = _FixedViewportOffset.zero;
  • 工厂构造方法:

    • fixed:创建一个固定偏移量(不随用户滚动变化)。
    • zero:偏移量永远是 0.0(不滚动)。
  • 内部使用 _FixedViewportOffset 实现。

pixels(非常重要)

/// 在主轴方向的反方向上,子组件需要偏移的像素数。
///
/// 例如,如果主轴方向(axis direction)是向下(down),
/// 那么这个像素值表示子组件需要向**上**移动多少逻辑像素。
/// 同样地,如果主轴方向是向左(left),
/// 那么这个像素值表示子组件需要向**右**移动多少逻辑像素。
///
/// 当这个值发生变化时(除了因 [correctBy] 导致的变化),
/// 该对象会通知它的监听者。

  double get pixels;

  • 方向是反的
    pixels 表示内容相对视口的位移量,而不是滚动条的位移方向。
    例如:

    • 轴向是 downpixels 增大 → 内容向 移动 → 看到更靠下的内容。
    • 轴向是 leftpixels 增大 → 内容向 移动 → 看到更靠左的内容。
  • 单位
    “逻辑像素”(logical pixels),跟设备物理像素不一定一样。

  • 监听机制

    • pixels 变化(滚动手势、动画等)时,会调用 notifyListeners(),触发 Scrollable 重新布局/绘制。
    • 但如果变化是通过 correctBy() 修正的,不会触发监听(主要用于布局阶段修正,避免不必要的 rebuild)。

hasPixels

/// [pixels] 属性当前是否可用。
bool get hasPixels;
  • 用途
    在某些情况下,ViewportOffset 可能还没完成初始化,比如滚动视图还没有完成第一次布局(layout),这时 pixels 的值是未知的。
    hasPixels 就是用来告诉你 现在能不能安全读取 pixels

  • 什么时候为 false

    • 刚创建 ViewportOffset 时还没关联到具体的 ScrollPositionRenderViewport
    • 布局之前,视口尺寸和滚动状态还没确定。
  • 什么时候为 true

    • ViewportOffset 已经与滚动视图绑定,并完成了第一次布局。
    • 此时你可以安全地读取 pixels 获取当前滚动位置。
  • 常见用法
    在自定义 SliverRenderViewport 时,如果直接访问 pixelshasPixels 还为 false,会抛异常。
    因此通常会先检查:

    dart
    复制编辑
    if (offset.hasPixels) {
      print(offset.pixels);
    }
    

applyViewportDimension

/// 当视口(viewport)的范围被确定时调用。
///
/// 参数 `viewportDimension` 表示 [RenderViewport] 在主轴方向上的尺寸
/// (例如,垂直滚动时就是视口的高度)。
///
/// 这个方法可能会被重复调用,每一帧都可能传入相同的值。
/// 它是在 [RenderViewport] 的布局(layout)阶段调用的。
/// 如果视口配置成了 shrink-wrap(根据内容自适应大小),
/// 那么它可能会被调用多次,因为每次滚动偏移被修正时都会重新布局。
///
/// 如果这个方法被调用,它一定会在 [applyContentDimensions] 之前调用,
/// 并且在同一次布局阶段很快会调用 [applyContentDimensions]。
///
/// 如果视口不是 shrink-wrap 模式,那么这个方法只会在视口重新计算尺寸时调用
/// (例如父节点重新布局时),而不是在正常滚动过程中调用。
///
/// 如果应用新的视口尺寸后导致滚动偏移值发生变化,就返回 false;否则返回 true。
/// 返回 false 会让 [RenderViewport] 再次进行布局,这会比较耗性能。
/// (返回值的意思是:**你是否无条件接受这个新的视口尺寸**。
/// 如果新的尺寸导致 [ViewportOffset.pixels] 变化了,
/// 那么需要重新布局视口。)

bool applyViewportDimension(double viewportDimension);

这个方法是 滚动系统布局阶段的关键回调之一,它主要负责告诉 ViewportOffset

“视口在主轴方向的可见范围是多少(逻辑像素)?”

在 Flutter 的布局流程中:

  1. RenderViewport 布局开始

    • 它会先计算出自己的 viewportDimension(主轴可见范围,比如高度)。
  2. 调用 applyViewportDimension(viewportDimension)

    • 把这个尺寸传给 ViewportOffset(通常是 ScrollPosition)。
  3. ViewportOffset 决定是否需要调整 pixels

    • 例如:当前滚动位置可能因为视口变大/变小而需要修正(避免越界)。
  4. 返回值的意义

    • true → “我接受这个尺寸,不用重布局”
    • false → “我调整了滚动偏移,需要重新布局” → 会触发 RenderViewport 再跑一遍布局。

调用场景举例

  • 非 shrink-wrap 模式
    当父容器尺寸变化时(屏幕旋转、窗口大小变化)才会调用。
  • shrink-wrap 模式收缩包装模式
    因为视口尺寸依赖内容高度,所以滚动偏移变化也会影响视口尺寸,可能导致多次调用。

性能注意点

  • 如果返回 false,会多一次完整布局 → 影响性能。
  • 实现中应尽量在不必要时避免调整 pixels,以减少重复布局。

applyContentDimensions

/// 当视口(viewport)的**内容范围**被确定时调用。
///
/// 参数是最小和最大可滚动范围(分别是 minScrollExtent 和 maxScrollExtent)。
/// 最小值一定小于或等于最大值。
/// 对于 Sliver 来说,minScrollExtent ≤ 0,maxScrollExtent ≥ 0。
///
/// 注意:maxScrollExtent 的值会减去视口的可见尺寸。
/// 例如:如果总共有 100 像素可滚动内容,
/// 视口的高度是 80 像素,
/// 那么 minScrollExtent 通常是 0.0,maxScrollExtent 通常是 20.0,
/// 因为实际可滚动的“余量”只有 20 像素。
///
/// 如果应用这些内容范围后导致滚动偏移值(pixels)发生变化,就返回 false;
/// 否则返回 true。
/// 返回 false 会让 [RenderViewport] 再次进行布局,这会比较耗性能。
/// (返回值本质上是在回答:
/// “你是否无条件接受这些内容范围?”
/// 如果新的范围会导致 [pixels] 改变,
/// 那么视口就需要重新布局。)
///
/// 这个方法在每次 [RenderViewport] 布局时至少调用一次,
/// 即使 min/max 值没变也会被调用。
/// 如果滚动偏移被修正(返回 false),
/// 它可能会被多次调用。
/// 如果调用了 [applyViewportDimension],
/// 那么这个方法总是在它之后调用。

bool applyContentDimensions(double minScrollExtent, double maxScrollExtent);

applyContentDimensions布局阶段第二步(第一步是 applyViewportDimension),作用是告诉 ViewportOffset

“你可以滚动的范围是从 minScrollExtentmaxScrollExtent。”

在 Flutter 中:

  1. RenderViewport 在布局过程中,先确定自己的可见尺寸(调用 applyViewportDimension)。

  2. 然后,根据所有子 Sliver 的布局结果,算出可滚动的总内容范围(min / max)。

  3. 调用 applyContentDimensions(min, max) 把范围传给 ViewportOffset(一般是 ScrollPosition)。

  4. ViewportOffset 检查当前滚动偏移 pixels 是否还在范围内。

    • 如果超出范围,就修正 pixels,返回 false(要求重新布局)。
    • 如果范围没变或不影响 pixels,返回 true(直接用新范围)。

举例

假设:

  • 总内容高度 = 100px
  • 视口高度 = 80px
  • 当前滚动位置 = 90px

滚动范围应该是:

ini
复制编辑
minScrollExtent = 0
maxScrollExtent = 100 - 80 = 20

如果当前 pixels = 90,显然超过最大值 20,那么 applyContentDimensions 会:

  • pixels 修正为 20
  • 返回 false → 触发重新布局

性能影响

  • 返回 true → 直接进入下一阶段,性能好
  • 返回 false → 会再次跑一遍布局 → 性能差(尤其是 shrink-wrap 情况可能会多次触发)

applyViewportDimension 的区别

方法触发时机作用返回值含义
applyViewportDimension先调用告诉 ViewportOffset 视口尺寸是否无条件接受新尺寸
applyContentDimensions后调用告诉 ViewportOffset 内容的最小/最大滚动范围

jumpTo

/// 让当前的滚动位置 [pixels] 直接跳到指定值。
/// 
/// - 不会有动画效果
/// - 不会检查新值是否在可滚动范围内
///
/// 相关方法:
///
///  * [correctBy]:在布局过程中改变当前滚动偏移,
///   并且推迟通知监听器直到布局完成。

void jumpTo(double pixels);

jumpTo(double pixels) 会立刻把 ViewportOffset(通常是 ScrollPosition)的滚动位置改成指定值,不做任何过渡动画,也不会像正常滚动那样先检查是否超出范围。

⚠️ 这意味着:

  • 如果传入的值小于 minScrollExtent 或大于 maxScrollExtent,滚动位置会变成一个“非法”值,可能导致视图显示异常(比如出现空白区域)。
  • 一般只在你确定目标偏移是合法的、并且不需要动画时使用。

animateTo

/// 以动画的方式将 [pixels] 从当前值变化到指定值。
///
/// 返回的 [Future] 会在动画结束时完成,不管是正常结束,
/// 还是在中途被打断。
///
/// 参数 [duration] 不能为零。如果想直接跳到某个值而不使用动画,
/// 请使用 [jumpTo]。
Future<void> animateTo(double to, {required Duration duration, required Curve curve});


Future<void> animateTo(double to, {required Duration duration, required Curve curve});

animateTo 会:

  1. 获取当前滚动位置pixels)。

  2. 创建一个滚动动画,从当前值缓动到 to 指定的目标值。

  3. 在动画过程中

    • 每一帧会根据给定的曲线 curve(比如 Curves.easeOut)计算当前进度。
    • 调用 jumpTo 或等价的内部方法更新 pixels
    • 通知 ScrollController 的监听器,让 UI 重绘。
  4. 动画结束时

    • 返回的 Future 会完成。
    • 如果在动画过程中用户手动滚动,动画会提前终止,Future 也会立即完成。

moveTo

/// 如果 [duration] 为 null 或 [Duration.zero],则调用 [jumpTo];
/// 否则调用 [animateTo]。
///
/// 如果调用的是 [animateTo],那么 [curve] 默认为 [Curves.ease]。
/// 参数 [clamp] 在当前这个基类实现中会被忽略,
/// 但在子类(例如 [ScrollPosition])中会被用来调整 [to] 的值,
/// 防止发生超滚动(overscroll)或反向超滚动(underscroll)。
Future<void> moveTo(double to, {Duration? duration, Curve? curve, bool? clamp}) {
  // 如果不需要动画(duration 是 null 或 0)
  if (duration == null || duration == Duration.zero) {
    jumpTo(to); // 直接跳到指定位置
    return Future<void>.value(); // 返回一个立即完成的 Future
  } else {
    // 否则,执行带动画的滚动
    return animateTo(
      to,
      duration: duration, // 动画持续时间
      curve: curve ?? Curves.ease, // 没传 curve 就用默认缓动曲线
    );
  }
}

执行逻辑

  1. 判断是否需要动画

    • 如果 durationnullDuration.zero(0 毫秒) → 直接调用 jumpTo
    • 否则 → 调用 animateTo 进行平滑滚动。
  2. 处理 curve

    • 如果没传 curve → 默认使用 Curves.ease(先快后慢)。
  3. 处理 clamp

    • 在这个类(抽象或基类)的实现中完全忽略 clamp
    • 但子类(如 ScrollPosition)会利用 clamp 参数来调整 to 的值,防止目标滚动位置超出允许范围。

参考资料

玩转Flutter滑动机制