Flutter Viewport Widget Class 源碼解析
Flutter Viewport
實作拖曳邏輯,用於顯示子集的 children。 Viewport
只能放 Sliver
。這篇大略描述重點邏輯。
Viewport
有用到 system information tree。
需要留意的兩個方法:
markNeedsBuild
將 element 標記成 dirtymarkNeedsLayout
將 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
ViewportElementMixin
對 ScrollNotification
跟 OverscrollIndicatorNotification
反應,以及對 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.height
或 size.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.)
計算 mainAxisExtent
跟 crossAxisExtent
, 如果軸是垂直,則 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.
computeMinIntrinsicWidth
跟 computeMaxIntrinsicWidth
會回傳 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 theSliverConstraints.scrollOffset
to pass the first child. The scroll offset is adjusted bySliverGeometry.scrollExtent
for subsequent children.overlap
is theSliverConstraints.overlap
to pass the first child. The overlay is adjusted by theSliverGeometry.paintOrigin
andSliverGeometry.paintExtent
for subsequent children.layoutOffset
is the layout offset at which to place the first child. The layout offset is updated by theSliverGeometry.layoutExtent
for subsequent children.remainingPaintExtent
isSliverConstraints.remainingPaintExtent
to pass the first child. The remaining paint extent is updated by theSliverGeometry.layoutExtent
for subsequent children.mainAxisExtent
is theSliverConstraints.viewportMainAxisExtent
to pass to each child.crossAxisExtent
is theSliverConstraints.crossAxisExtent
to pass to each child.growthDirection
is theSliverConstraints.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
}
邏輯整理如下
- 對 child 使用 SliverConstraints 做 layout
- 將 child 的 layout offset 儲存在 dParentData
- 更新 viewport 的參數,例如 max paint offset
- 更新 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
拿出來。paintOffset
在 RenderViewportBase.layoutChildSequence
中透過 RenderViewportBase.updateChildLayoutOffset
設定。
@override
Offset paintOffsetOf(RenderSliver child) {
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
return childParentData.paintOffset;
}