Flutter Viewport 實作拖曳邏輯,用於顯示子集的 children。 Viewport 只能放 Sliver。這篇大略描述重點邏輯。

Viewport 有用到 system information tree。

需要留意的兩個方法:

  • markNeedsBuild 將 element 標記成 dirty
  • markNeedsLayout 將 render object 標記成 dirty

Viewport

Viewport 是一個 Stateless Widget,負責對一條雙向的 sliver 列表,可以上下,或是左右滾動,決定要不要將 sliver 內容顯示在螢幕上。坐標系改成為 slivers 使用的 scroll offset,而不是原本的 Cartesian 座標系統。

/// 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.

整理類圖如下:

classDiagram

class MultiChildRenderObjectWidget
class Viewport
class _ViewportElement
class RenderViewport
class ViewportOffset

MultiChildRenderObjectWidget <|-- Viewport
Viewport --> "建立" _ViewportElement
_ViewportElement --> "建立" RenderViewport
RenderViewport --> "使用" ViewportOffset

ViewportOffset

ViewportOffset 是一個抽象類別,定義 Viewport Offset 變動時,要發送事件通知。該類別繼承 ChangeNotifier

classDiagram

class ViewportOffset
<<abstract>> ViewportOffset

ChangeNotifier <|-- ViewportOffset

重要的屬性是 ViewportOffset.pixels

_ViewportElement

_ViewportElement 處理 ViewportOffset 的變動,判斷要不要產生新的 RenderViewport

整理類圖如下:

classDiagram

MultiChildRenderObjectElement <|-- _ViewportElement
NotifiableElementMixin <|-- "mixin" _ViewportElement
ViewportElementMixin <|-- "mixin" _ViewportElement
NotifiableElementMixin --> _NotificationNode
NotifiableElementMixin --> Notification

NotifiableElementMixin

NotifiableElementMixin 對 element 增加 notifiable 的特性,讓他可以收到 notification,加這個是為了讓 ViewportElementMixin 可以被加進來。

/// Mixin this class to allow receiving [Notification] objects dispatched by
/// child elements.
///
/// See also:
///   * [NotificationListener], for a widget that allows consuming notifications.
mixin NotifiableElementMixin on Element {
  /// Called when a notification of the appropriate type arrives at this
  /// location in the tree.
  ///
  /// Return true to cancel the notification bubbling. Return false to
  /// allow the notification to continue to be dispatched to further ancestors.
  bool onNotification(Notification notification);

  @override
  void attachNotificationTree() {
    _notificationTree = _NotificationNode(_parent?._notificationTree, this);
  }
}

ViewportElementMixin

ViewportElementMixinScrollNotificationOverscrollIndicatorNotification 反應,以及對 notification 的 depth 做 tweak 。通常是聽 ViewportOffset 值得變動, ViewportOffset 是一個 ChangerNotifier 。 這個 mixin 必須跟 NotifiableElementMixin 一起使用。

classDiagram

class ViewportElementMixin
<<mixin>> ViewportElementMixin
class NotifiableElementMixin
<<mixin>> NotifiableElementMixin
class Notification

Notification <|-- ScrollNotification
Notification <|-- OverscrollIndicatorNotification

ViewportElementMixin --> "on" NotifiableElementMixin
ViewportElementMixin --> Notification
/// Mixin for [Notification]s that track how many [RenderAbstractViewport] they
/// have bubbled through.
///
/// This is used by [ScrollNotification] and [OverscrollIndicatorNotification].
mixin ViewportElementMixin  on NotifiableElementMixin {
  @override
  bool onNotification(Notification notification) {
    if (notification is ViewportNotificationMixin) {
      notification._depth += 1;
    }
    return false;
  }
}

RenderAbstractViewport.attach/dispatch 有建立 addListender, 聽到 ViewportOffset 變動,便會執行 RenderAbstractViewport.markNeedsLayout

  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
    _offset.addListener(markNeedsLayout);
  }

  @override
  void detach() {
    _offset.removeListener(markNeedsLayout);
    super.detach();
  }

RenderViewport

RenderViewport 處理真正的 layout,坐標系改成為 slivers 使用的 scroll offset,而不是原本的 Cartesian 座標系統。 通常 override performLayout layoutChildSequence 。Center 如果沒給,那第一個 child 就是 center。

/// A base class for render objects that are bigger on the inside.
///
/// This render object provides the shared code for render objects that host
/// [RenderSliver] render objects inside a [RenderBox]. The viewport establishes
/// an [axisDirection], which orients the sliver's coordinate system, which is
/// based on scroll offsets rather than Cartesian coordinates.
///
/// The viewport also listens to an [offset], which determines the
/// [SliverConstraints.scrollOffset] input to the sliver layout protocol.
///
/// Subclasses typically override [performLayout] and call
/// [layoutChildSequence], perhaps multiple times.
classDiagram

class RenderViewport
class RenderAbstractViewport
<<interface>> RenderAbstractViewport

RenderAbstractViewport <|-- "實作" RenderViewport
RenderViewportBase <|-- RenderViewport
RenderViewportBase --> "使用" SliverPhysicalContainerParentData

performLayout

offset 加上 size.heightsize.width,計算出絕對位置.

	// 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;
    }

ViewportOffset.applyViewportDimension 的作用見註解如下。

  /// Called when the viewport's extents are established.
  ///
  /// The argument is the dimension of the [RenderViewport] in the main axis
  /// (e.g. the height, for a vertical viewport).
  ///
  /// This may be called redundantly, with the same value, each frame. This is
  /// called during layout for the [RenderViewport]. If the viewport is
  /// configured to shrink-wrap its contents, it may be called several times,
  /// since the layout is repeated each time the scroll offset is corrected.
  ///
  /// If this is called, it is called before [applyContentDimensions]. If this
  /// is called, [applyContentDimensions] will be called soon afterwards in the
  /// same layout phase. If the viewport is not configured to shrink-wrap its
  /// contents, then this will only be called when the viewport recomputes its
  /// size (i.e. when its parent lays out), and not during normal scrolling.
  ///
  /// If applying the viewport dimensions changes the scroll offset, return
  /// false. Otherwise, return true. If you return false, the [RenderViewport]
  /// will be laid out again with the new scroll offset. This is expensive. (The
  /// return value is answering the question "did you accept these viewport
  /// dimensions unconditionally?"; if the new dimensions change the
  /// [ViewportOffset]'s actual [pixels] value, then the viewport will need to
  /// be laid out again.)

計算 mainAxisExtentcrossAxisExtent, 如果軸是垂直,則 mainAxisExtent 是 viewport 的高, crossAxisExtent 是寬,反之, mainAxisExtent 是寬, crossAxisExtent 是高。

_attemptLaytout 做真正的 layout,並對 offset 做 correction。由於可能會無窮迴圈,所以會對這動作限制,超出限制則 throw Error。

    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);

_attemptLayout 實際使用 layoutChildSequence 去做 佈局

RenderAbstractViewport

RenderAbstractViewport 是一個介面,定義用來控制 Viewport 怎麼依據ViewportOffset 讓 framework 來顯示內部內容,並且不需要知道有幾種 Viewport

/// An interface for render objects that are bigger on the inside.
///
/// Some render objects, such as [RenderViewport], present a portion of their
/// content, which can be controlled by a [ViewportOffset]. This interface lets
/// the framework recognize such render objects and interact with them without
/// having specific knowledge of all the various types of viewports.
classDiagram

class RenderAbstractViewport {
	of()
	defaultCacheExtent
	getOffsetToReveal()
}
<<interface>> RenderAbstractViewport

RenderObject <|-- RenderAbstractViewport
RenderAbstractViewport --> RevealedOffset

這個介面要求必須實作 getOffsetToReveal, 其型別如下:

  RevealedOffset getOffsetToReveal(RenderObject target, double alignment, { Rect? rect });

用途是給定一個 RenderObject,取得能夠顯示他的 ViewportOffset ,並且可以選擇指定一個 bound,而非使用 RenderObject.paintBound 當作計算範圍。這個方法假設 Viewport 裡面的內容是以線性移動,也就是説 Viewport 的 offset 移動了 x ,那 target 也跟著 Viewport 移動 x 。

這方法會被 RenderViewportBase.showInViewport 用到。

RenderViewportBase

classDiagram
class RenderViewportBase

RenderAbstractViewport <|-- RenderViewportBase
RenderViewportBase -->  Clip
  • cacheExtent :: 暫存空間,在此區的 child 會被 layout,但不會顯示在螢幕上。 "The cacheExtent describes how many pixels the cache area extends before the leading edge and after the trailing edge of the viewport. "
  • totalExtent :: The total extent, which the viewport will try to cover with children, is cacheExtent before the leading edge + extent of the main axis + cacheExtent after the trailing edge.

computeMinIntrinsicWidthcomputeMaxIntrinsicWidth 會回傳 0.0,因為 Viewport 通常沒有 occupied size。如果 leadingNegativeChild 不存在則處理意外,然後進行正確的行動

performLayout

    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: clampDouble(mainAxisExtent - centerOffset, -_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: clampDouble(centerOffset, -_calculatedCacheExtent!, 0.0),
    );

layoutChildSequence

layoutChildSequence 計算 viewport children 的 size and position,是子類別的 peformLayout 主要的 layout 邏輯所在。

我們先看註解:

  /// Determines the size and position of some of the children of the viewport.
  ///
  /// This function is the workhorse of `performLayout` implementations in
  /// subclasses.
  ///
  /// Layout starts with `child`, proceeds according to the `advance` callback,
  /// and stops once `advance` returns null.
  ///
  ///  * `scrollOffset` is the [SliverConstraints.scrollOffset] to pass the
  ///    first child. The scroll offset is adjusted by
  ///    [SliverGeometry.scrollExtent] for subsequent children.
  ///  * `overlap` is the [SliverConstraints.overlap] to pass the first child.
  ///    The overlay is adjusted by the [SliverGeometry.paintOrigin] and
  ///    [SliverGeometry.paintExtent] for subsequent children.
  ///  * `layoutOffset` is the layout offset at which to place the first child.
  ///    The layout offset is updated by the [SliverGeometry.layoutExtent] for
  ///    subsequent children.
  ///  * `remainingPaintExtent` is [SliverConstraints.remainingPaintExtent] to
  ///    pass the first child. The remaining paint extent is updated by the
  ///    [SliverGeometry.layoutExtent] for subsequent children.
  ///  * `mainAxisExtent` is the [SliverConstraints.viewportMainAxisExtent] to
  ///    pass to each child.
  ///  * `crossAxisExtent` is the [SliverConstraints.crossAxisExtent] to pass to
  ///    each child.
  ///  * `growthDirection` is the [SliverConstraints.growthDirection] to pass to
  ///    each child.
  ///
  /// Returns the first non-zero [SliverGeometry.scrollOffsetCorrection]
  /// encountered, if any. Otherwise returns 0.0. Typical callers will call this
  /// function repeatedly until it returns 0.0.
  • scrollOffset is the SliverConstraints.scrollOffset to pass the first child. The scroll offset is adjusted by SliverGeometry.scrollExtent for subsequent children.
  • overlap is the SliverConstraints.overlap to pass the first child. The overlay is adjusted by the SliverGeometry.paintOrigin and SliverGeometry.paintExtent for subsequent children.
  • layoutOffset is the layout offset at which to place the first child. The layout offset is updated by the SliverGeometry.layoutExtent for subsequent children.
  • remainingPaintExtent is SliverConstraints.remainingPaintExtent to pass the first child. The remaining paint extent is updated by the SliverGeometry.layoutExtent for subsequent children.
  • mainAxisExtent is the SliverConstraints.viewportMainAxisExtent to pass to each child.
  • crossAxisExtent is the SliverConstraints.crossAxisExtent to pass to each child.
  • growthDirection is the SliverConstraints.growthDirection to pass to each child.

主要的 Layout 邏輯:

while(...) {
      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);


   ...

   updateOutOfBandData(growthDirection, childLayoutGeometry);

   ...

   return 0.0
}

邏輯整理如下

  1. 對 child 使用 SliverConstraints 做 layout
  2. 將 child 的 layout offset 儲存在 dParentData
  3. 更新 viewport 的參數,例如 max paint offset
  4. 更新 out of bond data

其中 maxPaintOffset 跟變動,使得下一個 child 的 sliver constraints 可以得到正確的數值。 advance 用來拿下一個 child 的 callback function.

paint()

有 overflow 用 Clip 剪裁,否則用 paintContext 畫出。

paintContext()

將 layout phase 計算好的 child, 判斷是否要顯示,畫在螢幕上。

  void _paintContents(PaintingContext context, Offset offset) {
    for (final RenderSliver child in childrenInPaintOrder) {
      if (child.geometry!.visible) {
        context.paintChild(child, offset + paintOffsetOf(child));
      }
    }
  }

需要注意的地方是 child 的 paint offset 要計算。

paintOffsetOf()

這是個抽象方法,實作由 RenderViewport.paintOffsetOf 決定。這方法算一個語法糖,只是單純把 child 的 parentData 中儲存的 paintOffset 拿出來。paintOffsetRenderViewportBase.layoutChildSequence 中透過 RenderViewportBase.updateChildLayoutOffset 設定。

  @override
  Offset paintOffsetOf(RenderSliver child) {
    final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
    return childParentData.paintOffset;
  }