Flutter Sliver你要的瀑布流小姐姐

法的空間發表於2019-12-11

Flutter Sliver你要的瀑布流小姐姐

前言

今天看了Flutter Interact, 全程有個小姐姐翻譯(同聲翻譯,好強力),於是我就邊聽邊完成了這篇文章。

Flutter Sliver你要的瀑布流小姐姐

接著上一章Flutter Sliver一生之敵 (ExtendedList),這章我們將編寫一個瀑布流佈局,來檢驗一下我們上一章對Sliver列表原始碼分析是否正確。

歡迎加入Flutter Candies flutter-candies QQ群: 181398081。

Flutter Sliver你要的瀑布流小姐姐
知道你們只關心小姐姐,我還是先放效果圖吧。

Flutter Sliver你要的瀑布流小姐姐
Flutter Sliver你要的瀑布流小姐姐
Flutter Sliver你要的瀑布流小姐姐
Flutter Sliver你要的瀑布流小姐姐

原理

之前做UWP的時候,我自己也做過瀑布流佈局。似乎是一種執念,入坑Flutter之後也想實現一下瀑布流佈局。下面簡單講一下什麼是瀑布流以及原理。

瀑布流佈局的特點是等寬不等高。 為了讓最後一行的差距最小,從第二行開始,需要將一項放在第一行最矮的一項下面,以此類推,如下圖。4在0下面,5在3下面,6在1下面,7在2下面,8在4下面...

Flutter Sliver你要的瀑布流小姐姐

核心程式碼

知道了原理,下面我們來一起把原理實現為程式碼。

  • 由於我們需要知道離viewport頂部最近的Items,以及viewport底部最近的Items。這樣才能知道向後滾動新的item放哪個item下面,或者說向前滾動的時候知道新的item放在哪個item的上面 我設計了CrossAxisItems 來存放leadingItems 和 trailingItems。

  • 向後新增新的item的時候程式碼如下

1.補充leadingItems直到等於crossAxisCount

2.找到當前最矮的一項,將設定它的layoutoffset

3.儲存這一列的indexes

  void insert({
    @required RenderBox child,
    @required ChildTrailingLayoutOffset childTrailingLayoutOffset,
    @required PaintExtentOf paintExtentOf,
  }) {
    final WaterfallFlowParentData data = child.parentData;
    final LastChildLayoutType lastChildLayoutType =
        delegate.getLastChildLayoutType(data.index);
    
    ///處理最後一個特殊化佈局
    switch (lastChildLayoutType) {
      case LastChildLayoutType.fullCrossAxisExtend:
      case LastChildLayoutType.foot:
        //橫軸繪製offset
        data.crossAxisOffset = 0.0;
        //橫軸index
        data.crossAxisIndex = 0;
        //該child的大小
        final size = paintExtentOf(child);
        
        if (lastChildLayoutType == LastChildLayoutType.fullCrossAxisExtend ||
            maxChildTrailingLayoutOffset + size >
                constraints.remainingPaintExtent) {
          data.layoutOffset = maxChildTrailingLayoutOffset;
        } else {
          //如果全部children沒有繪製viewport的大
          data.layoutOffset = constraints.remainingPaintExtent - size;
        }
        data.trailingLayoutOffset = childTrailingLayoutOffset(child);
        return;
      case LastChildLayoutType.none:
        break;
    }

    if (!leadingItems.contains(data)) {
      //補充滿leadingItems
      if (leadingItems.length != crossAxisCount) {
        data.crossAxisIndex ??= leadingItems.length;

        data.crossAxisOffset =
            delegate.getCrossAxisOffset(constraints, data.crossAxisIndex);

        if (data.index < crossAxisCount) {
          data.layoutOffset = 0.0;
          data.indexs.clear();
        }

        trailingItems.add(data);
        leadingItems.add(data);
      } else {
        if (data.crossAxisIndex != null) {
          var item = trailingItems.firstWhere(
              (x) =>
                  x.index > data.index &&
                  x.crossAxisIndex == data.crossAxisIndex,
              orElse: () => null);

          ///out of viewport
          if (item != null) {
            data.trailingLayoutOffset = childTrailingLayoutOffset(child);
            return;
          }
        }
        //找到最矮的那個
        var min = trailingItems.reduce((curr, next) =>
            ((curr.trailingLayoutOffset < next.trailingLayoutOffset) ||
                    (curr.trailingLayoutOffset == next.trailingLayoutOffset &&
                        curr.crossAxisIndex < next.crossAxisIndex)
                ? curr
                : next));

        data.layoutOffset = min.trailingLayoutOffset + delegate.mainAxisSpacing;
        data.crossAxisIndex = min.crossAxisIndex;
        data.crossAxisOffset =
            delegate.getCrossAxisOffset(constraints, data.crossAxisIndex);

        trailingItems.forEach((f) => f.indexs.remove(min.index));
        min.indexs.add(min.index);
        data.indexs = min.indexs;
        trailingItems.remove(min);
        trailingItems.add(data);
      }
    }

    data.trailingLayoutOffset = childTrailingLayoutOffset(child);
  }
複製程式碼
  • 向前新增新的item的時候程式碼如下

1.通過indexs找到新item屬於哪一列

2.新增到舊的item的上面

  void insertLeading({
    @required RenderBox child,
    @required PaintExtentOf paintExtentOf,
  }) {
    final WaterfallFlowParentData data = child.parentData;
    if (!leadingItems.contains(data)) {
      var pre = leadingItems.firstWhere((x) => x.indexs.contains(data.index),
          orElse: () => null);

      if (pre == null || pre.index < data.index) return;

      data.trailingLayoutOffset = pre.layoutOffset - delegate.mainAxisSpacing;
      data.crossAxisIndex = pre.crossAxisIndex;
      data.crossAxisOffset =
          delegate.getCrossAxisOffset(constraints, data.crossAxisIndex);

      leadingItems.remove(pre);
      leadingItems.add(data);
      trailingItems.remove(pre);
      trailingItems.add(data);
      data.indexs = pre.indexs;

      data.layoutOffset = data.trailingLayoutOffset - paintExtentOf(child);
    }
  }
複製程式碼
  • 計算離viewport頂部最近的,應該確保leadingItems都在viewport裡面 跟之前Listview的原始碼分析差不多,只是這裡我們要保證最大的LeadingLayoutOffset都小於scrollOffset,這樣leadingItems就都在viewport裡面了
    if (crossAxisItems.maxLeadingLayoutOffset > scrollOffset) {
      RenderBox child = firstChild;
      //move to max index of leading
      final int maxLeadingIndex = crossAxisItems.maxLeadingIndex;
      while (child != null && maxLeadingIndex > indexOf(child)) {
        child = childAfter(child);
      }
      //fill leadings from max index of leading to min index of leading
      while (child != null && crossAxisItems.minLeadingIndex < indexOf(child)) {
        crossAxisItems.insertLeading(
            child: child, paintExtentOf: paintExtentOf);
        child = childBefore(child);
      }
      //collectGarbage(maxLeadingIndex - index, 0);

      while (crossAxisItems.maxLeadingLayoutOffset > scrollOffset) {
        // We have to add children before the earliestUsefulChild.
        earliestUsefulChild =
            insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);

        if (earliestUsefulChild == null) {
          if (scrollOffset == 0.0) {
            // insertAndLayoutLeadingChild only lays out the children before
            // firstChild. In this case, nothing has been laid out. We have
            // to lay out firstChild manually.
            firstChild.layout(childConstraints, parentUsesSize: true);

            earliestUsefulChild = firstChild;
            leadingChildWithLayout = earliestUsefulChild;
            trailingChildWithLayout ??= earliestUsefulChild;
            crossAxisItems.reset();
            crossAxisItems.insert(
              child: earliestUsefulChild,
              childTrailingLayoutOffset: childTrailingLayoutOffset,
              paintExtentOf: paintExtentOf,
            );
            break;
          } else {
            // We ran out of children before reaching the scroll offset.
            // We must inform our parent that this sliver cannot fulfill
            // its contract and that we need a scroll offset correction.
            geometry = SliverGeometry(
              scrollOffsetCorrection: -scrollOffset,
            );
            return;
          }
        }

        crossAxisItems.insertLeading(
            child: earliestUsefulChild, paintExtentOf: paintExtentOf);

        final WaterfallFlowParentData data = earliestUsefulChild.parentData;

        // firstChildScrollOffset may contain double precision error
        if (data.layoutOffset < -precisionErrorTolerance) {
          // The first child doesn't fit within the viewport (underflow) and
          // there may be additional children above it. Find the real first child
          // and then correct the scroll position so that there's room for all and
          // so that the trailing edge of the original firstChild appears where it
          // was before the scroll offset correction.
          // do this work incrementally, instead of all at once,
          // i.e. find a way to avoid visiting ALL of the children whose offset
          // is < 0 before returning for the scroll correction.
          double correction = 0.0;
          while (earliestUsefulChild != null) {
            assert(firstChild == earliestUsefulChild);
            correction += paintExtentOf(firstChild);
            earliestUsefulChild = insertAndLayoutLeadingChild(childConstraints,
                parentUsesSize: true);
            crossAxisItems.insertLeading(
                child: earliestUsefulChild, paintExtentOf: paintExtentOf);
          }
          geometry = SliverGeometry(
            scrollOffsetCorrection: correction - data.layoutOffset,
          );
          return;
        }

        assert(earliestUsefulChild == firstChild);
        leadingChildWithLayout = earliestUsefulChild;
        trailingChildWithLayout ??= earliestUsefulChild;
      }
    }
複製程式碼
  • 計算達到viewport底部,應該確保trailingItems中最短的要超過viewport的底部
    // Now find the first child that ends after our end.
    if (child != null) {
      while (crossAxisItems.minChildTrailingLayoutOffset <
              targetEndScrollOffset ||
              //make sure leading children are painted. 
          crossAxisItems.leadingItems.length < _gridDelegate.crossAxisCount
          || crossAxisItems.leadingItems.length  > childCount
          ) {
        if (!advance()) {
          reachedEnd = true;
          break;
        }
      }
    }
複製程式碼

waterfall_flow使用

  • 在pubspec.yaml中增加庫引用

dependencies:
  waterfall_flow: any

複製程式碼
  • 匯入庫

  import 'package:waterfall_flow/waterfall_flow.dart';
  
複製程式碼

如何定義

你可以通過設定SliverWaterfallFlowDelegate引數來定義瀑布流

引數 描述 預設
crossAxisCount 橫軸的等長度元素數量 必填
mainAxisSpacing 主軸元素之間的距離 0.0
crossAxisSpacing 橫軸元素之間的距離 0.0
collectGarbage 元素回收時候的回撥 -
lastChildLayoutTypeBuilder 最後一個元素的佈局樣式(詳情請檢視後面) -
viewportBuilder 可視區域中元素indexes變化時的回撥 -
closeToTrailing 可否讓佈局緊貼trailing(詳情請檢視後面) false
            WaterfallFlow.builder(
              //cacheExtent: 0.0,
              padding: EdgeInsets.all(5.0),
              gridDelegate: SliverWaterfallFlowDelegate(
                  crossAxisCount: 2,
                  crossAxisSpacing: 5.0,
                  mainAxisSpacing: 5.0,
                  /// follow max child trailing layout offset and layout with full cross axis extend
                  /// last child as loadmore item/no more item in [GridView] and [WaterfallFlow]
                  /// with full cross axis extend
                  //  LastChildLayoutType.fullCrossAxisExtend,

                  /// as foot at trailing and layout with full cross axis extend
                  /// show no more item at trailing when children are not full of viewport
                  /// if children is full of viewport, it's the same as fullCrossAxisExtend
                  //  LastChildLayoutType.foot,
                  lastChildLayoutTypeBuilder: (index) => index == _list.length
                      ? LastChildLayoutType.foot
                      : LastChildLayoutType.none,
                  ),

複製程式碼

完整小姐姐Demo

結語

沒有再對原始碼有更多的分析,上一篇如果你看過了,應該會更加明白其中的道理。這一篇是對瀑布流原理在Flutter上面實現的展示,沒有做不到效果,只有想不到的效果,這就是Flutter給我帶來的體驗。

最後放上Flutter Interact 的一些內容,我是邊聽邊寫的,如果有誤,請提醒我更改下。

  • Flutter 1.12版本
  • Material design 以及字型庫
  • 帶來了Desktop/Web(你現在可以嘗試他們了,不再是玩具)
  • 叼炸天的各種開發工具(同時除錯7種裝置,UI化介面)
  • gskinner 酷炫互動+開源
  • supernova 知名的 design-to-code (設計轉程式碼) 工具
  • Adobe XD 程式猿們顫抖吧,UI將代替你們了。
  • flutter_vignettes商業互吹
  • rive.app展示了一個炒雞可愛的遊戲,而且是用Flutter web

歡迎加入Flutter Candies,一起生產可愛的Flutter小糖果( flutter-candiesQQ群:181398081)

最最後放上Flutter Candies全家桶,真香。

Flutter Sliver你要的瀑布流小姐姐

相關文章