/// 一个组件,通过它可以查看更大内容的一部分,通常与 [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);
- 这行代码表示
Viewportwidget 会创建一个_ViewportElement作为其 Element。 Element是 Flutter 构建 UI 的核心,用于连接 widget 和渲染对象(RenderObject)。_ViewportElement是Viewport专用的 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 表示底部/右侧)。 -
cacheExtent与cacheExtentStyle:控制预渲染区域,优化滚动性能。 -
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和_maxScrollExtent与anchor计算
- min/max 通过
-
循环 → 当 offset 被修正时,可能需要重新布局,因此用
do-while循环 -
计数限制 → 防止无限循环
assert(() {
if (count >= maxLayoutCycles) {
assert(count != 1);
throw FlutterError(
'A RenderViewport exceeded its maximum number of layout cycles.\n'
...
);
}
return true;
}());
-
安全检查 → 如果循环次数超过上限:
- 通常说明 sliver 或 offset 修正逻辑存在问题
- 抛出
FlutterError提示开发者
-
这是防止布局逻辑进入无限循环的一种保护