Flutter 中 SliverGrid 是一個通常與 CustomScroll 搭配使用的 Sliver ,作用是讓 children 以二維的方式佈局,並且可以動態載入 children。本文介紹大致邏輯。

SliverGrid

源碼定義在 ~lib/src/rendering/sliver.dart

首先我們看註解,最重要的參數是 gridDelegate ,型別是 SliverGridDelegateSliverGridDelegate 是一個抽象類別。 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 有兩個子類別,分別是 SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent

類圖整理如下:

classDiagram
class SliverGrid
class SliverGridDelegate
abstract SliverGridDelegate
class SliverGridDelegateWithFixedCrossAxisCount
class SliverGridDelegateWithMaxCrossAxisExtent
class RenderSliverGrid

SliverGrid --> "使用" SliverGridDelegate
SliverGridDelegate <|-- SliverGridDelegateWithFixedCrossAxisCount
SliverGridDelegate <|-- SliverGridDelegateWithMaxCrossAxisExtent

SliverGrid --> "建立" RenderSliverGrid

最後 SliverGrid 會使用到的類別,像是 SliverGridDelegateRenderSliverGrid 等等,都被定義在 ~/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 {

這個抽象類別很簡單,就是要子類別必須實作 getLayoutshouldRelayout 兩個方法,建立出來的 SliverGridLayout 會被 RenderSliverGird.performLayout 使用,計算出 children 要 rendering 的位置。 RenderSliverGridSliverGrid.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 計算出對應大小的 SliverGridLayoutRenderSilverGrid 。這個 layout 會給 SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent 使用。

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

SliverGridGeometryRenderSliverGird.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 是一個抽象類別,目前有兩個實作,也就是 SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent

整個類別最重要的邏輯在 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 還混合了 ContainerParentDataMixinKeepAliveParentDataMixin 。它的用途是追蹤 child index 的狀態。

/// Parent data structure used by [RenderSliverMultiBoxAdaptor].
class SliverMultiBoxAdaptorParentData extends SliverLogicalParentData with ContainerParentDataMixin<RenderBox>, KeepAliveParentDataMixin {

KeepAliveParentDataMixinFlutter SliverChildBuilderDelegate 源碼解析 已經解析過了,所以我們接下只看 SliverLogicalParentDataContainerParentDataMixin

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

ContainerParentDataMixinParentData 支援 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);

接下來就是利用 SliverGridLayoutSliverConstraints 去計算 firstIndextargetLastIndex。兩個位置都會因為做滾動 (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 所拿到的 childConstraintsSliverGridGeometry.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 的關係。