本篇將解析 Flutter 中自定義佈局的原理,並帶你深入實戰自定義佈局的流程,利用兩種自定義佈局的實現方式,完成如下圖所示的介面效果,看完這一篇你將可以更輕鬆的對 Flutter 為所欲為。
前文:
一、前言
在之前的篇章我們講過 Widget
、Element
和 RenderObject
之間的關係,所謂的 自定義佈局,事實上就是自定義 RenderObject
內 child
的大小和位置 ,而在這點上和其他框架不同的是,在 Flutter 中佈局的核心並不是巢狀堆疊,Flutter 佈局的核心是在於 Canvas
,我們所使用的 Widget
,僅僅是為了簡化 RenderObject
的操作。
在《九、 深入繪製原理》的測試繪製 中我們知道, 對於 Flutter 而言,整個螢幕都是一塊畫布,我們通過各種
Offset
和Rect
確定了位置,然後通過Canvas
繪製 UI,而整個螢幕區域都是繪製目標,如果在child
中我們 “不按照套路出牌” ,我們甚至可以不管parent
的大小和位置隨意繪製。
二、MultiChildRenderObjectWidget
瞭解基本概念後,我們知道 自定義 Widget
佈局的核心在於自定義 RenderObject
,而在官方預設提供的佈局控制元件裡,大部分的佈局控制元件都是通過繼承 MultiChildRenderObjectWidget
實現,那麼一般情況下自定義佈局時,我們需要做什麼呢?
如上圖所示,一般情況下實現自定義佈局,我們會通過繼承 MultiChildRenderObjectWidget
和 RenderBox
這兩個 abstract
類實現,而 MultiChildRenderObjectElement
則負責關聯起它們, 除了此之外,還有有幾個關鍵的類
: ContainerRenderObjectMixin
、 RenderBoxContainerDefaultsMixin
和 ContainerBoxParentData
。
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
ContainerBoxParentData
是 BoxParentData
的子類,主要是關聯了 ContainerDefaultsMixin
和 BoxParentData
,BoxParentData
是 RenderBox
繪製時所需的位置類。
通過 ContainerBoxParentData
,我們可以將 RenderBox
需要的 BoxParentData
和上面的 ContainerParentDataMixin
組合起來,事實上我們得到的 children 雙連結串列就是以 ParentData
的形式呈現出來的。
abstract class ContainerBoxParentData<ChildType extends RenderObject> extends BoxParentData with ContainerParentDataMixin<ChildType> { }
複製程式碼
4、MultiChildRenderObjectWidget
MultiChildRenderObjectWidget
的實現很簡單 ,它僅僅只是繼承了 RenderObjectWidget
,然後提供了 children
陣列,並建立了 MultiChildRenderObjectElement
。
上面的
RenderObjectWidget
顧名思義,它是提供RenderObject
的Widget
,那有不存在RenderObject
的Widget
嗎?有的,比如我們常見的
StatefulWidget
、StatelessWidget
、Container
等,它們的Element
都是ComponentElement
,ComponentElement
僅僅起到容器的作用,而它的get renderObject
需要來自它的child
。
5、MultiChildRenderObjectElement
前面的篇章我們說過 Element
是 BuildContext
的實現, 內部一般持有 Widget
、RenderObject
並作為二者溝通的橋樑,那麼 MultiChildRenderObjectElement
就是我們自定義佈局時的橋樑了, 如下程式碼所示,MultiChildRenderObjectElement
主要實現瞭如下介面,其主要功能是對內部 children
的 RenderObject
,實現了插入、移除、訪問、更新等邏輯:
/// 下面三個方法都是利用 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
最終將我們自定義的 RenderBox
和 Widget
關聯起來。
6、自定義流程
上述主要描述了 MultiChildRenderObjectWidget
、 MultiChildRenderObjectElement
和其他三個輔助類ContainerRenderObjectMixin
、 RenderBoxContainerDefaultsMixin
和 ContainerBoxParentData
之間的關係。
瞭解幾個關鍵類之後,我們看一般情況下,實現自定義佈局的簡化流程是:
- 1、自定義
ParentData
繼承ContainerBoxParentData
。 - 2、繼承
RenderBox
,同時混入ContainerRenderObjectMixin
和RenderBoxContainerDefaultsMixin
實現自定義RenderObject
。 - 3、繼承
MultiChildRenderObjectWidget
,實現createRenderObject
和updateRenderObject
方法,關聯我們自定義的RenderBox
。 - 4、override
RenderBox
的performLayout
和setupParentData
方法,實現自定義佈局。
當然我們可以利用官方的 CustomMultiChildLayout
實現自定義佈局,這個後面也會講到,現在讓我們先從基礎開始, 而上述流程中混入的 ContainerRenderObjectMixin
和 RenderBoxContainerDefaultsMixin
,在 RenderFlex
、RenderWrap
、RenderStack
等官方實現的佈局裡,也都會混入它們。
三、自定義佈局
自定義佈局就是在 performLayout
中實現的 child.layout
大小和 child.ParentData.offset
位置的賦值。
首先我們要實現類似如圖效果,我們需要自定義 RenderCloudParentData
繼承 ContainerBoxParentData
,用於記錄寬高和內容區域 :
class RenderCloudParentData extends ContainerBoxParentData<RenderBox> {
double width;
double height;
Rect get content => Rect.fromLTWH(
offset.dx,
offset.dy,
width,
height,
);
}
複製程式碼
然後自定義 RenderCloudWidget
繼承 RenderBox
,並混入 ContainerRenderObjectMixin
和 RenderBoxContainerDefaultsMixin
實現 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);
}
複製程式碼
如下程式碼所示,接下來主要看 RenderCloudWidget
中override 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.parentData
的 offset
,來控制其位置。
最後通過 CloudWidget
載入我們的 RenderCloudWidget
即可, 當然完整程式碼還需要結合 FittedBox
與 RotatedBox
簡化完成,具體可見 :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;
}
}
複製程式碼
最後我們總結,實現自定義佈局的流程就是,實現自定義 RenderBox
中 performLayout
child 的 offset
。
四、CustomMultiChildLayout
CustomMultiChildLayout
是 Flutter 為我們封裝的簡化自定義佈局實現,它的內部同樣是通過 MultiChildRenderObjectWidget
實現,但是它為我們封裝了 RenderCustomMultiChildLayoutBox
和 MultiChildLayoutParentData
,並通過 MultiChildLayoutDelegate
暴露出需要自定義的地方。
使用 CustomMultiChildLayout
你只需要繼承 MultiChildLayoutDelegate
,並實現如下方法即可:
void performLayout(Size size);
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate);
複製程式碼
通過繼承 MultiChildLayoutDelegate
,並且實現 performLayout
方法,我們可以快速自定義我們需要的控制元件,當然便捷的封裝也代表了靈活性的喪失,可以看到 performLayout
方法中只有佈局自身的 Size
引數,所以完成上圖需求時,我們還需要 child 的大小和位置 ,也就是 childSize
和 childId
。
childSize
相信大家都能故名思義,那 childId
是什麼呢?
這就要從 MultiChildLayoutDelegate
的實現說起,在 MultiChildLayoutDelegate
內部會有一個 Map<Object, RenderBox> _idToChild;
物件,這個 Map
物件儲存著 Object id
和 RenderBox
的對映關係,而在 MultiChildLayoutDelegate
中獲取 RenderBox
都需要通過 id
獲取。
_idToChild
這個 Map
是在 RenderBox performLayout
時,在 delegate._callPerformLayout
方法內建立的,建立後所用的 id
為 MultiChildLayoutParentData
中的 id, 而 MultiChildLayoutParentData
的 id ,可以通過 LayoutId
巢狀時自定義指定賦值。
而完成上述佈局,我們需要知道每個 child 的 index ,所以我們可以把 index 作為 id 設定給每個 child 的 LayoutId
。
所以我們可以通過 LayoutId
指定 id 為數字 index , 同時告知 delegate ,這樣我們就知道 child 順序和位置啦。
這個 id 是
Object
型別 ,所以你懂得,你可以賦予很多屬性進去。
如下程式碼所示,這樣在自定義的 CircleLayoutDelegate
中,就知道每個控制元件的 index
位置,也就是知道了,圓形佈局中每個 item 需要的位置。
我們只需要通過 index
,計算出 child 所在的角度,然後利用 layoutChild
和 positionChild
對每個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 的方式做二次封裝,這樣的自定義佈局會更行規範可控。
自此,第十六篇終於結束了!(///▽///)
資源推薦
- Github : github.com/CarGuo
- 開源 Flutter 完整專案:github.com/CarGuo/GSYG…
- 開源 Flutter 多案例學習型專案: github.com/CarGuo/GSYF…
- 開源 Fluttre 實戰電子書專案:github.com/CarGuo/GSYF…
- 開源 React Native 專案:github.com/CarGuo/GSYG…