「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」。

前言

前面也提到过ViewPort ,跟ScrollPosition配合实现控制展示区域的作用,现在就来细看一下这部分是怎么实现的;

还是先看注释

/// A widget that is bigger on the inside.
///
/// [Viewport] is the visual workhorse of the scrolling machinery. It displays a
/// subset of its children according to its own dimensions and the given
/// [offset]. As the offset varies, different children are visible through
/// the viewport.
///
/// [Viewport] hosts a bidirectional list of slivers, anchored on a [center]
/// sliver, which is placed at the zero scroll offset. The center widget is
/// displayed in the viewport according to the [anchor] property.
///
/// Slivers that are earlier in the child list than [center] are displayed in
/// reverse order in the reverse [axisDirection] starting from the [center]. For
/// example, if the [axisDirection] is [AxisDirection.down], the first sliver
/// before [center] is placed above the [center]. The slivers that are later in
/// the child list than [center] are placed in order in the [axisDirection]. For
/// example, in the preceding scenario, the first sliver after [center] is
/// placed below the [center].
///
/// [Viewport] cannot contain box children directly. Instead, use a
/// [SliverList], [SliverFixedExtentList], [SliverGrid], or a
/// [SliverToBoxAdapter], for example.

从注释中可以得知:

ViewPort 是一个控制滚动类型Widget 显示范围的控件;它不支持直接包含控件,而是通过使用SliverList这种带Sliver的控件来实现控制;

好像这回没说明大概的工作原理啊~这下要完全靠自己了……

ViewPort的组成

首先ViewPort是个纯纯的Widget,甚至其代码去掉成员变量、构造器、调试方法后,也就这些:

@override
RenderViewport createRenderObject(BuildContext context) {
  return RenderViewport(
    axisDirection: axisDirection,
    crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection),
    anchor: anchor,
    offset: offset,
    cacheExtent: cacheExtent,
    cacheExtentStyle: cacheExtentStyle,
    clipBehavior: clipBehavior,
  );
}
@override
void updateRenderObject(BuildContext context, RenderViewport renderObject) {
  renderObject
    ..axisDirection = axisDirection
    ..crossAxisDirection = crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection)
    ..anchor = anchor
    ..offset = offset
    ..cacheExtent = cacheExtent
    ..cacheExtentStyle = cacheExtentStyle
    ..clipBehavior = clipBehavior;
}
@override
_ViewportElement createElement() => _ViewportElement(this);

这是从这个系列文章开始,第一个涉及到Element和RenderObject 的Widget~~

那么很明显,核心逻辑并不放在这里,先从Element,也就是_ViewportElement开始:

_ViewportElement

_ViewportElement 本身也没什么特别的,很明显,只是单纯更新RenderObject;

class _ViewportElement extends MultiChildRenderObjectElement {
  /// Creates an element that uses the given widget as its configuration.
  _ViewportElement(Viewport widget) : super(widget);
  @override
  Viewport get widget => super.widget as Viewport;
  @override
  RenderViewport get renderObject => super.renderObject as RenderViewport;
  @override
  void mount(Element? parent, Object? newSlot) {
    super.mount(parent, newSlot);
    _updateCenter();
  }
  @override
  void update(MultiChildRenderObjectWidget newWidget) {
    super.update(newWidget);
    _updateCenter();
  }
  void _updateCenter() {
    // TODO(ianh): cache the keys to make this faster
    if (widget.center != null) {
      renderObject.center = children.singleWhere(
        (Element element) => element.widget.key == widget.center,
      ).renderObject as RenderSliver?;
    } else if (children.isNotEmpty) {
      renderObject.center = children.first.renderObject as RenderSliver?;
    } else {
      renderObject.center = null;
    }
  }
  @override
  void debugVisitOnstageChildren(ElementVisitor visitor) {
    children.where((Element e) {
      final RenderSliver renderSliver = e.renderObject! as RenderSliver;
      return renderSliver.geometry!.visible;
    }).forEach(visitor);
  }
}

其核心逻辑也就一个_updateCenter方法,将更新后的RenderObject的属性更新,仅此而已;

RenderViewport

那么很明显,RenderViewPort才是核心逻辑所在:

首先解决一下之前提到的一个问题:

至于ViewPort中是怎么绑定了ScrollPosition,这些Position为什么有更新,就能更新界面,ViewPortScrollPosition的关系是什么?

ScrollPosition是如何绑定给ViewPort

首先,需要看下ScrollPosition 跟 ViewPort 的关系:

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

ScrollPosition 其实是继承自 ViewPortOffset,知道这点后,来到当初构造ViewPort的地方,也就是Scrollable的build方法那里:

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

在这里,将Scrollable本身持有的position传递给了ViewPort的构造器中,并最终以offset的名字传递给ViewPort

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

这样第一个问题解决了,在构造ViewPort的开始,就将ScrollPosition以ViewPortOffset的身份传递给ViewPort;

当然最后还是传给了RenderObject层;也就是RenderViewPort;

为什么ScrollPosition 的更新,会更新界面

还记得曾经说过的ScrollPosition的本质么?

ScrollPosition 本质同样还是一个Listenable

而且在 RenderViewPort 的attach 和 detach 方法中,有这么一句代码:

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

所有很明显,当offset 触发 notifyListener方法的时候,会调用ViewPort 的markNeedsLayout方法,进而触发layout方法;

那么来到ScrollPosition的notifyListener方法,看下具体调用位置:

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

看来基本关注到setPixels方法就行了;另一个是用于类似于jump方法的;

而setPixels方法的调用位置,是在下面这个方法:

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

而applyUserOffset方法,是在ScrollActivity中这么被调用的:

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

既然提到了ScroollActivity,那么当初分析 Scrollable 的时候,顺便分析了一下ScrollPosition是如何改变值的;

回到Scrollable,就能发现里面有需要的update方法:

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

成功跟之前的部分对接成功~~~~

ok,总结一下,ViewPort 触发更新的流程是

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

ViewPort 为什么要更新界面,都做了什么?

根据layout方法的说明,子类应该关注于performLayout方法,那么就来看下ViewPort 的performLayout方法都干了什么:

@override
void performLayout() {
  // Ignore the return value of applyViewportDimension because we are
  // doing a layout regardless.
  switch (axis) {
    case Axis.vertical:
      offset.applyViewportDimension(size.height);
      break;
    case Axis.horizontal:
      offset.applyViewportDimension(size.width);
      break;
  }
  if (center == null) {
    assert(firstChild == null);
    _minScrollExtent = 0.0;
    _maxScrollExtent = 0.0;
    _hasVisualOverflow = false;
    offset.applyContentDimensions(0.0, 0.0);
    return;
  }
  assert(center!.parent == this);
  final double mainAxisExtent;
  final double crossAxisExtent;
  switch (axis) {
    case Axis.vertical:
      mainAxisExtent = size.height;
      crossAxisExtent = size.width;
      break;
    case Axis.horizontal:
      mainAxisExtent = size.width;
      crossAxisExtent = size.height;
      break;
  }
  final double centerOffsetAdjustment = center!.centerOffsetAdjustment;
  double correction;
  int count = 0;
  do {
    assert(offset.pixels != null);
    correction = _attemptLayout(mainAxisExtent, crossAxisExtent, offset.pixels + centerOffsetAdjustment);
    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);
  assert(() {
    if (count >= _maxLayoutCycles) {
      assert(count != 1);
      throw FlutterError(
        'A RenderViewport exceeded its maximum number of layout cycles.n'
        'RenderViewport render objects, during layout, can retry if either their '
        'slivers or their ViewportOffset decide that the offset should be corrected '
        'to take into account information collected during that layout.n'
        'In the case of this RenderViewport object, however, this happened $count '
        'times and still there was no consensus on the scroll offset. This usually '
        'indicates a bug. Specifically, it means that one of the following three '
        'problems is being experienced by the RenderViewport object:n'
        ' * One of the RenderSliver children or the ViewportOffset have a bug such'
        ' that they always think that they need to correct the offset regardless.n'
        ' * Some combination of the RenderSliver children and the ViewportOffset'
        ' have a bad interaction such that one applies a correction then another'
        ' applies a reverse correction, leading to an infinite loop of corrections.n'
        ' * There is a pathological case that would eventually resolve, but it is'
        ' so complicated that it cannot be resolved in any reasonable number of'
        ' layout passes.',
      );
    }
    return true;
  }());
}
  • 首先第一步是确保ViewPort的范围大小跟ScrollPosition中保存的范围是一致的;

  • 接下来判断一个名为 center 的成员变量是否为空,为空的话,则将所有数据初始化,并标记不绘制;而这个 center 的成员变量,默认为firstChild,也就是第一个子View(根据Widget树的话,应该是SliverPadding?)当然如果是只分析ListView的话,在这种情况下,应该不会没事被置空~

  • 接下来才是重点:

    • 首先规定了主轴和交叉轴的范围;
    • 循环10次,调用 _attemptLayout 方法,
      • 如果返回结果不为0,修正Position中保存的数据;
      • 如果返回结果为0,那么中断此次循环,将最终滚动范围大小传递给Position保存;

第二步中的 _attemptLayout 都干了哪些呢?

double _attemptLayout(double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
  assert(!mainAxisExtent.isNaN);
  assert(mainAxisExtent >= 0.0);
  assert(crossAxisExtent.isFinite);
  assert(crossAxisExtent >= 0.0);
  assert(correctedOffset.isFinite);
  _minScrollExtent = 0.0;
  _maxScrollExtent = 0.0;
  _hasVisualOverflow = false;
  // centerOffset is the offset from the leading edge of the RenderViewport
  // to the zero scroll offset (the line between the forward slivers and the
  // reverse slivers).
  final double centerOffset = mainAxisExtent * anchor - correctedOffset;
  final double reverseDirectionRemainingPaintExtent = centerOffset.clamp(0.0, mainAxisExtent);
  final double forwardDirectionRemainingPaintExtent = (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
  switch (cacheExtentStyle) {
    case CacheExtentStyle.pixel:
      _calculatedCacheExtent = cacheExtent;
      break;
    case CacheExtentStyle.viewport:
      _calculatedCacheExtent = mainAxisExtent * _cacheExtent;
      break;
  }
  final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
  final double centerCacheOffset = centerOffset + _calculatedCacheExtent!;
  final double reverseDirectionRemainingCacheExtent = centerCacheOffset.clamp(0.0, fullCacheExtent);
  final double forwardDirectionRemainingCacheExtent = (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
  final RenderSliver? leadingNegativeChild = childBefore(center!);
  if (leadingNegativeChild != null) {
    // negative scroll offsets
    final double result = layoutChildSequence(
      child: leadingNegativeChild,
      scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
      overlap: 0.0,
      layoutOffset: forwardDirectionRemainingPaintExtent,
      remainingPaintExtent: reverseDirectionRemainingPaintExtent,
      mainAxisExtent: mainAxisExtent,
      crossAxisExtent: crossAxisExtent,
      growthDirection: GrowthDirection.reverse,
      advance: childBefore,
      remainingCacheExtent: reverseDirectionRemainingCacheExtent,
      cacheOrigin: (mainAxisExtent - centerOffset).clamp(-_calculatedCacheExtent!, 0.0),
    );
    if (result != 0.0)
      return -result;
  }
  // positive scroll offsets
  return layoutChildSequence(
    child: center,
    scrollOffset: math.max(0.0, -centerOffset),
    overlap: leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
    layoutOffset: centerOffset >= mainAxisExtent ? centerOffset: reverseDirectionRemainingPaintExtent,
    remainingPaintExtent: forwardDirectionRemainingPaintExtent,
    mainAxisExtent: mainAxisExtent,
    crossAxisExtent: crossAxisExtent,
    growthDirection: GrowthDirection.forward,
    advance: childAfter,
    remainingCacheExtent: forwardDirectionRemainingCacheExtent,
    cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
  );
}

虽说代码挺长的,但是其实大部分都是计算一些初始化数值,比如说中间那大部分都是计算各种类型下的cacheExtent,最后核心的部分就仅仅调用了一个layoutChildSequence 方法而已;

当然,也因为ListView的ViewPort,只有一个child,自然childBefore方法返回的为null,直接走 return layoutChildSequence;

layoutChildSequence 方法所做内容是这样的:

@protected
double layoutChildSequence({
  required RenderSliver? child,
  required double scrollOffset,
  required double overlap,
  required double layoutOffset,
  required double remainingPaintExtent,
  required double mainAxisExtent,
  required double crossAxisExtent,
  required GrowthDirection growthDirection,
  required RenderSliver? Function(RenderSliver child) advance,
  required double remainingCacheExtent,
  required double cacheOrigin,
}) {
  assert(scrollOffset.isFinite);
  assert(scrollOffset >= 0.0);
  final double initialLayoutOffset = layoutOffset;
  final ScrollDirection adjustedUserScrollDirection =
      applyGrowthDirectionToScrollDirection(offset.userScrollDirection, growthDirection);
  assert(adjustedUserScrollDirection != null);
  double maxPaintOffset = layoutOffset + overlap;
  double precedingScrollExtent = 0.0;
  while (child != null) {
    final double sliverScrollOffset = scrollOffset <= 0.0 ? 0.0 : scrollOffset;
    // If the scrollOffset is too small we adjust the paddedOrigin because it
    // doesn't make sense to ask a sliver for content before its scroll
    // offset.
    final double correctedCacheOrigin = math.max(cacheOrigin, -sliverScrollOffset);
    final double cacheExtentCorrection = cacheOrigin - correctedCacheOrigin;
    assert(sliverScrollOffset >= correctedCacheOrigin.abs());
    assert(correctedCacheOrigin <= 0.0);
    assert(sliverScrollOffset >= 0.0);
    assert(cacheExtentCorrection <= 0.0);
    child.layout(SliverConstraints(
      axisDirection: axisDirection,
      growthDirection: growthDirection,
      userScrollDirection: adjustedUserScrollDirection,
      scrollOffset: sliverScrollOffset,
      precedingScrollExtent: precedingScrollExtent,
      overlap: maxPaintOffset - layoutOffset,
      remainingPaintExtent: math.max(0.0, remainingPaintExtent - layoutOffset + initialLayoutOffset),
      crossAxisExtent: crossAxisExtent,
      crossAxisDirection: crossAxisDirection,
      viewportMainAxisExtent: mainAxisExtent,
      remainingCacheExtent: math.max(0.0, remainingCacheExtent + cacheExtentCorrection),
      cacheOrigin: correctedCacheOrigin,
    ), parentUsesSize: true);
    final SliverGeometry childLayoutGeometry = child.geometry!;
    assert(childLayoutGeometry.debugAssertIsValid());
    // If there is a correction to apply, we'll have to start over.
    if (childLayoutGeometry.scrollOffsetCorrection != null)
      return childLayoutGeometry.scrollOffsetCorrection!;
    // We use the child's paint origin in our coordinate system as the
    // layoutOffset we store in the child's parent data.
    final double effectiveLayoutOffset = layoutOffset + childLayoutGeometry.paintOrigin;
    // `effectiveLayoutOffset` becomes meaningless once we moved past the trailing edge
    // because `childLayoutGeometry.layoutExtent` is zero. Using the still increasing
    // 'scrollOffset` to roughly position these invisible slivers in the right order.
    if (childLayoutGeometry.visible || scrollOffset > 0) {
      updateChildLayoutOffset(child, effectiveLayoutOffset, growthDirection);
    } else {
      updateChildLayoutOffset(child, -scrollOffset + initialLayoutOffset, growthDirection);
    }
    maxPaintOffset = math.max(effectiveLayoutOffset + childLayoutGeometry.paintExtent, maxPaintOffset);
    scrollOffset -= childLayoutGeometry.scrollExtent;
    precedingScrollExtent += childLayoutGeometry.scrollExtent;
    layoutOffset += childLayoutGeometry.layoutExtent;
    if (childLayoutGeometry.cacheExtent != 0.0) {
      remainingCacheExtent -= childLayoutGeometry.cacheExtent - cacheExtentCorrection;
      cacheOrigin = math.min(correctedCacheOrigin + childLayoutGeometry.cacheExtent, 0.0);
    }
    updateOutOfBandData(growthDirection, childLayoutGeometry);
    // move on to the next child
    child = advance(child);
  }
  // we made it without a correction, whee!
  return 0.0;
}

这一大片代码,简单拆分一下,是都干了这些东西:

  • 首先还是一些数据的初始化;
  • 做一个do while 循环,直到child为空
    • 首先根据上面初始化的数据,构造SliverConstraints 并调用layout方法传入;
    • 因为Sliver在layout方法后,会生成SliverGeometry,首先判断一下geometry的返回值,是否有 scrollOffsetCorrection ,有的话表示需要修正数据,中断循环直接返回,最终触发ScrollPosition.correctBy 方法;
    • 计算一下child 实际绘制位置(paintExtent是跟父View的相对位置,所以要加上滑动距离);并最终设置到ParentData中保存
    • 根据计算结果,最终确定最大滑动范围,并保存起来。
    • 获取下一个child;

因为调用了child 的 layou方法,自然也会触发 child 的重新绘制,又因为滑动距离等信息,通过构造的SliverConstraints 传递了下去,自然 child 也会根据这些信息更新界面,不过那就是后话了~

自此,ViewPort 的基本流程也分析完成;

总结

总结一下,整体流程图简单描述的话,差不多是这样的:

【Flutter】熊孩子拆组件系列之拆ListView(五)—— ViewPort

下面就是要看一下,涉及到具体内容的SliverList部分了