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

語言: CN / TW / HK

theme: condensed-night-purple

「這是我參與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 的關係:

image.png

ScrollPosition 其實是繼承自 ViewPortOffset,知道這點後,來到當初構造ViewPort的地方,也就是Scrollable的build方法那裡:

image.png

在這裡,將Scrollable本身持有的position傳遞給了ViewPort的構造器中,並最終以offset的名字傳遞給ViewPort

image.png

image.png

這樣第一個問題解決了,在構造ViewPort的開始,就將ScrollPosition以ViewPortOffset的身份傳遞給ViewPort;

當然最後還是傳給了RenderObject層;也就是RenderViewPort;

為什麼ScrollPosition 的更新,會更新介面

還記得曾經說過的ScrollPosition的本質麼?

ScrollPosition 本質同樣還是一個 Listenable

而且在 RenderViewPort 的attach 和 detach 方法中,有這麼一句程式碼:

image.png

所有很明顯,當offset 觸發 notifyListener方法的時候,會呼叫ViewPort 的markNeedsLayout方法,進而觸發layout方法;

那麼來到ScrollPosition的notifyListener方法,看下具體呼叫位置:

image.png

image.png

看來基本關注到setPixels方法就行了;另一個是用於類似於jump方法的;

而setPixels方法的呼叫位置,是在下面這個方法:

image.png

而applyUserOffset方法,是在ScrollActivity中這麼被呼叫的:

image.png

既然提到了ScroollActivity,那麼當初分析 Scrollable 的時候,順便分析了一下ScrollPosition是如何改變值的;

回到Scrollable,就能發現裡面有需要的update方法:

image.png

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

ok,總結一下,ViewPort 觸發更新的流程是

image.png

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 的基本流程也分析完成;

總結

總結一下,整體流程圖簡單描述的話,差不多是這樣的:

流程圖.png

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

「其他文章」