我會用三篇文章來講透 Flutter ConstraintLayout(約束布局),讓你用起來能夠得心應(yīng)手。分別是《Flutter ConstraintLayout 完全指南》、《Flutter ConstraintLayout 原理解析》、《Flutter ConstraintLayout 最佳實(shí)踐》。今天是第一篇。
https://github.com/hackware1993/Flutter_ConstraintLayout
前言
這是一份 Flutter ConstraintLayout 的完全指南,基于當(dāng)前最新的 1.6.3-stable 版本。由于現(xiàn)在的 API 已經(jīng)穩(wěn)定,所以本文可能會長期適用,后期僅會有很小的變動。
如果你有 Android ConstraintLayout 的經(jīng)驗(yàn),那你能上手更快,但也希望你能認(rèn)真看完全文,因?yàn)槁暶魇?UI 下的用法和 XML 里的用法截然不同。并且它有很多 Android ConstraintLayout 所不具有的優(yōu)秀特性。如果你是一名 iOSer,那就更應(yīng)該看完了,據(jù)我的了解,不論是 Android 下的 ConstraintLayout,還是 Flutter ConstraintLayout,它們都和 AutoLayout 有很大的不同。
約束布局的本質(zhì)
任何布局控件的核心都在于計(jì)算子控件的大小和位置。而約束布局的核心在于使用約束來計(jì)算子控件的大小和位置,而約束的本質(zhì)是對齊。即指定子控件的上、下、左、右、基線分別和哪些子控件(或 parent)的哪些位置去對齊。這直接決定了子控件的位置,并可能決定子控件的大?。ㄈ绻涌丶笮≈付?matchConstraint 的話)。
用過約束布局的人幾乎都回不去了,主要是因?yàn)樗袃纱髿⑹诛担皇撬茏尣季謱哟胃颖馄交?,這樣能帶來渲染性能的提升。二是強(qiáng)大靈活的布局(排版)能力能讓我們更快的開發(fā) UI。
我認(rèn)為后者更重要,因?yàn)檫@種布局能力是一種所見即所寫的能力,什么意思呢?意思是說設(shè)計(jì)同事在做設(shè)計(jì)的時(shí)候不會去考慮技術(shù)實(shí)現(xiàn)的細(xì)節(jié),他們只關(guān)心哪個元素在哪個元素的下面,哪個元素在哪個元素的右邊等等。對于他們來講,沒有嵌套這種說法。他們認(rèn)為所有的元素都在一個平面內(nèi),他們做設(shè)計(jì)的時(shí)候只是在一個平面內(nèi)去拖動、調(diào)整一些元素而已。
然而對于開發(fā)者來講,事情就不一樣了。
當(dāng)不使用約束布局時(shí),開發(fā)者必須要考慮實(shí)現(xiàn)方案,即怎么樣結(jié)合 Row、Column、Stack 或自定義控件去達(dá)到設(shè)計(jì)師的效果。這個考慮的過程直接拖慢了開發(fā)進(jìn)度。此外對于某些場景,開發(fā)需要的工作量也會進(jìn)一步拖慢開發(fā)進(jìn)度,而這些場景使用約束布局可能是分分鐘就能搞定的事。
當(dāng)使用約束布局時(shí)。所有的子元素都在一個平面內(nèi)。我們幾乎可以完全遵照視覺稿來布局,甚至不需要思考。哪個元素在哪個元素的下面,我們就把它約束在哪個元素的下面。哪個元素在哪個元素的右邊,我們就把它約束在哪個元素的右邊。這會大大地提高開發(fā)效率。
約束布局會在性能、靈活性、開發(fā)速度、可維護(hù)性方面全面超越傳統(tǒng)嵌套寫法,不信你可以試試。
基本用法
在 Flutter 中,父元素想給子元素施加布局參數(shù),標(biāo)準(zhǔn)的做法是使用 ParentDataWidget 將子元素包起來。例如 Stack 中的子元素有時(shí)要用 Positioned 包住以定位它們。ParentDataWidget 機(jī)制就跟 Android 中的 LayoutParams 是一個意思,它的原理很簡單,本文不做過多介紹,我后面會再寫一篇文章深入剖析它(連同 ParentData 機(jī)制)??傊虢o子元素施加 LayoutParams,就用 ParentDataWidget 包裹它,不同的布局會提供不同類型的 ParentDataWidget。Flutter ConstraintLayout 也提供了 ParentDataWidget,名為 Constrained。用法如下:
ConstraintLayout(
children: [
Constrained(
child: Container(
color: Colors.yellow,
),
constraint: Constraint(
size: matchParent,
),
)
],
),
所有給子元素施加的約束都存儲在 Constraint 中。除了內(nèi)置的 Helper Widget(Guideline、Barrier)以外,所有其他 Widget 都需要使用 Constrained 包裹。當(dāng)然這顯得有點(diǎn)麻煩,因此我提供了基于擴(kuò)展函數(shù)的簡便寫法,并推薦大家這么寫:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
centerTo: parent,
)
平均下來 applyConstraint 中一般只需要兩行代碼,一行指定元素大小,一行指定元素位置。
基本約束(對齊屬性)
Flutter ConstraintLayout 提供了兩套約束系統(tǒng),一套是基本約束,一套是圖釘定位(Pinned Position)。所有的基本約束如下:
- left
- toLeft
- toCenter(默認(rèn)偏移量為 0.5,代表中心)
- toRight
- right
- toLeft
- toCenter(默認(rèn)偏移量為 0.5,代表中心)
- toRight
- top
- toTop
- toCenter(默認(rèn)偏移量為 0.5,代表中心)
- toBottom
- bottom
- toTop
- toCenter(默認(rèn)偏移量為 0.5,代表中心)
- toBottom
- baseline
- toTop
- toCenter(默認(rèn)偏移量為 0.5,代表中心)
- toBaseline
- toBottom
示例:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
left: parent.left,
top: title.bottom,
)
這些約束具有自解釋能力,不做過多介紹。它們起到的作用是讓子元素的哪個位置去和其它子元素(或 parent)的哪個位置去對齊。
相比 Android 這里額外多了 toCenter:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
left: parent.center,
right: parent.center.bias(0.8),
)
center 可以指定偏移量,默認(rèn)為 0.5,為 0 時(shí)效果和 left 等同,為 1 時(shí)效果和 right 等同。
任何子元素在橫向和縱向都必須至少有一個約束。這樣才能定位它們。如果你在某一方向施加了兩個約束,那起到的效果就是居中。
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
left: parent.left,
right: parent.right,
top: parent.center,
)

如果你把左右或上下都約束到同一個位置,那子元素就會相對于這個位置居中。
Container(
color: Colors.yellow,
).applyConstraint(
id: anchor,
size: 200,
centerTo: parent,
),
Container(
color: Colors.green,
).applyConstraint(
size: 100,
left: anchor.right,
right: anchor.right,
top: anchor.bottom,
)

值得注意的是,如果將子元素的寬或高設(shè)置為 matchParent,則不能再設(shè)置基本約束。因?yàn)閮?nèi)部會自動設(shè)置:
if (width == matchParent) {
left = parent.left;
right = parent.right;
}
if (height == matchParent) {
top = parent.top;
bottom = parent.bottom;
baseline = null;
}
子元素大小設(shè)置
有 3 個屬性來設(shè)置子元素的大小,分別是 width、height、size。它們可以取值為:
- matchParent: 撐滿父布局
- wrapContent(默認(rèn)值): 自己有多大就撐多大,可設(shè)置最小或最大大小
- matchConstraint: 大小由約束決定
- fixed_size (>= 0): 固定大小
當(dāng) width 和 height 相同時(shí),給 size 賦值就行了,這樣能少寫一行代碼。內(nèi)部會做如下轉(zhuǎn)換:
if (size != null) {
width = size!;
height = size!;
}
每一種取值需要的約束數(shù)量是不同的,比如取值為 matchConstraint 時(shí),某一方向上必須要設(shè)置 2 個約束(完整約束),相關(guān)的規(guī)則如下:
int getMinimalConstraintCount(double size) {
if (size == matchParent) {
return 0; // 不能再設(shè)置約束
} else if (size == wrapContent || size >= 0) {
return 1; // 至少要設(shè)置 1 個約束
} else {
return 2; // matchConstraint,必須要設(shè)置 2 個約束
}
}
當(dāng)子元素的寬或高為 wrapContent(默認(rèn)) 時(shí),可使用 minWidth、maxWidth、minHeight、maxHeight 設(shè)置最小、最大寬高。默認(rèn)的最小值為 0,最大值為 matchParent。即默認(rèn)情況下子元素的寬高不能超過 parent 的寬高,你可以賦其它值來突破這個限制。
id 與相對 id
如果子元素需要和某個子元素的某個位置對齊,可以給后者指定一個 id。先聲明,再賦值和引用:
// 聲明
ConstraintId anchor = ConstraintId('anchor');
Container(
color: Colors.yellow,
).applyConstraint(
id: anchor, // 賦值
);
Container(
color: Colors.green,
).applyConstraint(
left: anchor.right, // 引用
top: anchor.bottom, // 引用
);
這里需要保證字符串的唯一性。一般將 id 聲明為 StatelessWidget 或 State 的成員變量,但也可即時(shí)聲明:
Container(
color: Colors.yellow,
).applyConstraint(
id: cId('yellowArea'),
size: 200,
centerTo: parent,
),
Container(
color: Colors.green,
).applyConstraint(
size: 100,
topLeftTo: cId('yellowArea'),
)

這里的 id 都是絕對 id,與之對應(yīng)的是相對 id:
- rId(3) 代表第三個子元素,以此類推
- rId(-1) 代表最后一個子元素
- rId(-2) 代表倒數(shù)第二個子元素,以此類推
- sId(-1) 代表上一個兄弟元素,以此類推
- sId(1) 代表下一個兄弟元素,以此類推
比如上例可以改造為:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
centerTo: parent,
),
Container(
color: Colors.green,
).applyConstraint(
size: 100,
topLeftTo: rId(0), // 引用第 0 個子元素
)
或
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
centerTo: parent,
),
Container(
color: Colors.green,
).applyConstraint(
size: 100,
topLeftTo: sId(-1), // 引用上一個兄弟元素
)
相對 id 主要是為懶癌患者設(shè)計(jì)的,因?yàn)槊莻€麻煩事。如果已經(jīng)為子元素定義了絕對 id,則不能再使用相對 id 來引用他們。
包裝約束
包裝約束是為了簡化使用而設(shè)計(jì)的,顧名思義它是對基本約束的包裝,它在運(yùn)行時(shí)會轉(zhuǎn)化為基本約束,比如以下代碼:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
topLeftTo: parent,
),
等價(jià)于:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
left: parent.left,
top: parent.top,
),
再比如:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
centerTo: parent,
)
等價(jià)于:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
left: parent.left,
top: parent.top,
right: parent.right,
bottom: parent.bottom,
)
一共有 27 個包裝約束,分別是:
- topLeftTo
- topCenterTo
- topRightTo
- centerLeftTo
- centerTo
- centerRightTo
- bottomLeftTo
- bottomCenterTo
- bottomRightTo
- centerHorizontalTo
- centerVerticalTo
- outTopLeftTo
- outTopCenterTo
- outTopRightTo
- outCenterLeftTo
- outCenterRightTo
- outBottomLeftTo
- outBottomCenterTo
- outBottomRightTo
- centerTopLeftTo
- centerTopCenterTo
- centerTopRightTo
- centerCenterLeftTo
- centerCenterRightTo
- centerBottomLeftTo
- centerBottomCenterTo
- centerBottomRightTo
其中一部分是自解釋的,另一部分可參考下圖:

或者進(jìn)入 Flutter ConstraintLayout 在線示例(https://constraintlayout.flutterfirst.cn)去查看。
clickPadding
快速擴(kuò)大子元素的點(diǎn)擊區(qū)域而無需改變子元素的實(shí)際大小。這意味著你可以完全遵照視覺稿來布局,而不用為了考慮點(diǎn)擊區(qū)域而做額外的事情,這會提升一定的開發(fā)效率。用法如下:
Container(
color: Colors.red,
).applyConstraint(
size: 200,
centerTo: parent,
clickPadding: const EdgeInsets.all(10), // 四周都擴(kuò)大 10 dp,每個邊都可分別擴(kuò)大
)

深色區(qū)域?yàn)樽釉氐膶?shí)際大小,淺色區(qū)域?yàn)閿U(kuò)大后的點(diǎn)擊區(qū)域。淺色區(qū)域內(nèi)的觸摸事件會映射到深色區(qū)域。
這也意味著子元素之間可以在不增加嵌套的情況下共享點(diǎn)擊區(qū)域,有時(shí)可能需要結(jié)合 e-index(后面會講到) 使用。
可見性控制
visibility 屬性用來控制子元素的可見性,可取值為 visible、invisible、gone。這個其實(shí)沒什么可講的,大家都懂。對于 gone 來講,有時(shí)可能更好的辦法是使用條件表達(dá)式來阻止 Widget 的創(chuàng)建。用 gone 的好處是可以保留狀態(tài)(如果該 Widget 是 StatefulWidget 的話)。
有一點(diǎn)值得一提的是在 Flutter 里 RenderObject 任何時(shí)候都必須被 layout,但可以不 paint。因此 gone 的 Widget 也會被 layout,只不過它會縮小成一個點(diǎn)。依賴它的其他 Widget 的 goneMargin 會生效。gone 和 invisible 的 Widget 都不會被 paint。
margin
有三個屬性來設(shè)置 margin,分別是 margin、goneMargin、percentageMargin。
margin 和 goneMargin 都可以為負(fù)數(shù),使用方法為:
margin: const EdgeInsets.only(left: 10),
當(dāng)依賴的元素的可見性為 gone 或者其某一邊的實(shí)際大小為 0 時(shí),goneMargin 就會生效,否則 margin 會生效,即便其自身的可見性為 gone。也就是說,當(dāng)元素自身可見性為 gone 時(shí),它自身的 margin 仍然會生效,因?yàn)樗?layout 了。這和 Android 是不同的。
percentageMargin 是為了支持 Guideline 而開發(fā)的一個內(nèi)部功能,現(xiàn)把它暴露出來,興許對你有用。其默認(rèn)為 false,如果設(shè)置為 true,則 margin、goneMargin 的值只能在 [-1, 1] 的范圍內(nèi)?;鶞?zhǔn)是 parent 的寬或高。
zIndex
搞過前端的應(yīng)該都知道這個屬性,它即視圖層級,值越大子元素就越顯示在上層。默認(rèn)值是子元素的下標(biāo)。一般來講,越顯示在上層就越先接收點(diǎn)擊事件。
如果兩個子元素的 zIndex 相同,則下標(biāo)越大,越顯示在上層。
translate
當(dāng)需要對子元素進(jìn)行平移時(shí),除了可以使用 Flutter 自帶的 Transform Widget,還可以使用約束布局內(nèi)置的平移功能:
Container(
color: Colors.yellow,
).applyConstraint(
size: 200,
centerTo: parent,
id: anchor,
),
Container(
color: Colors.green,
).applyConstraint(
size: 100,
topLeftTo: anchor,
translate: const Offset(100, 100),
)

默認(rèn)情況下,平移只會移動自身,那些依賴自己的元素不會跟著被平移,如果也想讓他們跟著移動,請將 translateConstraint 設(shè)置為 true。
和 margin 一樣,平移也支持 percentageTranslate,但基準(zhǔn)是自身的寬或高。
百分比布局
當(dāng)大小被設(shè)置為 matchConstraint 時(shí),就會啟用百分比布局,默認(rèn)的百分比是 1(100%)。相關(guān)的屬性是 widthPercent,heightPercent,widthPercentageAnchor,heightPercentageAnchor。
PercentageAnchor 的取值有兩種,parent 和 constraint(默認(rèn)),當(dāng)取值為前者時(shí),代表以 parent 的寬或高為基準(zhǔn),因此只需要在對應(yīng)方向添加一個約束即可。當(dāng)取值為后者時(shí),代表以約束的區(qū)間寬度為基準(zhǔn),因此需要在對應(yīng)的方向添加兩個約束(完整約束)。示例代碼如下:
Container(
color: Colors.redAccent,
alignment: Alignment.center,
child: const Text('width: 50% of parent\nheight: 200'),
).applyConstraint(
width: matchConstraint,
height: 200,
widthPercent: 0.5,
bottomCenterTo: parent,
),
Container(
color: Colors.blue,
alignment: Alignment.center,
child: const Text('width: 300\nheight: 30% of parent'),
).applyConstraint(
width: 300,
height: matchConstraint,
heightPercent: 0.3,
topLeftTo: parent,
heightPercentageAnchor: PercentageAnchor.parent,
)

偏移
當(dāng)某一方向有兩個約束時(shí)(完整約束),可使用 horizontalBias、verticalBias 調(diào)整偏移量,范圍為 [0, 1],默認(rèn)值為 0.5,代表居中。比如給上例中的紅色區(qū)域添加 horizontalBias: 0 時(shí),它就跑到最左邊了。

布局回調(diào)
如果你想做一些布局、繪制監(jiān)聽,那就使用 layoutCallback、paintCallback 吧,它們的定義如下:
typedef OnLayoutCallback = void Function(RenderBox renderBox, Rect rect);
typedef OnPaintCallback = void Function(RenderBox renderBox, Offset offset,
Size size, Offset? anchorPoint, double? angle);
這兩個回調(diào)是施加給局部的某個子元素的,而不是全局的 ConstraintLayout。
等比例布局
當(dāng)需要等比例布局時(shí),除了可以使用 Flutter 自帶的 FractionallySizedBox,還可以使用約束布局提供的等比例布局功能。相關(guān)的屬性是:
- widthHeightRatio: 1 / 3,
- ratioBaseOnWidth: true, (默認(rèn)值是 null,代表自動推斷,未確定邊的大小會根據(jù)確定邊的大小和 widthHeightRatio
計(jì)算出來。未確定邊的大小必須設(shè)置為 matchConstraint,確定邊的大小可以為 matchParent,固定大?。?gt;=0),matchConstraint)
示例如下:
Container(
color: Colors.yellow,
).applyConstraint(
width: 200,
height: matchConstraint,
widthHeightRatio: 2 / 1,
centerTo: parent,
)

請不要把百分比布局和等比例布局搞混了,前者是寬高由外部決定,后者是寬高由自身決定。
eIndex
eIndex 是事件分發(fā)順序,它的默認(rèn)值是 zIndex。一般很少用到它。比如以下場景就需要用到它:

圖片中的 ListView item 布局追求了一層嵌套,白色圓角區(qū)域(一個 Container)和其他元素是平級的且位于最底層(zIndex 最?。?,但點(diǎn)擊整個 item 需要跳轉(zhuǎn)新頁面。因此這里把點(diǎn)擊事件設(shè)置到 Container,并讓它的 eIndex 變大(比如賦值 1000)。這樣就能在不增加嵌套的情況下整體響應(yīng)事件了。
但這種追求一層嵌套的寫法并不是在所有情況下都適用,比如按下 ListView item 時(shí) item 內(nèi)的背景、文本要變色,就必須得增加嵌套了。
柵欄(屏障)Barrier
搞過 Android 的都知道這個,它和 Android 中的柵欄完全一樣。目的是為了在幾個子元素之間生成一條虛擬的屏障,然后別的元素可以相對于這個屏障去布局,示例如下:
Container(
color: const Color(0xFF005BBB),
).applyConstraint(
id: leftChild,
size: 200,
topLeftTo: parent,
),
Container(
color: const Color(0xFFFFD500),
).applyConstraint(
id: rightChild,
width: 200,
height: matchConstraint,
centerRightTo: parent,
heightPercent: 0.5,
verticalBias: 0,
),
Barrier(
id: barrier,
direction: BarrierDirection.bottom, // 方向
referencedIds: [leftChild, rightChild], // 引用的子元素的 id,此處的 id 不能為相對 id
),
const Text(
'Align to barrier',
style: TextStyle(
fontSize: 40,
color: Colors.blue,
),
).applyConstraint(
centerHorizontalTo: parent,
top: barrier.bottom,
)

引導(dǎo)線 Guideline
這個也和 Android 中的 Guideline 完全一樣。示例如下:
Container(
color: const Color(0xFF005BBB),
).applyConstraint(
width: matchParent,
height: matchConstraint,
top: parent.top,
bottom: guideline.top,
),
Guideline(
id: guideline,
horizontal: true, // 方向,true 為水平,false 為垂直
guidelinePercent: 0.5,
),
Container(
color: const Color(0xFFFFD500),
).applyConstraint(
width: matchParent,
height: matchConstraint,
top: guideline.bottom,
bottom: parent.bottom,
),
const Text(
'Align to Guideline',
style: TextStyle(
fontSize: 40,
color: Colors.white,
),
textAlign: TextAlign.center,
).applyConstraint(
centerHorizontalTo: parent,
bottom: guideline.bottom,
)

Guideline 有四個屬性可以設(shè)置,分別是 horizontal、guidelinePercent、guidelineBegin、guidelineEnd。后三個屬性都是相對于 parent 而言。
圖釘定位
Flutter ConstraintLayout 提供了兩套約束系統(tǒng),一套是基本約束,一套是圖釘定位(Pinned Position)。提供圖釘定位主要是為了讓布局更靈活一些。設(shè)想一下,要想定位一個元素,除了給它指定橫向、縱向?qū)R到哪里以外,我認(rèn)為還有一種辦法是讓它的哪個位置釘在哪里。把一個東西釘在哪里,從邏輯上來講會產(chǎn)生兩個孔,一個孔穿過元素自身,一個孔穿過目標(biāo)位置。因此圖釘定位的 API 主要是用來描述兩個孔的位置,示例如下:
Container(
color: Colors.yellow,
).applyConstraint(
id: anchor,
size: 200,
centerTo: parent,
),
Container(
color: Colors.cyan,
).applyConstraint(
size: 100,
pinnedInfo: PinnedInfo(
anchor,
Anchor(0.2, AnchorType.percent, 0.2, AnchorType.percent),
Anchor(1, AnchorType.percent, 1, AnchorType.percent),
),
),
Container(
decoration: const BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
).applyConstraint(
size: 10,
centerBottomRightTo: anchor,
)

例子中的意思是青色區(qū)域橫豎 20% 的點(diǎn)釘在黃色區(qū)域的右下角,紅點(diǎn)即為孔的位置。PinnedInfo 類完整的構(gòu)造函數(shù)如下:
PinnedInfo(
this.targetId,
this.selfAnchor,
this.targetAnchor, {
this.angle = 0.0,
});
targetId 和 targetAncnor 描述了目標(biāo)孔的位置,selfAnchor 描述了自身孔的位置。一個物體被圖釘釘住后,它就有了個轉(zhuǎn)軸,就能旋轉(zhuǎn)起來,因此 angle 代表旋轉(zhuǎn)的角度,范圍為 [0.0, 360.0]。

基本約束和圖釘定位兩套約束系統(tǒng)是互斥的,只能用其一。如果你使用基本約束時(shí)也想讓元素轉(zhuǎn)起來,可以使用 Pinned Translate:
translate: PinnedTranslate(PinnedInfo(
null,
Anchor(0.2, AnchorType.percent, 0.2, AnchorType.percent),
null,
angle: 90,
))
如果不設(shè)置目標(biāo)孔的位置,則相對于自身孔的位置旋轉(zhuǎn)。
隨意定位(Arbitrary Position)
盡管 ConstraintLayout 的布局能力已經(jīng)很靈活了,但我還想更進(jìn)一步,讓你能夠自定義!因此我暴露了布局的接口給你:
typedef CalcSizeCallback = BoxConstraints Function(
RenderBox parent, List<ConstrainedNode> anchors);
typedef CalcOffsetCallback = Offset Function(
RenderBox parent, ConstrainedNode self, List<ConstrainedNode> anchors);
使用方法如下:
Container(
color: Colors.orange,
).applyConstraint(
size: matchConstraint, // 寬高必須設(shè)置為 matchConstraint
anchors: [sId(-1)], // 依賴的元素,只有依賴的元素都布局好了,才會調(diào)用 callback
calcSizeCallback: (parent, anchors) {
// 動態(tài)返回子元素的大小
},
calcOffsetCallback: (parent, self, anchors) {
// 動態(tài)返回子元素的 Offset
},
)
具體可參考在線示例?;诖耍銕缀蹩梢詾樗麨?。
約束與 Widget 分離
約束和布局其實(shí)是可以分離的,這個特性借鑒了 Compose 的約束布局。
@override
Widget build(BuildContext context) {
return Scaffold(
body: ConstraintLayout(
childConstraints: [
Constraint(
id: cId('title'),
size: 200,
centerTo: parent,
),
],
children: [
Container(
color: Colors.red,
).applyConstraintId(
id: cId('title'),
),
],
),
);
}
分離后,你就可以動態(tài)地改變一個子元素的約束了。此外,可以在 childConstraints 中聲明 Helper Widgets(Guideline、Barrier),這樣可以避免創(chuàng)建 RenderObject。具體請看下文中的性能優(yōu)化部分。
Flutter ConstraintLayout 提供了兩個 ParentDataWidget,分別是 Constrained 和 UnConstrained。applyConstraint 是對 Constrained 的包裝,applyConstraintId 是對 UnConstrained 的包裝。前者聲明完整的約束信息,后者只聲明子元素和約束的對應(yīng)關(guān)系。
布局調(diào)試
Flutter ConstraintLayout 提供了布局調(diào)試的功能,提供了以下的調(diào)試開關(guān):
- showHelperWidgets:輔助 Widget 包含 Guideline 和 Barrier,默認(rèn)情況下它們是不可見的,可開啟此開關(guān)讓它們可見
- showClickArea:當(dāng)使用 clickPadding 擴(kuò)大點(diǎn)擊區(qū)域時(shí),可開啟此開關(guān)查看實(shí)際的點(diǎn)擊區(qū)域
- showZIndex:可開啟此開關(guān)查看各子元素的 z-Index
- showChildDepth:在后面的原理分析文章中再作介紹
- debugPrintConstraints:在后面的原理分析文章中再作介紹
- showLayoutPerformanceOverlay:開啟此開關(guān)后,會將每一幀的 layout、paint 耗時(shí)繪制出來,單位為微秒(us,1ms 等于 1000us)
自身大小設(shè)置
Flutter ConstraintLayout 默認(rèn)會撐滿父布局,但你也可以自定義它的大小,我提供了 width、height、size 三個屬性來設(shè)置約束布局自身的大小:
// fixed size、matchParent(默認(rèn)值)、wrapContent
final double width;
final double height;
/// When size is non-null, both width and height are set to size
final double? size;
開放式語法(Open Grammar)
開放式語法是一個比較大的創(chuàng)新,有了它你可以使用任何 Dart 的語法來組織子元素,而不僅僅局限于集合中的 if和集合中的 for這種簡單表達(dá)式。示例如下:
@override
Widget build(BuildContext context) {
return Scaffold(
body: ConstraintLayout().open(() {
int i = 0;
while (i < 100) {
Text("$i").applyConstraint(
left: parent.left,
top: i == 0 ? parent.top : sId(-1).bottom,
);
i++;
}
}),
);
你需要調(diào)用 open 擴(kuò)展函數(shù),就可以在函數(shù)的作用域中使用任何語法組織子元素。上面的幾行簡單的代碼就構(gòu)造了一個列表:

約束提示
當(dāng)前的版本有完善的約束缺失、非法、冗余提示。一旦約束有問題,要么會觸發(fā) assert 錯誤,要么會直接拋出異常。由于 Flutter 中異常并不會導(dǎo)致程序崩潰,因此即便拋出異常后也無法中斷后續(xù)的布局、繪制流程,而在這個階段會觸發(fā)更多的異常。因此當(dāng)你發(fā)現(xiàn)布局展示不符合預(yù)期時(shí),大概率是內(nèi)部拋異常了或 assert 出錯了。你要去看看異常日志,一般翻到最頂部才能看到根本原因。
瀑布流、網(wǎng)格、列表
Flutter ConstraintLayout 可以當(dāng)成一個比較通用的布局平臺,你只需要生成約束,把布局、繪制交給它就好。我把這個稱作擴(kuò)展。瀑布流、網(wǎng)格、列表就是以擴(kuò)展的形式提供的,具體請參考在線示例吧。比如上例中的列表就是個擴(kuò)展,它生成約束充當(dāng)了 Column 的能力。
圓形定位
和 Android 的約束布局一樣,F(xiàn)lutter ConstraintLayout 也提供了圓形定位。但兩者的實(shí)現(xiàn)方式截然不同,后者并沒有做特殊的支持,只是增加了一個 Util 函數(shù):
/// For circle position
Offset circleTranslate({
required double radius,
/// [0.0,360.0]
required double angle,
}) {
assert(radius >= 0 && radius != double.infinity);
double xTranslate = sin((angle / 180) * pi) * radius;
double yTranslate = -cos((angle / 180) * pi) * radius;
return Offset(xTranslate, yTranslate);
}
并配合包裝約束 centerTo 一起使用,示例如下:
for (int i = 0; i < 12; i++)
Text(
'${i + 1}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 25,
),
).applyConstraint(
centerTo: rId(0),
translate: circleTranslate(
radius: 205,
angle: (i + 1) * 30,
),
)

性能優(yōu)化
1.當(dāng)布局復(fù)雜時(shí),如果子元素需要頻繁重繪,可以考慮使用 RepaintBoundary。當(dāng)然合成 Layer 也有開銷,所以需要合理使用。
class OffPaintExample extends StatelessWidget {
const OffPaintExample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: ConstraintLayout(
children: [
Container(
color: Colors.orangeAccent,
).offPaint().applyConstraint(
width: 200,
height: 200,
topRightTo: parent,
)
],
),
),
);
}
}
此處的 offPaint 擴(kuò)展方法是對 RepaintBoundary 的簡單封裝。
2.盡量使用 const Widget。如果你沒法將子元素聲明為 const 而它自身又不會改變??梢允褂脙?nèi)置的 OffBuildWidget 來避免子元素重復(fù) build。
class OffBuildExample extends StatelessWidget {
const OffBuildExample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: ConstraintLayout(
children: [
// 子樹不會改變
Container(
color: Colors.orangeAccent,
).offBuild(id: 'id').applyConstraint(
width: 200,
height: 200,
topRightTo: parent,
)
],
),
),
);
}
}
3.子元素會自動成為 RelayoutBoundary 除非它的寬或高是 wrapContent??梢宰们榈臏p少 wrapContent 的使用,因?yàn)楫?dāng) ConstraintLayout
自身的大小發(fā)生變化時(shí)(通常是窗口大小發(fā)生變化,移動端幾乎不存在此類情況),所有寬或高為 wrapContent
的子元素都會被重新布局。而其他元素由于傳遞給它們的約束未發(fā)生變化,不會觸發(fā)真正的布局。
4.如果你在 children 列表中使用 Guideline 或 Barrier, Element 和 RenderObject 將不可避免的被創(chuàng)建,它們會被布局但不會繪制。此時(shí)你可以使用
GuidelineDefine 或 BarrierDefine 來優(yōu)化, Element 和 RenderObject 就不會再創(chuàng)建了。
class BarrierExample extends StatelessWidget {
const BarrierExample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
ConstraintId leftChild = ConstraintId('leftChild');
ConstraintId rightChild = ConstraintId('rightChild');
ConstraintId barrier = ConstraintId('barrier');
return Scaffold(
body: ConstraintLayout(
childConstraints: [
BarrierDefine(
id: barrier,
direction: BarrierDirection.bottom,
referencedIds: [leftChild, rightChild],
),
],
children: [
Container(
color: const Color(0xFF005BBB),
).applyConstraint(
id: leftChild,
width: 200,
height: 200,
topLeftTo: parent,
),
Container(
color: const Color(0xFFFFD500),
).applyConstraint(
id: rightChild,
width: 200,
height: matchConstraint,
centerRightTo: parent,
heightPercent: 0.5,
verticalBias: 0,
),
const Text(
'Align to barrier',
style: TextStyle(
fontSize: 40,
color: Colors.blue,
),
).applyConstraint(
centerHorizontalTo: parent,
top: barrier.bottom,
)
],
),
);
}
}
5.每一幀,ConstraintLayout 會比對參數(shù)并決定以下事情:
- 是否需要重新計(jì)算約束?
- 是否需要重新布局?
- 是否需要重新繪制?
- 是否需要重排繪制順序?
- 是否需要重排事件分發(fā)順序?
這些比對不會成為性能瓶頸,但會提高 CPU 占用率。如果你對 ConstraintLayout 內(nèi)部原理足夠了解(后面會寫一篇原理分析的文章),你可以使用 ConstraintLayoutController 來手動觸發(fā)這些操作,停止參數(shù)比對。
class ConstraintControllerExampleState extends State<ConstraintControllerExample> {
double x = 0;
double y = 0;
ConstraintLayoutController controller = ConstraintLayoutController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(
title: 'Constraint Controller',
codePath: 'example/constraint_controller.dart',
),
body: ConstraintLayout(
controller: controller,
children: [
GestureDetector(
child: Container(
color: Colors.pink,
alignment: Alignment.center,
child: const Text('box draggable'),
),
onPanUpdate: (details) {
setState(() {
x += details.delta.dx;
y += details.delta.dy;
controller.markNeedsPaint();
});
},
).applyConstraint(
size: 200,
centerTo: parent,
translate: Offset(x, y),
)
],
),
);
}
}
結(jié)束語
好了,以上就是 Flutter ConstraintLayout(約束布局)的所有功能介紹,趕緊收藏起來吧。
https://github.com/hackware1993/Flutter_ConstraintLayout
我是 hackware,關(guān)注我的公眾號:FlutterFirst,一起成長!