GridView 你怎麼那麼皮——從需求出發,如何讓 GridView 的尺寸再也不難以控制

ListView 和 GridView 是咱們在開發過程當中常常使用的控件。前者用來繪製一個具備動態 item 的固定方向的列表,後者用來顯示一個宮格的瀑布流。它們的 item 是一個個獨立的佈局,咱們能夠對其自定義。markdown

在 Android 中,咱們既能夠根據其父控件的尺寸約束對 item 進行尺寸規範,也能夠根據各個 item 間的相互關係動態規劃 item 尺寸。less

flutter 中也有 ListView 和 GridView ,而今天,我想重點說說這個 GridView 。ide

邂逅 GridView 你怎麼那麼皮函數

我初接觸 flutter 的 GridView 是在作一個平板項目的時候,用它來展現一個帳單列表,每一個帳單由於場景須要,需指定其寬高。由於在 GridView 中沒有找到設置寬高的屬性,我嘗試固定其item 的寬高,但顯示出來徹底不是預料中的效果。在查閱資料後,發現了childAspectRatio 屬性,設置以後也老是和個人預期不符——要麼寬度過了,要麼高度不夠。後來接連的幾個使用到 GridView 的場景也徹底不能駕輕就熟,就像抓一條泥鰍,別提多難受了。佈局

打開 GridView 的源碼,咱們看看它究竟是怎麼處理這個邏輯的。ui

窺見this

GridView 繼承於BoxScrollView,有5個構造方法,其中4個是代理構造方法,它們分別以下:spa

GridView({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required this.gridDelegate,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    List<Widget> children = const <Widget>[],
    int semanticChildCount,
  }) : assert(gridDelegate != null),
       childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount ?? children.length,
       );

GridView.builder({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required this.gridDelegate,
    @required IndexedWidgetBuilder itemBuilder,
    int itemCount,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    int semanticChildCount,
  }) : assert(gridDelegate != null),
       childrenDelegate = SliverChildBuilderDelegate(
         itemBuilder,
         childCount: itemCount,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount ?? itemCount,
       );

const GridView.custom({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required this.gridDelegate,
    @required this.childrenDelegate,
    double cacheExtent,
    int semanticChildCount,
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
  }) : assert(gridDelegate != null),
       assert(childrenDelegate != null),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount,
         dragStartBehavior: dragStartBehavior,
       );

GridView.count({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required int crossAxisCount,
    double mainAxisSpacing = 0.0,
    double crossAxisSpacing = 0.0,
    double childAspectRatio = 1.0,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    List<Widget> children = const <Widget>[],
    int semanticChildCount,
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
  }) : gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
         crossAxisCount: crossAxisCount,
         mainAxisSpacing: mainAxisSpacing,
         crossAxisSpacing: crossAxisSpacing,
         childAspectRatio: childAspectRatio,
       ),
       childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount ?? children.length,
         dragStartBehavior: dragStartBehavior,
       );

GridView.extent({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required double maxCrossAxisExtent,
    double mainAxisSpacing = 0.0,
    double crossAxisSpacing = 0.0,
    double childAspectRatio = 1.0,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    List<Widget> children = const <Widget>[],
    int semanticChildCount,
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
  }) : gridDelegate = SliverGridDelegateWithMaxCrossAxisExtent(
         maxCrossAxisExtent: maxCrossAxisExtent,
         mainAxisSpacing: mainAxisSpacing,
         crossAxisSpacing: crossAxisSpacing,
         childAspectRatio: childAspectRatio,
       ),
       childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         semanticChildCount: semanticChildCount ?? children.length,
         dragStartBehavior: dragStartBehavior,
       );
複製代碼

咱們發現每一個構造函數除了一些基本的諸如水平方向間距和垂直方向間距外,主要都在構造兩個對象——final SliverGridDelegate gridDelegate和final SliverChildDelegate childrenDelegate,那它們就是 GridView 的重點。debug

源碼分別對兩個對象的做用作了註釋:代理

  1. childrenDelegate A delegate that provides the children for the [GridView]. 這是構造 GridView 子控件的主要對象。
  2. gridDelegate A delegate that controls the layout of the children within the [GridView]. 這是控制 GridView 的佈局的主要對象。

構造出來的 childrenDelegate 和 gridDelegate 的惟一引用的地方在 GridView 的buildChildLayout方法,該方法返回一個 SliverGrid 即 Widget 對象,該對象的入參就是 childrenDelegate 和 gridDelegate,因此其實 GridView 就是對 SliverGrid 的封裝。

咱們先看 childrenDelegate,由於 gridDelegate 後面會和它串起來。

在 SliverGrid 中,childrenDelegate 被向上傳遞給父類對象SliverMultiBoxAdaptorWidget,而類中引用 childrenDelegate 的惟一做用就是用來預測最大滾動距離。

咱們再回頭看 buildChildLayout 這個方法,它繼承自父類,在父類 BoxScrollView 中被 buildSlivers 方法搜引用。

@override
  List<Widget> buildSlivers(BuildContext context) {
    Widget sliver = buildChildLayout(context);
    EdgeInsetsGeometry effectivePadding = padding;
    if (padding == null) {
      final MediaQueryData mediaQuery = MediaQuery.of(context, nullOk: true);
      if (mediaQuery != null) {
        // Automatically pad sliver with padding from MediaQuery.
        final EdgeInsets mediaQueryHorizontalPadding =
            mediaQuery.padding.copyWith(top: 0.0, bottom: 0.0);
        final EdgeInsets mediaQueryVerticalPadding =
            mediaQuery.padding.copyWith(left: 0.0, right: 0.0);
        // Consume the main axis padding with SliverPadding.
        effectivePadding = scrollDirection == Axis.vertical
            ? mediaQueryVerticalPadding
            : mediaQueryHorizontalPadding;
        // Leave behind the cross axis padding.
        sliver = MediaQuery(
          data: mediaQuery.copyWith(
            padding: scrollDirection == Axis.vertical
                ? mediaQueryHorizontalPadding
                : mediaQueryVerticalPadding,
          ),
          child: sliver,
        );
      }
    }

    if (effectivePadding != null)
      sliver = SliverPadding(padding: effectivePadding, sliver: sliver);
    return <Widget>[ sliver ];
  }
複製代碼

該方法將構造的 SliverGrid 進行一些包裝,而後塞到一個 List 中返回。

再向上搜索,ScrollView 的 build 方法中拿到了這個返回值,

@override
  Widget build(BuildContext context) {
    final List<Widget> slivers = buildSlivers(context);
    final AxisDirection axisDirection = getDirection(context);

    final ScrollController scrollController =
        primary ? PrimaryScrollController.of(context) : controller;
    final Scrollable scrollable = Scrollable(
      dragStartBehavior: dragStartBehavior,
      axisDirection: axisDirection,
      controller: scrollController,
      physics: physics,
      semanticChildCount: semanticChildCount,
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        return buildViewport(context, offset, axisDirection, slivers);
      },
    );
    final Widget scrollableResult = primary && scrollController != null
        ? PrimaryScrollController.none(child: scrollable)
        : scrollable;

    if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
      return NotificationListener<ScrollUpdateNotification>(
        child: scrollableResult,
        onNotification: (ScrollUpdateNotification notification) {
          final FocusScopeNode focusScope = FocusScope.of(context);
          if (notification.dragDetails != null && focusScope.hasFocus) {
            focusScope.unfocus();
          }
          return false;
        },
      );
    } else {
      return scrollableResult;
    }
  }
複製代碼

這裏仍是給 slivers 套了一些殼,而後返回。這裏一直出現一個詞——sliver,至於這個是個什麼東西,後面再單獨記錄。

後面的調用流程就深刻到 framework 了,其調用流程以下:

ScrollView # build <= StatelessElement # build <= ComponentElement # build <= ComponentElement # performRebuild <= Element # performRebuild <= Element # rebuild...

可見,SliverGrid 被層層包裹一些必要的外殼後,最終被掛載在視圖樹上並顯示,那麼真正的顯示內容是在何處構建完成的呢?這就要看 childrenDelegate 的初始化了,咱們以代理構造函數 GridView.builder 爲例,能夠看到它被初始化:

childrenDelegate = SliverChildBuilderDelegate(
  itemBuilder,
  childCount: itemCount,
  addAutomaticKeepAlives: addAutomaticKeepAlives,
  addRepaintBoundaries: addRepaintBoundaries,
  addSemanticIndexes: addSemanticIndexes,
)
複製代碼

因此 SliverChildBuilderDelegate 的內部構建過程就是 SliverGrid 的顯示內容被構造的過程啦。

@override
  Widget build(BuildContext context, int index) {
    assert(builder != null);
    if (index < 0 || (childCount != null && index >= childCount))
      return null;
    Widget child;
    try {
      child = builder(context, index);
    } catch (exception, stackTrace) {
      child = _createErrorWidget(exception, stackTrace);
    }
    if (child == null)
      return null;
    final Key key = child.key != null ? _SaltedValueKey(child.key) : null;
    if (addRepaintBoundaries)
      child = RepaintBoundary(child: child);
    if (addSemanticIndexes) {
      final int semanticIndex = semanticIndexCallback(child, index);
      if (semanticIndex != null)
        child = IndexedSemantics(index: semanticIndex + semanticIndexOffset, child: child);
    }
    if (addAutomaticKeepAlives)
      child = AutomaticKeepAlive(child: child);
    return KeyedSubtree(child: child, key: key);
  }
複製代碼

上面的方法將咱們 GridView 中的 item 構造出來,而後通過層層調用,其過程大概以下:

SliverChildBuilderDelegate # build <= SliverMultiBoxAdaptorElement # _build <= SliverMultiBoxAdaptorElement # createChild <= RenderSliverMultiBoxAdaptor # _createOrObtainChild <= RenderSliverMultiBoxAdaptor # insertAndLayoutChild <= RenderSliverGrid # performLayout

RenderSliverGrid 的 performLayout 方法中的代碼是關鍵代碼,咱們好好閱讀一下。

@override
void performLayout() {
  final SliverConstraints constraints = this.constraints;
  childManager.didStartLayout();
  childManager.setDidUnderflow(false);

  final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
  assert(scrollOffset >= 0.0);
  final double remainingExtent = constraints.remainingCacheExtent;
  assert(remainingExtent >= 0.0);
  final double targetEndScrollOffset = scrollOffset + remainingExtent;

  final SliverGridLayout layout = _gridDelegate.getLayout(constraints);

  final int firstIndex = layout.getMinChildIndexForScrollOffset(scrollOffset);
  final int targetLastIndex = targetEndScrollOffset.isFinite ?
    layout.getMaxChildIndexForScrollOffset(targetEndScrollOffset) : null;

  if (firstChild != null) {
    final int oldFirstIndex = indexOf(firstChild);
    final int oldLastIndex = indexOf(lastChild);
    final int leadingGarbage = (firstIndex - oldFirstIndex).clamp(0, childCount) as int;
    final int trailingGarbage = targetLastIndex == null
      ? 0
      : ((oldLastIndex - targetLastIndex).clamp(0, childCount) as int);
    collectGarbage(leadingGarbage, trailingGarbage);
  } else {
    collectGarbage(0, 0);
  }

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

  RenderBox trailingChildWithLayout;

  for (int index = indexOf(firstChild) - 1; index >= firstIndex; --index) {
    final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index);
    final RenderBox child = insertAndLayoutLeadingChild(
      gridGeometry.getBoxConstraints(constraints),
    );
    final SliverGridParentData childParentData = child.parentData as SliverGridParentData;
    childParentData.layoutOffset = gridGeometry.scrollOffset;
    childParentData.crossAxisOffset = gridGeometry.crossAxisOffset;
    assert(childParentData.index == index);
    trailingChildWithLayout ??= child;
    trailingScrollOffset = math.max(trailingScrollOffset, gridGeometry.trailingScrollOffset);
  }

  if (trailingChildWithLayout == null) {
    firstChild.layout(firstChildGridGeometry.getBoxConstraints(constraints));
    final SliverGridParentData childParentData = firstChild.parentData as SliverGridParentData;
    childParentData.layoutOffset = firstChildGridGeometry.scrollOffset;
    childParentData.crossAxisOffset = firstChildGridGeometry.crossAxisOffset;
    trailingChildWithLayout = firstChild;
  }

  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);
    }
    trailingChildWithLayout = child;
    assert(child != null);
    final SliverGridParentData childParentData = child.parentData as SliverGridParentData;
    childParentData.layoutOffset = gridGeometry.scrollOffset;
    childParentData.crossAxisOffset = gridGeometry.crossAxisOffset;
    assert(childParentData.index == index);
    trailingScrollOffset = math.max(trailingScrollOffset, gridGeometry.trailingScrollOffset);
  }

  final int lastIndex = indexOf(lastChild);

  assert(childScrollOffset(firstChild) <= scrollOffset);
  assert(debugAssertChildListIsNonEmptyAndContiguous());
  assert(indexOf(firstChild) == firstIndex);
  assert(targetLastIndex == null || lastIndex <= targetLastIndex);

  final double estimatedTotalExtent = childManager.estimateMaxScrollOffset(
    constraints,
    firstIndex: firstIndex,
    lastIndex: lastIndex,
    leadingScrollOffset: leadingScrollOffset,
    trailingScrollOffset: trailingScrollOffset,
  );

  final double paintExtent = calculatePaintOffset(
    constraints,
    from: leadingScrollOffset,
    to: trailingScrollOffset,
  );
  final double cacheExtent = calculateCacheOffset(
    constraints,
    from: leadingScrollOffset,
    to: trailingScrollOffset,
  );

  geometry = SliverGeometry(
    scrollExtent: estimatedTotalExtent,
    paintExtent: paintExtent,
    maxPaintExtent: estimatedTotalExtent,
    cacheExtent: cacheExtent,
    // Conservative to avoid complexity.
    hasVisualOverflow: true,
  );

  // We may have started the layout while scrolled to the end, which
  // would not expose a new child.
  if (estimatedTotalExtent == trailingScrollOffset)
    childManager.setDidUnderflow(true);
  childManager.didFinishLayout();
}
複製代碼

咱們注意到這裏有個對象 layout,它的賦值語句是 final SliverGridLayout layout = _gridDelegate.getLayout(constraints);,怎麼樣,有沒有感受有個東西很熟悉,沒錯,這裏的 _gridDelegate 正是咱們前面 GridView 中見到的兩個 @required 之一。到這裏,childrenDelegate 和 gridDelegate 就串聯起來了。那咱們就看看 getLayout 裏究竟返回了什麼東西。咱們以 SliverGridDelegateWithMaxCrossAxisExtent 爲例,看看源碼。

@override
SliverGridLayout getLayout(SliverConstraints constraints) {
  assert(_debugAssertIsValid());
  final int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
  final double usableCrossAxisExtent = constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1);
  final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount;
  final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio;
  return SliverGridRegularTileLayout(
  crossAxisCount: crossAxisCount,
  mainAxisStride: childMainAxisExtent + mainAxisSpacing,
  crossAxisStride: childCrossAxisExtent + crossAxisSpacing,
  childMainAxisExtent: childMainAxisExtent,
  childCrossAxisExtent: childCrossAxisExtent,
  reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),
  );
}
複製代碼

這裏計算了次軸方向(如每一行)的元素數目(crossAxisCount)、減去元素間次軸方向的間距後可用的空間大小(usableCrossAxisExtent)、次軸方向每一個元素可用的空間大小(childCrossAxisExtent)、元素在主軸方向的大小(childMainAxisExtent)等,經過這些數據對列表的子元素進行尺寸測繪。這裏咱們就明白了,爲何 GrdiView 這麼皮,由於自始至終,children 的佈局都沒有行進明確的尺寸限制,而是根據可用空間對元素的大小和位置進行動態排布。

因此回到最開始的 GridView 自己,它就是依靠兩個助手——childrenDelegate 和 gridDelegate——「橫行霸道」,前者幫助處理 children 繪製和佈局方面的業務,後者幫忙處理 children 尺寸測繪方面的業務。兩助手在手,天下任我走!

回想

那麼再回頭看最初的訴求,有沒有辦法寫一個固定寬高,剩餘空間均勻分佈的 GridView 呢?咱們嘗試一下。

首先通過上面的分析,咱們知道 gridDelegate 是佈局的關鍵,那麼咱們能夠嘗試繼承 SliverGridDelegate,來重寫它的佈局。

import 'package:flutter/rendering.dart';

class SliverGridDelegateWithFixedSize extends SliverGridDelegate {

  final double width;
  final double height;
  final double mainAxisSpacing;
  final double minCrossAxisSpacing;

  SliverGridDelegateWithFixedSize(
    this.width,
    this.height, {
    this.mainAxisSpacing = 0.0,
    this.minCrossAxisSpacing = 0.0,
  });

  @override
  SliverGridLayout getLayout(SliverConstraints constraints) {

    int crossAxisCount = constraints.crossAxisExtent ~/ width;
    double crossAxisSpacing =
        (constraints.crossAxisExtent - width * crossAxisCount) /
            (crossAxisCount - 1);

    while (crossAxisSpacing < minCrossAxisSpacing) {
      crossAxisCount -= 1;
      crossAxisSpacing =
          (constraints.crossAxisExtent - width * crossAxisCount) /
              (crossAxisCount - 1);
    }

    return SliverGridRegularTileLayout(
      crossAxisCount: crossAxisCount,
      mainAxisStride: height + mainAxisSpacing,
      crossAxisStride: width + crossAxisSpacing,
      childMainAxisExtent: height,
      childCrossAxisExtent: width,
      reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),
    );
  }

  @override
  bool shouldRelayout(SliverGridDelegateWithFixedSize oldDelegate) {
    return oldDelegate.width != width
        || oldDelegate.height != height
        || oldDelegate.mainAxisSpacing != mainAxisSpacing;
  }

}
複製代碼

這裏的佈局參考了 SliverGridDelegate 的兩個子類 SliverGridDelegateWithFixedCrossAxisCount 和 SliverGridDelegateWithMaxCrossAxisExtent。好的,咱們使用上面的 GridDelegate 來構建一個界面。

@override
Widget build(BuildContext context) {
    return Container(
        child: GridView.builder(
            gridDelegate: FixedSizeGridDelegate(240, 128, mainAxisSpacing: 10),
            itemCount: _blocks.length,
            itemBuilder: (context, index) => _blocks[index],
        ),
    );
}
複製代碼

效果以下(設備分辨率爲 1366 * 768):

咱們的預期這樣就算完成啦,若是還有其餘的需求,也能夠試着向這個方向嘗試。

相關文章
相關標籤/搜索