Flutter完整開發實戰詳解(十六、詳解自定義佈局實戰)

戀貓de小郭發表於2019-07-02

本篇將解析 Flutter 中自定義佈局的原理,並帶你深入實戰自定義佈局的流程,利用兩種自定義佈局的實現方式,完成如下圖所示的介面效果,看完這一篇你將可以更輕鬆的對 Flutter 為所欲為。

前文:

Flutter完整開發實戰詳解(十六、詳解自定義佈局實戰)

一、前言

在之前的篇章我們講過 WidgetElementRenderObject 之間的關係,所謂的 自定義佈局,事實上就是自定義 RenderObjectchild 的大小和位置 ,而在這點上和其他框架不同的是,在 Flutter 中佈局的核心並不是巢狀堆疊,Flutter 佈局的核心是在於 Canvas ,我們所使用的 Widget ,僅僅是為了簡化 RenderObject 的操作。

《九、 深入繪製原理》的測試繪製 中我們知道, 對於 Flutter 而言,整個螢幕都是一塊畫布,我們通過各種 OffsetRect 確定了位置,然後通過 Canvas 繪製 UI,而整個螢幕區域都是繪製目標,如果在 child 中我們 “不按照套路出牌” ,我們甚至可以不管 parent 的大小和位置隨意繪製。

二、MultiChildRenderObjectWidget

瞭解基本概念後,我們知道 自定義 Widget 佈局的核心在於自定義 RenderObject ,而在官方預設提供的佈局控制元件裡,大部分的佈局控制元件都是通過繼承 MultiChildRenderObjectWidget 實現,那麼一般情況下自定義佈局時,我們需要做什麼呢?

Flutter完整開發實戰詳解(十六、詳解自定義佈局實戰)

如上圖所示,一般情況下實現自定義佈局,我們會通過繼承 MultiChildRenderObjectWidgetRenderBox 這兩個 abstract 類實現,而 MultiChildRenderObjectElement 則負責關聯起它們, 除了此之外,還有有幾個關鍵的類 : ContainerRenderObjectMixinRenderBoxContainerDefaultsMixinContainerBoxParentData

RenderBox 我們知道是 RenderObject 的子類封裝,也是我們自定義 RenderObject 時經常需要繼承的,那麼其他的類分別是什麼含義呢?

1、ContainerRenderObjectMixin

故名思義,這是一個 mixin 類,ContainerRenderObjectMixin 的作用,主要是維護提供了一個雙連結串列的 children RenderObject

通過在 RenderBox 裡混入 ContainerRenderObjectMixin , 我們就可以得到一個雙連結串列的 children ,方便在我們佈局時,可以正向或者反向去獲取和管理 RenderObject 們 。

2、RenderBoxContainerDefaultsMixin

RenderBoxContainerDefaultsMixin 主要是對 ContainerRenderObjectMixin 的擴充,是對 ContainerRenderObjectMixin 內的 children 提供常用的預設行為和管理,介面如下所示:

	/// 計算返回第一個 child 的基線 ,常用於 child 的位置順序有關
	double defaultComputeDistanceToFirstActualBaseline(TextBaseline baseline)
	
	/// 計算返回所有 child 中最小的基線,常用於 child 的位置順序無關
	double defaultComputeDistanceToHighestActualBaseline(TextBaseline baseline)
	
	/// 觸控碰撞測試
	bool defaultHitTestChildren(BoxHitTestResult result, { Offset position })
	
	/// 預設繪製
	void defaultPaint(PaintingContext context, Offset offset)
	
	/// 以陣列方式返回 child 連結串列
	List<ChildType> getChildrenAsList()

複製程式碼

3、ContainerBoxParentData

ContainerBoxParentDataBoxParentData 的子類,主要是關聯了 ContainerDefaultsMixinBoxParentDataBoxParentDataRenderBox 繪製時所需的位置類。

通過 ContainerBoxParentData ,我們可以將 RenderBox 需要的 BoxParentData 和上面的 ContainerParentDataMixin 組合起來,事實上我們得到的 children 雙連結串列就是以 ParentData 的形式呈現出來的。

abstract class ContainerBoxParentData<ChildType extends RenderObject> extends BoxParentData with ContainerParentDataMixin<ChildType> { }
複製程式碼

4、MultiChildRenderObjectWidget

MultiChildRenderObjectWidget 的實現很簡單 ,它僅僅只是繼承了 RenderObjectWidget,然後提供了 children 陣列,並建立了 MultiChildRenderObjectElement

上面的 RenderObjectWidget 顧名思義,它是提供 RenderObjectWidget ,那有不存在 RenderObjectWidget 嗎?

有的,比如我們常見的 StatefulWidgetStatelessWidgetContainer 等,它們的 Element 都是 ComponentElementComponentElement 僅僅起到容器的作用,而它的 get renderObject 需要來自它的 child

5、MultiChildRenderObjectElement

前面的篇章我們說過 ElementBuildContext 的實現, 內部一般持有 WidgetRenderObject 並作為二者溝通的橋樑,那麼 MultiChildRenderObjectElement 就是我們自定義佈局時的橋樑了, 如下程式碼所示,MultiChildRenderObjectElement 主要實現瞭如下介面,其主要功能是對內部 childrenRenderObject ,實現了插入、移除、訪問、更新等邏輯:

	/// 下面三個方法都是利用 ContainerRenderObjectMixin 的 insert/move/remove 去操作
	/// ContainerRenderObjectMixin<RenderObject, ContainerParentDataMixin<RenderObject> 
	void insertChildRenderObject(RenderObject child, Element slot) 
	void moveChildRenderObject(RenderObject child, dynamic slot)         
	void removeChildRenderObject(RenderObject child)
	
	/// visitChildren 是通過 Element 中的 ElementVisitor 去迭代的
	/// 一般在 RenderObject get renderObject 會呼叫
	void visitChildren(ElementVisitor visitor)
	
	/// 新增忽略child _forgottenChildren.add(child);
	void forgetChild(Element child) 
	
	/// 通過 inflateWidget , 把 children 中 List<Widget> 對應的 List<Element>
	void mount(Element parent, dynamic newSlot)
	
	/// 通過 updateChildren 方法去更新得到  List<Element>
	void update(MultiChildRenderObjectWidget newWidget)
	
複製程式碼

所以 MultiChildRenderObjectElement 利用 ContainerRenderObjectMixin 最終將我們自定義的 RenderBoxWidget 關聯起來。

6、自定義流程

上述主要描述了 MultiChildRenderObjectWidgetMultiChildRenderObjectElement 和其他三個輔助類ContainerRenderObjectMixinRenderBoxContainerDefaultsMixinContainerBoxParentData 之間的關係。

瞭解幾個關鍵類之後,我們看一般情況下,實現自定義佈局的簡化流程是:

  • 1、自定義 ParentData 繼承 ContainerBoxParentData
  • 2、繼承 RenderBox ,同時混入 ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin 實現自定義RenderObject
  • 3、繼承 MultiChildRenderObjectWidget,實現 createRenderObjectupdateRenderObject 方法,關聯我們自定義的 RenderBox
  • 4、override RenderBoxperformLayoutsetupParentData 方法,實現自定義佈局。

當然我們可以利用官方的 CustomMultiChildLayout 實現自定義佈局,這個後面也會講到,現在讓我們先從基礎開始, 而上述流程中混入的 ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin ,在 RenderFlexRenderWrapRenderStack 等官方實現的佈局裡,也都會混入它們。

三、自定義佈局

自定義佈局就是在 performLayout 中實現的 child.layout 大小和 child.ParentData.offset 位置的賦值。

Flutter完整開發實戰詳解(十六、詳解自定義佈局實戰)

首先我們要實現類似如圖效果,我們需要自定義 RenderCloudParentData 繼承 ContainerBoxParentData ,用於記錄寬高和內容區域 :

class RenderCloudParentData extends ContainerBoxParentData<RenderBox> {
  double width;
  double height;

  Rect get content => Rect.fromLTWH(
        offset.dx,
        offset.dy,
        width,
        height,
      );
}

複製程式碼

然後自定義 RenderCloudWidget 繼承 RenderBox ,並混入 ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin 實現 RenderBox 自定義的簡化。

class RenderCloudWidget extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, RenderCloudParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, RenderCloudParentData> {
  RenderCloudWidget({
    List<RenderBox> children,
    Overflow overflow = Overflow.visible,
    double ratio,
  })  : _ratio = ratio,
        _overflow = overflow {
   ///新增所有 child 
    addAll(children);
  }
複製程式碼

如下程式碼所示,接下來主要看 RenderCloudWidgetoverride performLayout 中的實現,這裡我們只放關鍵程式碼:

  • 1、我們首先拿到 ContainerRenderObjectMixin 連結串列中的 firstChild ,然後從頭到位讀取整個連結串列。
  • 2、對於每個 child 首先通過 child.layout 設定他們的大小,然後記錄下大小之後。
  • 3、以容器控制元件的中心為起點,從內到外設定佈局,這是設定的時候,需要通過記錄的 Rect 判斷是否會重複,每次佈局都需要計算位置,直到當前 child 不在重複區域內。
  • 4、得到最終佈局內大小,然後設定整體居中。
///設定為我們的資料
@override
void setupParentData(RenderBox child) {
  if (child.parentData is! RenderCloudParentData)
    child.parentData = RenderCloudParentData();
}

@override
  void performLayout() {
    ///預設不需要裁剪
    _needClip = false;

    ///沒有 childCount 不玩
    if (childCount == 0) {
      size = constraints.smallest;
      return;
    }

    ///初始化區域
    var recordRect = Rect.zero;
    var previousChildRect = Rect.zero;

    RenderBox child = firstChild;

    while (child != null) {
      var curIndex = -1;

      ///提出資料
      final RenderCloudParentData childParentData = child.parentData;

      child.layout(constraints, parentUsesSize: true);

      var childSize = child.size;

      ///記錄大小
      childParentData.width = childSize.width;
      childParentData.height = childSize.height;

      do {
        ///設定 xy 軸的比例
        var rX = ratio >= 1 ? ratio : 1.0;
        var rY = ratio <= 1 ? ratio : 1.0;

        ///調整位置
        var step = 0.02 * _mathPi;
        var rotation = 0.0;
        var angle = curIndex * step;
        var angleRadius = 5 + 5 * angle;
        var x = rX * angleRadius * math.cos(angle + rotation);
        var y = rY * angleRadius * math.sin(angle + rotation);
        var position = Offset(x, y);

        ///計算得到絕對偏移
        var childOffset = position - Alignment.center.alongSize(childSize);

        ++curIndex;

        ///設定為遏制
        childParentData.offset = childOffset;

        ///判處是否交疊
      } while (overlaps(childParentData));

      ///記錄區域
      previousChildRect = childParentData.content;
      recordRect = recordRect.expandToInclude(previousChildRect);

      ///下一個
      child = childParentData.nextSibling;
    }

    ///調整佈局大小
    size = constraints
        .tighten(
          height: recordRect.height,
          width: recordRect.width,
        )
        .smallest;

    ///居中
    var contentCenter = size.center(Offset.zero);
    var recordRectCenter = recordRect.center;
    var transCenter = contentCenter - recordRectCenter;
    child = firstChild;
    while (child != null) {
      final RenderCloudParentData childParentData = child.parentData;
      childParentData.offset += transCenter;
      child = childParentData.nextSibling;
    }

    ///超過了嘛?
    _needClip =
        size.width < recordRect.width || size.height < recordRect.height;
  }
複製程式碼

其實看完程式碼可以發現,關鍵就在於你怎麼設定 child.parentDataoffset ,來控制其位置。

最後通過 CloudWidget 載入我們的 RenderCloudWidget 即可, 當然完整程式碼還需要結合 FittedBoxRotatedBox 簡化完成,具體可見 :GSYFlutterDemo

class CloudWidget extends MultiChildRenderObjectWidget {
  final Overflow overflow;
  final double ratio;

  CloudWidget({
    Key key,
    this.ratio = 1,
    this.overflow = Overflow.clip,
    List<Widget> children = const <Widget>[],
  }) : super(key: key, children: children);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderCloudWidget(
      ratio: ratio,
      overflow: overflow,
    );
  }

  @override
  void updateRenderObject(
      BuildContext context, RenderCloudWidget renderObject) {
    renderObject
      ..ratio = ratio
      ..overflow = overflow;
  }
}
複製程式碼

最後我們總結,實現自定義佈局的流程就是,實現自定義 RenderBoxperformLayout child 的 offset

四、CustomMultiChildLayout

CustomMultiChildLayout 是 Flutter 為我們封裝的簡化自定義佈局實現,它的內部同樣是通過 MultiChildRenderObjectWidget 實現,但是它為我們封裝了 RenderCustomMultiChildLayoutBoxMultiChildLayoutParentData ,並通過 MultiChildLayoutDelegate 暴露出需要自定義的地方。

Flutter完整開發實戰詳解(十六、詳解自定義佈局實戰)

使用 CustomMultiChildLayout 你只需要繼承 MultiChildLayoutDelegate ,並實現如下方法即可:

  
  void performLayout(Size size);

  bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate);

複製程式碼

通過繼承 MultiChildLayoutDelegate,並且實現 performLayout 方法,我們可以快速自定義我們需要的控制元件,當然便捷的封裝也代表了靈活性的喪失,可以看到 performLayout 方法中只有佈局自身的 Size 引數,所以完成上圖需求時,我們還需要 child 的大小和位置 ,也就是 childSizechildId

childSize 相信大家都能故名思義,那 childId 是什麼呢?

這就要從 MultiChildLayoutDelegate 的實現說起,MultiChildLayoutDelegate 內部會有一個 Map<Object, RenderBox> _idToChild; 物件,這個 Map 物件儲存著 Object idRenderBox 的對映關係,而在 MultiChildLayoutDelegate 中獲取 RenderBox 都需要通過 id 獲取。

_idToChild 這個 Map 是在 RenderBox performLayout 時,在 delegate._callPerformLayout 方法內建立的,建立後所用的 idMultiChildLayoutParentData 中的 id, MultiChildLayoutParentData 的 id ,可以通過 LayoutId 巢狀時自定義指定賦值。

而完成上述佈局,我們需要知道每個 child 的 index ,所以我們可以把 index 作為 id 設定給每個 child 的 LayoutId

所以我們可以通過 LayoutId 指定 id 為數字 index , 同時告知 delegate ,這樣我們就知道 child 順序和位置啦。

這個 id 是 Object 型別 ,所以你懂得,你可以賦予很多屬性進去。

如下程式碼所示,這樣在自定義的 CircleLayoutDelegate 中,就知道每個控制元件的 index 位置,也就是知道了,圓形佈局中每個 item 需要的位置。

我們只需要通過 index ,計算出 child 所在的角度,然後利用 layoutChildpositionChild 對每個item進行佈局即可,完整程式碼:GSYFlutterDemo

///自定義實現圓形佈局
class CircleLayoutDelegate extends MultiChildLayoutDelegate {
  final List<String> customLayoutId;

  final Offset center;

  Size childSize;

  CircleLayoutDelegate(this.customLayoutId,
      {this.center = Offset.zero, this.childSize});

  @override
  void performLayout(Size size) {
    for (var item in customLayoutId) {
      if (hasChild(item)) {
        double r = 100;

        int index = int.parse(item);

        double step = 360 / customLayoutId.length;

        double hd = (2 * math.pi / 360) * step * index;

        var x = center.dx + math.sin(hd) * r;

        var y = center.dy - math.cos(hd) * r;

        childSize ??= Size(size.width / customLayoutId.length,
            size.height / customLayoutId.length);

        ///設定 child 大小
        layoutChild(item, BoxConstraints.loose(childSize));

        final double centerX = childSize.width / 2.0;

        final double centerY = childSize.height / 2.0;

        var result = new Offset(x - centerX, y - centerY);

        ///設定 child 位置
        positionChild(item, result);
      }
    }
  }

  @override
  bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
}
複製程式碼

總的來說,第二種實現方式相對簡單,但是也喪失了一定的靈活性,可自定義控制程度更低,但是也更加規範與間接,同時我們自己實現 RenderBox 時,也可以用類似的 delegate 的方式做二次封裝,這樣的自定義佈局會更行規範可控。

自此,第十六篇終於結束了!(///▽///)

資源推薦

文章

《Flutter完整開發實戰詳解系列》

《移動端跨平臺開發的深度解析》

Flutter完整開發實戰詳解(十六、詳解自定義佈局實戰)

相關文章