本篇將解析 Flutter 中自定義布局的原理,并帶你深入實(shí)戰(zhàn)自定義布局的流程,利用兩種自定義布局的實(shí)現(xiàn)方式,完成如下圖所示的界面效果,看完這一篇你將可以更輕松的對(duì) Flutter 為所欲為。
文章匯總地址:
一、前言
在之前的篇章我們講過 Widget 、Element 和 RenderObject 之間的關(guān)系,所謂的 自定義布局,事實(shí)上就是自定義 RenderObject 內(nèi) child 的大小和位置 ,而在這點(diǎn)上和其他框架不同的是,在 Flutter 中布局的核心并不是嵌套堆疊,Flutter 布局的核心是在于 Canvas ,我們所使用的 Widget ,僅僅是為了簡(jiǎn)化 RenderObject 的操作。
在《九、 深入繪制原理》的測(cè)試?yán)L制 中我們知道, 對(duì)于 Flutter 而言,整個(gè)屏幕都是一塊畫布,我們通過各種
Offset和Rect確定了位置,然后通過Canvas繪制 UI,而整個(gè)屏幕區(qū)域都是繪制目標(biāo),如果在child中我們 “不按照套路出牌” ,我們甚至可以不管parent的大小和位置隨意繪制。
二、MultiChildRenderObjectWidget
了解基本概念后,我們知道 自定義 Widget 布局的核心在于自定義 RenderObject ,而在官方默認(rèn)提供的布局控件里,大部分的布局控件都是通過繼承 MultiChildRenderObjectWidget 實(shí)現(xiàn),那么一般情況下自定義布局時(shí),我們需要做什么呢?
如上圖所示,一般情況下實(shí)現(xiàn)自定義布局,我們會(huì)通過繼承 MultiChildRenderObjectWidget 和 RenderBox 這兩個(gè) abstract 類實(shí)現(xiàn),而 MultiChildRenderObjectElement 則負(fù)責(zé)關(guān)聯(lián)起它們, 除了此之外,還有有幾個(gè)關(guān)鍵的類
: ContainerRenderObjectMixin 、 RenderBoxContainerDefaultsMixin 和 ContainerBoxParentData 。
RenderBox 我們知道是 RenderObject 的子類封裝,也是我們自定義 RenderObject 時(shí)經(jīng)常需要繼承的,那么其他的類分別是什么含義呢?
1、ContainerRenderObjectMixin
故名思義,這是一個(gè) mixin 類,ContainerRenderObjectMixin 的作用,主要是維護(hù)提供了一個(gè)雙鏈表的 children RenderObject 。
通過在 RenderBox 里混入 ContainerRenderObjectMixin , 我們就可以得到一個(gè)雙鏈表的 children ,方便在我們布局時(shí),可以正向或者反向去獲取和管理 RenderObject 們 。
2、RenderBoxContainerDefaultsMixin
RenderBoxContainerDefaultsMixin 主要是對(duì) ContainerRenderObjectMixin 的拓展,是對(duì) ContainerRenderObjectMixin 內(nèi)的 children 提供常用的默認(rèn)行為和管理,接口如下所示:
/// 計(jì)算返回第一個(gè) child 的基線 ,常用于 child 的位置順序有關(guān)
double defaultComputeDistanceToFirstActualBaseline(TextBaseline baseline)
/// 計(jì)算返回所有 child 中最小的基線,常用于 child 的位置順序無關(guān)
double defaultComputeDistanceToHighestActualBaseline(TextBaseline baseline)
/// 觸摸碰撞測(cè)試
bool defaultHitTestChildren(BoxHitTestResult result, { Offset position })
/// 默認(rèn)繪制
void defaultPaint(PaintingContext context, Offset offset)
/// 以數(shù)組方式返回 child 鏈表
List<ChildType> getChildrenAsList()
3、ContainerBoxParentData
ContainerBoxParentData 是 BoxParentData 的子類,主要是關(guān)聯(lián)了 ContainerDefaultsMixin 和 BoxParentData ,BoxParentData 是 RenderBox 繪制時(shí)所需的位置類。
通過 ContainerBoxParentData ,我們可以將 RenderBox 需要的 BoxParentData 和上面的 ContainerParentDataMixin 組合起來,事實(shí)上我們得到的 children 雙鏈表就是以 ParentData 的形式呈現(xiàn)出來的。
abstract class ContainerBoxParentData<ChildType extends RenderObject> extends BoxParentData with ContainerParentDataMixin<ChildType> { }
4、MultiChildRenderObjectWidget
MultiChildRenderObjectWidget 的實(shí)現(xiàn)很簡(jiǎn)單 ,它僅僅只是繼承了 RenderObjectWidget,然后提供了 children 數(shù)組,并創(chuàng)建了 MultiChildRenderObjectElement。
上面的
RenderObjectWidget顧名思義,它是提供RenderObject的Widget,那有不存在RenderObject的Widget嗎?有的,比如我們常見的
StatefulWidget、StatelessWidget、Container等,它們的Element都是ComponentElement,ComponentElement僅僅起到容器的作用,而它的get renderObject需要來自它的child。
5、MultiChildRenderObjectElement
前面的篇章我們說過 Element 是 BuildContext 的實(shí)現(xiàn), 內(nèi)部一般持有 Widget 、RenderObject 并作為二者溝通的橋梁,那么 MultiChildRenderObjectElement 就是我們自定義布局時(shí)的橋梁了, 如下代碼所示,MultiChildRenderObjectElement 主要實(shí)現(xiàn)了如下接口,其主要功能是對(duì)內(nèi)部 children 的 RenderObject ,實(shí)現(xiàn)了插入、移除、訪問、更新等邏輯:
/// 下面三個(gè)方法都是利用 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 會(huì)調(diào)用
void visitChildren(ElementVisitor visitor)
/// 添加忽略child _forgottenChildren.add(child);
void forgetChild(Element child)
/// 通過 inflateWidget , 把 children 中 List<Widget> 對(duì)應(yīng)的 List<Element>
void mount(Element parent, dynamic newSlot)
/// 通過 updateChildren 方法去更新得到 List<Element>
void update(MultiChildRenderObjectWidget newWidget)
所以 MultiChildRenderObjectElement 利用 ContainerRenderObjectMixin 最終將我們自定義的 RenderBox 和 Widget 關(guān)聯(lián)起來。
6、自定義流程
上述主要描述了 MultiChildRenderObjectWidget 、 MultiChildRenderObjectElement 和其他三個(gè)輔助類ContainerRenderObjectMixin 、 RenderBoxContainerDefaultsMixin 和 ContainerBoxParentData 之間的關(guān)系。
了解幾個(gè)關(guān)鍵類之后,我們看一般情況下,實(shí)現(xiàn)自定義布局的簡(jiǎn)化流程是:
- 1、自定義
ParentData繼承ContainerBoxParentData。 - 2、繼承
RenderBox,同時(shí)混入ContainerRenderObjectMixin和RenderBoxContainerDefaultsMixin實(shí)現(xiàn)自定義RenderObject。 - 3、繼承
MultiChildRenderObjectWidget,實(shí)現(xiàn)createRenderObject和updateRenderObject方法,關(guān)聯(lián)我們自定義的RenderBox。 - 4、override
RenderBox的performLayout和setupParentData方法,實(shí)現(xiàn)自定義布局。
當(dāng)然我們可以利用官方的 CustomMultiChildLayout 實(shí)現(xiàn)自定義布局,這個(gè)后面也會(huì)講到,現(xiàn)在讓我們先從基礎(chǔ)開始, 而上述流程中混入的 ContainerRenderObjectMixin 和 RenderBoxContainerDefaultsMixin ,在 RenderFlex 、RenderWrap 、RenderStack 等官方實(shí)現(xiàn)的布局里,也都會(huì)混入它們。
三、自定義布局
自定義布局就是在 performLayout 中實(shí)現(xiàn)的 child.layout 大小和 child.ParentData.offset 位置的賦值。
首先我們要實(shí)現(xiàn)類似如圖效果,我們需要自定義 RenderCloudParentData 繼承 ContainerBoxParentData ,用于記錄寬高和內(nèi)容區(qū)域 :
class RenderCloudParentData extends ContainerBoxParentData<RenderBox> {
double width;
double height;
Rect get content => Rect.fromLTWH(
offset.dx,
offset.dy,
width,
height,
);
}
然后自定義 RenderCloudWidget 繼承 RenderBox ,并混入 ContainerRenderObjectMixin 和 RenderBoxContainerDefaultsMixin 實(shí)現(xiàn) RenderBox 自定義的簡(jiǎn)化。
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 中的實(shí)現(xiàn),這里我們只放關(guān)鍵代碼:
- 1、我們首先拿到
ContainerRenderObjectMixin鏈表中的firstChild,然后從頭到位讀取整個(gè)鏈表。 - 2、對(duì)于每個(gè) child 首先通過
child.layout設(shè)置他們的大小,然后記錄下大小之后。 - 3、以容器控件的中心為起點(diǎn),從內(nèi)到外設(shè)置布局,這是設(shè)置的時(shí)候,需要通過記錄的
Rect判斷是否會(huì)重復(fù),每次布局都需要計(jì)算位置,直到當(dāng)前 child 不在重復(fù)區(qū)域內(nèi)。 - 4、得到最終布局內(nèi)大小,然后設(shè)置整體居中。
///設(shè)置為我們的數(shù)據(jù)
@override
void setupParentData(RenderBox child) {
if (child.parentData is! RenderCloudParentData)
child.parentData = RenderCloudParentData();
}
@override
void performLayout() {
///默認(rèn)不需要裁剪
_needClip = false;
///沒有 childCount 不玩
if (childCount == 0) {
size = constraints.smallest;
return;
}
///初始化區(qū)域
var recordRect = Rect.zero;
var previousChildRect = Rect.zero;
RenderBox child = firstChild;
while (child != null) {
var curIndex = -1;
///提出數(shù)據(jù)
final RenderCloudParentData childParentData = child.parentData;
child.layout(constraints, parentUsesSize: true);
var childSize = child.size;
///記錄大小
childParentData.width = childSize.width;
childParentData.height = childSize.height;
do {
///設(shè)置 xy 軸的比例
var rX = ratio >= 1 ? ratio : 1.0;
var rY = ratio <= 1 ? ratio : 1.0;
///調(diào)整位置
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);
///計(jì)算得到絕對(duì)偏移
var childOffset = position - Alignment.center.alongSize(childSize);
++curIndex;
///設(shè)置為遏制
childParentData.offset = childOffset;
///判處是否交疊
} while (overlaps(childParentData));
///記錄區(qū)域
previousChildRect = childParentData.content;
recordRect = recordRect.expandToInclude(previousChildRect);
///下一個(gè)
child = childParentData.nextSibling;
}
///調(diào)整布局大小
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;
}
其實(shí)看完代碼可以發(fā)現(xiàn),關(guān)鍵就在于你怎么設(shè)置 child.parentData 的 offset ,來控制其位置。
最后通過 CloudWidget 加載我們的 RenderCloudWidget 即可, 當(dāng)然完整代碼還需要結(jié)合 FittedBox 與 RotatedBox 簡(jiǎn)化完成,具體可見 :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;
}
}
最后我們總結(jié),實(shí)現(xiàn)自定義布局的流程就是,實(shí)現(xiàn)自定義 RenderBox 中 performLayout child 的 offset 。
四、CustomMultiChildLayout
CustomMultiChildLayout 是 Flutter 為我們封裝的簡(jiǎn)化自定義布局實(shí)現(xiàn),它的內(nèi)部同樣是通過 MultiChildRenderObjectWidget 實(shí)現(xiàn),但是它為我們封裝了 RenderCustomMultiChildLayoutBox 和 MultiChildLayoutParentData ,并通過 MultiChildLayoutDelegate 暴露出需要自定義的地方。
使用 CustomMultiChildLayout 你只需要繼承 MultiChildLayoutDelegate ,并實(shí)現(xiàn)如下方法即可:
void performLayout(Size size);
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate);
通過繼承 MultiChildLayoutDelegate,并且實(shí)現(xiàn) performLayout 方法,我們可以快速自定義我們需要的控件,當(dāng)然便捷的封裝也代表了靈活性的喪失,可以看到 performLayout 方法中只有布局自身的 Size 參數(shù),所以完成上圖需求時(shí),我們還需要 child 的大小和位置 ,也就是 childSize 和 childId 。
childSize 相信大家都能故名思義,那 childId 是什么呢?
這就要從 MultiChildLayoutDelegate 的實(shí)現(xiàn)說起,在 MultiChildLayoutDelegate 內(nèi)部會(huì)有一個(gè) Map<Object, RenderBox> _idToChild; 對(duì)象,這個(gè) Map 對(duì)象保存著 Object id 和 RenderBox 的映射關(guān)系,而在 MultiChildLayoutDelegate 中獲取 RenderBox 都需要通過 id 獲取。
_idToChild 這個(gè) Map 是在 RenderBox performLayout 時(shí),在 delegate._callPerformLayout 方法內(nèi)創(chuàng)建的,創(chuàng)建后所用的 id 為 MultiChildLayoutParentData 中的 id, 而 MultiChildLayoutParentData 的 id ,可以通過 LayoutId 嵌套時(shí)自定義指定賦值。
而完成上述布局,我們需要知道每個(gè) child 的 index ,所以我們可以把 index 作為 id 設(shè)置給每個(gè) child 的 LayoutId 。
所以我們可以通過 LayoutId 指定 id 為數(shù)字 index , 同時(shí)告知 delegate ,這樣我們就知道 child 順序和位置啦。
這個(gè) id 是
Object類型 ,所以你懂得,你可以賦予很多屬性進(jìn)去。
如下代碼所示,這樣在自定義的 CircleLayoutDelegate 中,就知道每個(gè)控件的 index 位置,也就是知道了,圓形布局中每個(gè) item 需要的位置。
我們只需要通過 index ,計(jì)算出 child 所在的角度,然后利用 layoutChild 和 positionChild 對(duì)每個(gè)item進(jìn)行布局即可,完整代碼:GSYFlutterDemo
///自定義實(shí)現(xiàn)圓形布局
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);
///設(shè)置 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);
///設(shè)置 child 位置
positionChild(item, result);
}
}
}
@override
bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
}
總的來說,第二種實(shí)現(xiàn)方式相對(duì)簡(jiǎn)單,但是也喪失了一定的靈活性,可自定義控制程度更低,但是也更加規(guī)范與間接,同時(shí)我們自己實(shí)現(xiàn) RenderBox 時(shí),也可以用類似的 delegate 的方式做二次封裝,這樣的自定義布局會(huì)更行規(guī)范可控。
自此,第十六篇終于結(jié)束了!(///▽///)
資源推薦
- Github : https://github.com/CarGuo
- 開源 Flutter 完整項(xiàng)目:https://github.com/CarGuo/GSYGithubAppFlutter
- 開源 Flutter 多案例學(xué)習(xí)型項(xiàng)目: https://github.com/CarGuo/GSYFlutterDemo
- 開源 Fluttre 實(shí)戰(zhàn)電子書項(xiàng)目:https://github.com/CarGuo/GSYFlutterBook
- 開源 React Native 項(xiàng)目:https://github.com/CarGuo/GSYGithubApp