Flutter SliverGrid Widget Class 源碼解析
Flutter 中 SliverGrid
是一個通常與 CustomScroll
搭配使用的 Sliver
,作用是讓 children 以二維的方式佈局,並且可以動態載入 children。本文介紹大致邏輯。
SliverGrid
源碼定義在 ~lib/src/rendering/sliver.dart
。
首先我們看註解,最重要的參數是 gridDelegate
,型別是 SliverGridDelegate
。 SliverGridDelegate
是一個抽象類別。 SliverGrid
可以垂直 scroll 或是水平 scroll。
/// A sliver that places multiple box children in a two dimensional arrangement.
///
/// [SliverGrid] places its children in arbitrary positions determined by
/// [gridDelegate]. Each child is forced to have the size specified by the
/// [gridDelegate].
///
/// The main axis direction of a grid is the direction in which it scrolls; the
/// cross axis direction is the orthogonal direction.
class SliverGrid extends SliverMultiBoxAdaptorWidget {
SliverGridDelegate
有兩個子類別,分別是 SliverGridDelegateWithFixedCrossAxisCount
跟 SliverGridDelegateWithMaxCrossAxisExtent
。
類圖整理如下:
classDiagram class SliverGrid class SliverGridDelegate abstract SliverGridDelegate class SliverGridDelegateWithFixedCrossAxisCount class SliverGridDelegateWithMaxCrossAxisExtent class RenderSliverGrid SliverGrid --> "使用" SliverGridDelegate SliverGridDelegate <|-- SliverGridDelegateWithFixedCrossAxisCount SliverGridDelegate <|-- SliverGridDelegateWithMaxCrossAxisExtent SliverGrid --> "建立" RenderSliverGrid
最後 SliverGrid
會使用到的類別,像是 SliverGridDelegate
、 RenderSliverGrid
等等,都被定義在 ~/lib/rendering/sliver_grid.dart~
。
接下來我們來看 SliverGridDelegate
的用途。
SliverGridDelegate
SliverGridDelegate
是一個抽象類別,負責計算出 children 對應的佈局(layout),註解上寫道,基本上是按造 scroll offset 來排序。
/// Controls the layout of tiles in a grid.
///
/// Given the current constraints on the grid, a [SliverGridDelegate] computes
/// the layout for the tiles in the grid. The tiles can be placed arbitrarily,
/// but it is more efficient to place tiles in roughly in order by scroll offset
/// because grids reify a contiguous sequence of children.
abstract class SliverGridDelegate {
這個抽象類別很簡單,就是要子類別必須實作 getLayout
跟 shouldRelayout
兩個方法,建立出來的 SliverGridLayout
會被 RenderSliverGird.performLayout
使用,計算出 children 要 rendering 的位置。 RenderSliverGrid
由 SliverGrid.createRenderObject
建立。
類圖整理如下:
classDiagram class SliverGridDelegate class SliverGridLayout <<abstract>> SliverGridDelegate <<abstract>> SliverGridLayout SliverGridDelegate --> "建立" SliverGridLayout RenderSliverGrid --> "使用" SliverGridLayout
SliverGridLayout
/// The size and position of all the tiles in a [RenderSliverGrid].
///
/// Rather that providing a grid with a [SliverGridLayout] directly, you instead
/// provide the grid a [SliverGridDelegate], which can compute a
/// [SliverGridLayout] given the current [SliverConstraints].
///
/// The tiles can be placed arbitrarily, but it is more efficient to place tiles
/// in roughly in order by scroll offset because grids reify a contiguous
/// sequence of children.
@immutable
abstract class SliverGridLayout {
classDiagram SliverGridLayout <|-- SliverGridRegularTileLayout
SliverGridRegularTileLayout
這個類別負責建立相同大小跟空白間隔的 tiles。在使用上會透過 SliverGridDelegate
根據 SliverConstraints
計算出對應大小的 SliverGridLayout
給 RenderSilverGrid
。這個 layout 會給 SliverGridDelegateWithFixedCrossAxisCount
跟 SliverGridDelegateWithMaxCrossAxisExtent
使用。
/// A [SliverGridLayout] that uses equally sized and spaced tiles.
///
/// Rather that providing a grid with a [SliverGridLayout] directly, you instead
/// provide the grid a [SliverGridDelegate], which can compute a
/// [SliverGridLayout] given the current [SliverConstraints].
///
/// This layout is used by [SliverGridDelegateWithFixedCrossAxisCount] and
/// [SliverGridDelegateWithMaxCrossAxisExtent].
///
/// See also:
///
/// * [SliverGridDelegateWithFixedCrossAxisCount], which uses this layout.
/// * [SliverGridDelegateWithMaxCrossAxisExtent], which uses this layout.
/// * [SliverGridLayout], which represents an arbitrary tile layout.
/// * [SliverGridGeometry], which represents the size and position of a single
/// tile in a grid.
/// * [SliverGridDelegate.getLayout], which returns this object to describe the
/// delegate's layout.
/// * [RenderSliverGrid], which uses this class during its
/// [RenderSliverGrid.performLayout] method.
class SliverGridRegularTileLayout extends SliverGridLayout {
classDiagram SliverGridLayout <|-- SliverGridRegularTileLayout SliverGridDelegateWithFixedCrossAxisCount --> "建立" SliverGridRegularTileLayout SliverGridDelegateWithMaxCrossAxisExtent --> "建立" SliverGridRegularTileLayout RenderSliverGrid --> "使用" SliverGridRegularTileLayout SliverGridRegularTileLayout --> "建立" SliverGridGeometry RenderSliverGrid --> "使用" SliverGridGeometry
從上面的類圖,我們可以看出 layout 最重要的任務是計算出 child 的大小跟位置,這部分由 SliverGridGeometry
負責,所以接下來說明 SliverGridGeometry
,這個類別跟其他類別的互動關係,有大概的認知即可,而 SliverGridGeometry
如何被使用我們將留到討論 RenderSliverGrid.performLayout
時說明。
SliverGridGeometry
/// Describes the placement of a child in a [RenderSliverGrid].
///
/// See also:
///
/// * [SliverGridLayout], which represents the geometry of all the tiles in a
/// grid.
/// * [SliverGridLayout.getGeometryForChildIndex], which returns this object
/// to describe the child's placement.
/// * [RenderSliverGrid], which uses this class during its
/// [RenderSliverGrid.performLayout] method.
@immutable
class SliverGridGeometry {
- scrolloffset :: 可以滾動的軸線上的偏移量
- crossAxisOffset :: 如果滚动轴是垂直的,这个偏移量就是从 parent 的最左边缘到 child 的最左边缘。如果滚动轴是水平的,这个偏移量是从 parent 的最上面的边缘到 child 的最上面的边缘。
- mainAxisExtent :: child 在滾動軸的範圍,滾動軸是垂直的,那這就是高。若是水平,這就是寬。
- crossAxisExtent :: child 在非滾動軸的範圍。
- trailingScrollOffset :: child 相對 parent 尾部的偏移量。
- getBoxConstraints -> BoxConstraints :: 為 child 建立一個固定大小的框框,強制讓他有一個固定大小。
classDiagram RenderSliverGrid --> "使用" SliverGridGeometry SliverGridLayout --> "建立" SliverGridGeometry SliverGridGeometry --> "建立" BoxConstraints
SliverGridGeometry
在 RenderSliverGird.performLayout
被大量使用,接下來進入重頭戲 —— 我們的主角 RenderSliverGird
。
RenderSliverGrid
到了這邊,基本上要讀的就是 RenderSliverGrid.performLayout
。但在開始之前,我們還是爬梳一下 RenderSliverGrid
跟其他類別的關係。
首先看註解。
/// A sliver that places multiple box children in a two dimensional arrangement.
///
/// [RenderSliverGrid] places its children in arbitrary positions determined by
/// [gridDelegate]. Each child is forced to have the size specified by the
/// [gridDelegate].
///
/// See also:
///
/// * [RenderSliverList], which places its children in a linear
/// array.
/// * [RenderSliverFixedExtentList], which places its children in a linear
/// array with a fixed extent in the main axis.
class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
註解提到 RenderSliverGrid
的父類別是 RenderSliverMultiBoxAdaptor
,所以本身有 KeepAlive
的特性,能透過 RenderSliverChildBoxManager
來控制 child 需不需要暫存,以增加 render 的效能。
再來,每一個 child 的大小都是由 gridDelegate
指定,而 gridDelegate
的型別是 SliverGridDelegate
。我們在前面提過 SliverGridDelegate
是一個抽象類別,目前有兩個實作,也就是 SliverGridDelegateWithFixedCrossAxisCount
跟 SliverGridDelegateWithMaxCrossAxisExtent
。
整個類別最重要的邏輯在 performLayout
,在這階段,使用了前面提到由 SliverGridLayout
計算出的 SliverGridGeometry
來將新增 child 到正確的位置。
唯一前面沒提到的是 SliverGridParentData
。
類圖整理如下:
classDiagram RenderSliverMultiBoxAdaptor <|-- RenderSliverGrid RenderSliverGrid --> "使用" SliverGridParentData RenderSliverGrid --> "使用" SliverGridDelegate SliverGridDelegate --> "建立" SliverGridLayout SliverGridLayout --> "建立" SliverGridGeometry RenderSliverGrid --> "使用" SliverGridGeometry RenderSliverGrid --> "使用" RenderSliverChildBoxManager
從上面我們看到,有一個陌生的類別是 SliverGridParentData
。他是做什麼的呢? 我們繼續看下去。
SliverGridParentData
熟悉 ParentData
的讀者大概可以猜到,他是用來儲存要給父元件使用的 child 參數, 比如說父元件要計算自己的大小時,必需要知道子元件的大小。而在這裏,主要是增加用來計算子元件 slivers 的 layout offset。
/// Parent data structure used by [RenderSliverGrid].
class SliverGridParentData extends SliverMultiBoxAdaptorParentData {
classDiagram SliverLogicalParentData <|-- SliverMultiBoxAdaptorParentData ContainerParentDataMixin <|-- "mixin" SliverMultiBoxAdaptorParentData KeepAliveParentDataMixin <|-- "mixin" SliverMultiBoxAdaptorParentData SliverMultiBoxAdaptorParentData <|-- SliverGridParentData
這個類別基本上繼承了 SliverMultiBoxAdaptorParentData
的屬性外,還多儲存了 crossAxisOffset
,用來追蹤 child 在非滾動軸線的偏移量。
/// The offset of the child in the non-scrolling axis.
///
/// If the scroll axis is vertical, this offset is from the left-most edge of
/// the parent to the left-most edge of the child. If the scroll axis is
/// horizontal, this offset is from the top-most edge of the parent to the
/// top-most edge of the child.
double? crossAxisOffset;
接下來我們開始深挖 SliverMultiBoxAdaptorParentData
的細節。
根據註解, SliverMultiBoxAdaptorParentData
除了繼承 SliverLogicalParentData
還混合了 ContainerParentDataMixin
跟 KeepAliveParentDataMixin
。它的用途是追蹤 child index 的狀態。
/// Parent data structure used by [RenderSliverMultiBoxAdaptor].
class SliverMultiBoxAdaptorParentData extends SliverLogicalParentData with ContainerParentDataMixin<RenderBox>, KeepAliveParentDataMixin {
KeepAliveParentDataMixin
在 Flutter SliverChildBuilderDelegate 源碼解析 已經解析過了,所以我們接下只看 SliverLogicalParentData
跟 ContainerParentDataMixin
。
SliverLogicalParentData
SliverLogicalParentData
主要用來讓 slivers 的父元件使用 layout offsets 進行 children 的佈局。
/// Parent data structure used by parents of slivers that position their
/// children using layout offsets.
///
/// This data structure is optimized for fast layout. It is best used by parents
/// that expect to have many children whose relative positions don't change even
/// when the scroll offset does.
class SliverLogicalParentData extends ParentData {
那麼 layout offsets
是什麼呢? 見註解如下:
/// The position of the child relative to the zero scroll offset.
///
/// The number of pixels from the zero scroll offset of the parent sliver
/// (the line at which its [SliverConstraints.scrollOffset] is zero) to the
/// side of the child closest to that offset. A [layoutOffset] can be null
/// when it cannot be determined. The value will be set after layout.
///
/// In a typical list, this does not change as the parent is scrolled.
///
/// Defaults to null.
double? layoutOffset;
根據註解得知,這是用來提供父元件被滾動時的 offset
,讓 children 的佈局 offset 能夠跟著 shift。
ContainerParentDataMixin
ContainerParentDataMixin
讓 ParentData
支援 double-linking list 的特徵,這個算是語法糖(syntax sugar),需要搭配 ContainerRenderObjectMixin.firstChild
or ContainerRenderObjectMixin.lastChild
使用。
在何處被使用?
這個 parentData
記住的 crossAxisOffset
會在 RenderSliverHelper.hitTestBoxChild
, RenderSliverHelper.applyPaintTransformForBoxChild
, RenderSliverMultiBoxAdaptor.paint
用到。parentData.layoutOffset
則會在 RenderSliverMultiBoxAdaptor.childScrollOffset
用到。
這裡面的細節需要查看 RenderSliver
的註解。
performLayout()
一開始需要通知 childManager
我們現在開始進行 layout,這裡還有設定 childManager.setDidUnderflow(false)
。具體做什麼,我們先忽略不追。
childManager.didStartLayout();
childManager.setDidUnderflow(false);
接下來就是利用 SliverGridLayout
跟 SliverConstraints
去計算 firstIndex
跟 targetLastIndex
。兩個位置都會因為做滾動 (scroll) 而變化。
final int firstIndex = layout.getMinChildIndexForScrollOffset(scrollOffset);
final int? targetLastIndex = targetEndScrollOffset.isFinite ?
layout.getMaxChildIndexForScrollOffset(targetEndScrollOffset) : null;
然後利用 collectGarbage(....)
將舊狀態清除,才開始進行新增 child 的邏輯。這邊有個例外狀況需要檢查,看是不是沒有 children 需要增加,或是已經滾動到 children 的底部,所以沒有新的 child 需要新增。方法是將第一個 child 用 RenderSliverMultiBoxAdaptor.addInitialChild
新增,如果失敗,則呼叫 childManager.didFinishLayout
終止 layout 步驟。
final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
final double leadingScrollOffset = firstChildGridGeometry.scrollOffset;
double trailingScrollOffset = firstChildGridGeometry.trailingScrollOffset;
if (firstChild == null) {
if (!addInitialChild(index: firstIndex, layoutOffset: firstChildGridGeometry.scrollOffset)) {
// There are either no children, or we are past the end of all our children.
final double max = layout.computeMaxScrollOffset(childManager.childCount);
geometry = SliverGeometry(
scrollExtent: max,
maxPaintExtent: max,
);
childManager.didFinishLayout();
return;
}
}
接下來遍歷所有 child,計算出各自的 constraints,然後透過 RenderSliverGrid.insertAndLayoutChild
新增並且對 child 佈局。
for (int index = indexOf(trailingChildWithLayout!) + 1; targetLastIndex == null || index <= targetLastIndex; ++index) {
final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index);
final BoxConstraints childConstraints = gridGeometry.getBoxConstraints(constraints);
RenderBox? child = childAfter(trailingChildWithLayout!);
if (child == null || indexOf(child) != index) {
child = insertAndLayoutChild(childConstraints, after: trailingChildWithLayout);
if (child == null) {
// We have run out of children.
break;
}
} else {
child.layout(childConstraints);
}
....
}
佈局的邏輯不複雜,主要是利用 RenderSliverBoxChildManager.createChild
建立真正的 child,這個 child 可能會從 cache 拿。我們在前面提過,RenderSliverBoxChildManager
會從 SliverMultiBoxAdaptorParentData.keepAlive
判斷要不要使用 cache。
/// Called during layout to create, add, and layout the child after
/// the given child.
///
/// Calls [RenderSliverBoxChildManager.createChild] to actually create and add
/// the child if necessary. The child may instead be obtained from a cache;
/// see [SliverMultiBoxAdaptorParentData.keepAlive].
///
/// Returns the new child. It is the responsibility of the caller to configure
/// the child's scroll offset.
///
/// Children after the `after` child may be removed in the process. Only the
/// new child may be added.
@protected
RenderBox? insertAndLayoutChild(
BoxConstraints childConstraints, {
required RenderBox? after,
bool parentUsesSize = false,
}) {
assert(_debugAssertChildListLocked());
assert(after != null);
final int index = indexOf(after!) + 1;
_createOrObtainChild(index, after: after);
final RenderBox? child = childAfter(after);
if (child != null && indexOf(child) == index) {
child.layout(childConstraints, parentUsesSize: parentUsesSize);
return child;
}
childManager.setDidUnderflow(true);
return null;
}
從上面可以看到,如果 child 沒建立,就會執行 childManager.setDidUnderflow(true)
。而 child.layout
所拿到的 childConstraints
是 SliverGridGeometry.getConstraints
計算出來的。 child.layout
這個方法是 performLayout
必定要呼叫的方法,也是真正更新 child 佈局資訊的地方。
建立一個 child 後,會順便更新他的 parent data,用來記住這個 child 在滾動軸跟非滾動軸的偏移量。什麼時候會被用到,我們在之前已經說明過,不再贅述。
final SliverGridParentData childParentData = child.parentData! as SliverGridParentData;
childParentData.layoutOffset = gridGeometry.scrollOffset;
childParentData.crossAxisOffset = gridGeometry.crossAxisOffset;
而當 children 的 constraints 計算完後,便開始計算 sliver 自己佔據的大小,並儲存在 geometry
這變數。
geometry = SliverGeometry(
scrollExtent: estimatedTotalExtent,
paintExtent: paintExtent,
maxPaintExtent: estimatedTotalExtent,
cacheExtent: cacheExtent,
hasVisualOverflow: estimatedTotalExtent > paintExtent || constraints.scrollOffset > 0.0 || constraints.overlap != 0.0,
);
讓父元件能依據這資訊更新他自己佔據的大小,做一些邊界處理,最後呼叫 childManager.didFinishLayout()
把 layout phase 結束。
總結
到此,我們已經說明完 SliverGrid
相關的類別以及職責,怎麼讓 children 根據滾動的狀態做佈局,唯一不清楚的地方是 Flutter Viewport Widget
跟 slivers 的關係。