flutter滚动视图之Viewport、RenderViewport源码解析(六)

43 阅读7分钟
/// 一个组件,通过它可以查看更大内容的一部分,通常与 [Scrollable] 配合使用。
///
/// [Viewport] 是滚动机制中负责显示内容的核心组件。  
/// 它根据自身尺寸和给定的 [offset] 显示子组件的一个子集。  
/// 当偏移量(offset)变化时,不同的子组件会通过视口显示出来。
///
/// [Viewport] 托管一个双向的 sliver 列表,以 [center] sliver 为锚点,  
/// 该中心 sliver 放置在零滚动偏移位置。中心组件在视口中的显示位置  
/// 由 [anchor] 属性控制。
///
/// 在子组件列表中位于 [center] 之前的 sliver,会按照 [axisDirection] 的反方向  
/// 从中心开始反向显示。例如,如果 [axisDirection][AxisDirection.down],  
/// 那么位于中心之前的第一个 sliver 会显示在中心上方。  
/// 位于 [center] 之后的 sliver 则按照 [axisDirection] 顺序显示。例如在上面的场景中,  
/// 中心之后的第一个 sliver 会显示在中心下方。
///
/// [Viewport] 不能直接包含 box 类型子组件。  
/// 需要使用 [SliverList][SliverFixedExtentList][SliverGrid][SliverToBoxAdapter] 来放置 box 类型组件。
///
/// 另请参见:
///
///  * [ListView][PageView][GridView][CustomScrollView],它们将  
///    [Scrollable][Viewport] 组合成更易用的组件。  
///  * [SliverToBoxAdapter],允许将 box 类型组件放入 sliver 上下文(与本组件相反)。  
///  * [ShrinkWrappingViewport][Viewport] 的一种变体,沿主轴收缩以包裹内容。  
///  * [ViewportElementMixin],应混入视口类组件使用的 [Element] 类型,以正确处理滚动通知。

Viewport

class Viewport extends MultiChildRenderObjectWidget {
  /// 创建一个“内部更大”的组件。
  ///
  /// Viewport 会监听 [offset],这意味着当 [offset] 改变时,
  /// 不需要重新构建这个组件。
  ///
  /// 如果 [cacheExtentStyle] 不是 [CacheExtentStyle.pixel],
  /// 则必须指定 [cacheExtent]。
  Viewport({
    super.key,
    this.axisDirection = AxisDirection.down,  // 滚动方向,默认为向下
    this.crossAxisDirection,                  // 横轴方向,可选
    this.anchor = 0.0,                        // 视口锚点,范围 [0, 1]
    required this.offset,                      // 滚动偏移
    this.center,                               // 双向滚动中心 sliver
    this.cacheExtent,                          // 缓存区域大小
    this.cacheExtentStyle = CacheExtentStyle.pixel, // 缓存计算方式
    this.clipBehavior = Clip.hardEdge,         // 裁剪行为
    List<Widget> slivers = const <Widget>[],   // sliver 列表
  }) : assert(
         center == null || slivers.where((Widget child) => child.key == center).length == 1,
         // 如果指定了 center,则在 slivers 列表中必须唯一
       ),
       assert(
         cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null,
         // 如果缓存方式为 viewport,则必须指定 cacheExtent
       ),
       super(children: slivers);
}

createRenderObject

RenderViewport createRenderObject(BuildContext context) {
  return RenderViewport(
    axisDirection: axisDirection, // 滚动方向(上下或左右)
    crossAxisDirection:
        crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection),
        // 横轴方向,如果未指定则使用默认方向
    anchor: anchor,               // 视口锚点(0~1),决定内容在视口中的对齐位置
    offset: offset,               // 滚动偏移量,控制显示内容位置
    cacheExtent: cacheExtent,     // 预渲染缓存区域大小
    cacheExtentStyle: cacheExtentStyle, // 缓存计算方式
    clipBehavior: clipBehavior,   // 裁剪行为
  );
}

updateRenderObject

void updateRenderObject(BuildContext context, RenderViewport renderObject) {
  renderObject
    ..axisDirection = axisDirection
        // 更新滚动方向(上下或左右)
    ..crossAxisDirection =
        crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection)
        // 更新横向方向,如果未指定则使用默认方向
    ..anchor = anchor
        // 更新视口锚点(0~1),决定内容在视口中的对齐位置
    ..offset = offset
        // 更新滚动偏移量
    ..cacheExtent = cacheExtent
        // 更新预渲染缓存区域大小
    ..cacheExtentStyle = cacheExtentStyle
        // 更新缓存计算方式
    ..clipBehavior = clipBehavior;
        // 更新裁剪行为
}

createElement

MultiChildRenderObjectElement createElement() => _ViewportElement(this);

  • 这行代码表示 Viewport widget 会创建一个 _ViewportElement 作为其 Element。
  • Element 是 Flutter 构建 UI 的核心,用于连接 widget 和渲染对象(RenderObject)。
  • _ViewportElementViewport 专用的 Element 类型,用于管理子 sliver 的生命周期和渲染更新。
  • 使用 MultiChildRenderObjectElement 表明 Viewport 可以拥有多个子组件(slivers)。

简单理解就是:

Widget (Viewport) → Element (_ViewportElement) → RenderObject (RenderViewport)

RenderViewport

/// 一个“内部更大”的渲染对象。
///
/// [RenderViewport] 是滚动机制中负责可视化显示的核心组件。  
/// 它根据自身尺寸和给定的 [offset] 显示子组件的一个子集。  
/// 随着偏移量(offset)的变化,不同的子组件会通过视口显示出来。
///
/// [RenderViewport] 在单一的共享 [Axis] 上托管双向 sliver 列表,  
/// 以 [center] sliver 为锚点,该中心 sliver 放置在零滚动偏移位置。  
/// 中心组件在视口中的显示位置由 [anchor] 属性控制。
///
/// 位于 [center] 之前的 sliver 会按照 [axisDirection] 的反方向  
/// 从中心开始反向显示。例如,如果 [axisDirection] 为 [AxisDirection.down],  
/// 那么中心之前的第一个 sliver 会显示在中心上方。  
/// 位于 [center] 之后的 sliver 则按照 [axisDirection] 顺序显示。例如在上述场景中,  
/// 中心之后的第一个 sliver 会显示在中心下方。
///
/// {@macro flutter.rendering.GrowthDirection.sample}
///
/// [RenderViewport] 不能直接包含 [RenderBox] 子组件。  
/// 需要使用 [RenderSliverList]、[RenderSliverFixedExtentList]、[RenderSliverGrid]  
/// 或 [RenderSliverToBoxAdapter] 来放置 box 类型渲染对象。
///
/// 另请参见:
///
///  * [RenderSliver],详细介绍 Sliver 协议。  
///  * [RenderBox],详细介绍 Box 协议。  
///  * [RenderSliverToBoxAdapter],允许将 [RenderBox] 放入 [RenderSliver] 中(与本类相反)。  
///  * [RenderShrinkWrappingViewport],[RenderViewport] 的变体,沿主轴收缩包裹内容。

RenderViewport

class RenderViewport extends RenderViewportBase<SliverPhysicalContainerParentData> {
  /// 为 [RenderSliver] 对象创建一个视口。
  ///
  /// 如果未指定 [center],则使用 `children` 列表中的第一个子组件(如果有的话)。
  ///
  /// 必须指定 [offset]。在测试场景下,可以传入 [ViewportOffset.zero] 或 [ViewportOffset.fixed]。
  RenderViewport({
    super.axisDirection,                     // 滚动方向
    required super.crossAxisDirection,       // 横轴方向
    required super.offset,                   // 滚动偏移
    double anchor = 0.0,                     // 视口锚点,范围 [0,1]
    List<RenderSliver>? children,            // sliver 子组件列表
    RenderSliver? center,                    // 中心 sliver
    super.cacheExtent,                       // 缓存区域大小
    super.cacheExtentStyle,                  // 缓存计算方式
    super.clipBehavior,                      // 裁剪行为
  }) : assert(anchor >= 0.0 && anchor <= 1.0),
       assert(cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null),
       _anchor = anchor,
       _center = center {
    addAll(children);                        // 添加所有 sliver 子组件
    if (center == null && firstChild != null) {
      _center = firstChild;                 // 如果未指定中心,则第一个子组件作为中心
    }
  }
}

  • RenderViewport 是专门为 RenderSliver 设计的渲染视口。

  • center:双向滚动的锚点,如果未指定,默认使用第一个子组件。

  • offset:滚动偏移量,控制视口显示的内容位置。

  • anchor:决定内容在视口中的对齐方式(0 表示顶部/左侧,1 表示底部/右侧)。

  • cacheExtentcacheExtentStyle:控制预渲染区域,优化滚动性能。

  • addAll(children):将所有 sliver 子组件加入视口。

anchor

/// 在 [GrowthDirection.forward] 增长方向上的第一个子组件。
///
/// 这个子组件会被放置在由 [anchor] 定义的位置上,当 [ViewportOffset.pixels][offset] 为 `0` 时。
///
/// [center] 之后的子组件会沿着 [axisDirection] 放置,相对于 [center]。
///
/// [center] 之前的子组件会沿着与 [axisDirection] 相反的方向放置,相对于 [center]。
/// 这些位于 [center] 上方的子组件,其增长方向为 [GrowthDirection.reverse]。
///
/// [center] 必须是 viewport 的直接子组件。

RenderSliver? get center => _center;
RenderSliver? _center;
set center(RenderSliver? value) {
  if (value == _center) {
    return;
  }
  _center = value;
  markNeedsLayout();
}

markNeedsLayout

告诉 Flutter 渲染树:这个 RenderObject 的 布局(layout)数据已过期,需要重新计算。

具体流程:

将 _needsLayout = true,标记自己需要 layout

在下一个 frame 渲染阶段,Flutter 调用 RenderObject 的 performLayout() 方法

performLayout() 会:

重新计算子 sliver 的 位置 和 尺寸

根据 center、anchor、axisDirection 重新放置每个 sliver

更新 geometry 数据(如 scrollExtent、paintExtent)

布局完成后,如果必要会触发绘制阶段 paint(),把子组件渲染到屏幕

performLayout

RenderViewport 的核心布局方法 performLayout(),它控制 如何计算子 sliver 的布局、滚动范围以及滚动偏移修正

// 忽略 applyViewportDimension 的返回值,因为我们无论如何都要做布局。
switch (axis) {
  case Axis.vertical:
    offset.applyViewportDimension(size.height);
  case Axis.horizontal:
    offset.applyViewportDimension(size.width);
}

  • 作用:把 viewport 的尺寸告诉 ViewportOffset(通常是 ScrollPosition),这样 offset 可以计算最大/最小滚动范围。

  • size.height/width → viewport 的主轴尺寸。

if (center == null) {
  assert(firstChild == null);
  _minScrollExtent = 0.0;
  _maxScrollExtent = 0.0;
  _hasVisualOverflow = false;
  offset.applyContentDimensions(0.0, 0.0);
  return;
}

  • 当没有 center(即没有 sliver)时

    • 没有子组件要布局
    • 滚动范围都设为 0
    • 标记没有视觉溢出
    • 调用 applyContentDimensions 告诉 offset 内容尺寸
  • 然后直接返回,不再做后续布局

final (double mainAxisExtent, double crossAxisExtent) = switch (axis) {
  Axis.vertical => (size.height, size.width),
  Axis.horizontal => (size.width, size.height),
};

根据轴方向,计算主轴和交叉轴尺寸

主轴 → 滚动方向长度

交叉轴 → 垂直于滚动方向的长度

final double centerOffsetAdjustment = center!.centerOffsetAdjustment;
  • 获取 center偏移修正值

  • 用于微调 center 的显示位置,使它在滚动 offset=0 时准确对齐 anchor

final int maxLayoutCycles = _maxLayoutCyclesPerChild * childCount;
  • 布局循环上限

  • 为了避免布局过程中 sliver/offset 修正导致无限循环,限定最大循环次数

  • _maxLayoutCyclesPerChild 是常量,乘以子节点数

double correction;
int count = 0;
do {
  correction = _attemptLayout(
    mainAxisExtent,
    crossAxisExtent,
    offset.pixels + centerOffsetAdjustment,
  );
  • 调用 _attemptLayout() 对所有 sliver 进行一次布局尝试

  • 传入:

    • 主轴/交叉轴尺寸
    • 当前 scroll offset + center 修正值
  • 返回 correction → 如果 sliver 或 offset 发现偏移不对,需要修正,则返回非 0

  if (correction != 0.0) {
    offset.correctBy(correction);
  } else {
    if (offset.applyContentDimensions(
      math.min(0.0, _minScrollExtent + mainAxisExtent * anchor),
      math.max(0.0, _maxScrollExtent - mainAxisExtent * (1.0 - anchor)),
    )) {
      break;
    }
  }
  count += 1;
} while (count < maxLayoutCycles);

  • 如果需要修正 → 调用 offset.correctBy(correction) 调整 scroll position

  • 否则 → 调用 applyContentDimensions,告诉 offset 最小/最大滚动范围

    • min/max 通过 _minScrollExtent_maxScrollExtentanchor 计算
  • 循环 → 当 offset 被修正时,可能需要重新布局,因此用 do-while 循环

  • 计数限制 → 防止无限循环

assert(() {
  if (count >= maxLayoutCycles) {
    assert(count != 1);
    throw FlutterError(
      'A RenderViewport exceeded its maximum number of layout cycles.\n'
      ...
    );
  }
  return true;
}());

  • 安全检查 → 如果循环次数超过上限:

    1. 通常说明 sliver 或 offset 修正逻辑存在问题
    2. 抛出 FlutterError 提示开发者
  • 这是防止布局逻辑进入无限循环的一种保护