Flutter ConstraintLayout(約束布局)完全指南

我會用三篇文章來講透 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)。所有的基本約束如下:

  1. left
    1. toLeft
    2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
    3. toRight
  2. right
    1. toLeft
    2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
    3. toRight
  3. top
    1. toTop
    2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
    3. toBottom
  4. bottom
    1. toTop
    2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
    3. toBottom
  5. baseline
    1. toTop
    2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
    3. toBaseline
    4. 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,
)
水平方向上居中,頂部和 parent 的中心對齊

如果你把左右或上下都約束到同一個位置,那子元素就會相對于這個位置居中。

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,
)
水平方向和黃塊的右側(cè)對齊,頂部和黃塊的底部對齊

值得注意的是,如果將子元素的寬或高設(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。它們可以取值為:

  1. matchParent: 撐滿父布局
  2. wrapContent(默認(rèn)值): 自己有多大就撐多大,可設(shè)置最小或最大大小
  3. matchConstraint: 大小由約束決定
  4. 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'),
)
即時(shí)聲明 id

這里的 id 都是絕對 id,與之對應(yīng)的是相對 id:

  1. rId(3) 代表第三個子元素,以此類推
  2. rId(-1) 代表最后一個子元素
  3. rId(-2) 代表倒數(shù)第二個子元素,以此類推
  4. sId(-1) 代表上一個兄弟元素,以此類推
  5. 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 個包裝約束,分別是:

  1. topLeftTo
  2. topCenterTo
  3. topRightTo
  4. centerLeftTo
  5. centerTo
  6. centerRightTo
  7. bottomLeftTo
  8. bottomCenterTo
  9. bottomRightTo
  10. centerHorizontalTo
  11. centerVerticalTo
  12. outTopLeftTo
  13. outTopCenterTo
  14. outTopRightTo
  15. outCenterLeftTo
  16. outCenterRightTo
  17. outBottomLeftTo
  18. outBottomCenterTo
  19. outBottomRightTo
  20. centerTopLeftTo
  21. centerTopCenterTo
  22. centerTopRightTo
  23. centerCenterLeftTo
  24. centerCenterRightTo
  25. centerBottomLeftTo
  26. centerBottomCenterTo
  27. 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ò)大
)
擴(kuò)大點(diǎn)擊區(qū)域

深色區(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)的屬性是:

  1. widthHeightRatio: 1 / 3,
  2. 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。一般很少用到它。比如以下場景就需要用到它:

eIndex

圖片中的 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,
)
Barrier

引導(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

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]。

旋轉(zhuǎn)

基本約束和圖釘定位兩套約束系統(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):

  1. showHelperWidgets:輔助 Widget 包含 Guideline 和 Barrier,默認(rèn)情況下它們是不可見的,可開啟此開關(guān)讓它們可見
  2. showClickArea:當(dāng)使用 clickPadding 擴(kuò)大點(diǎn)擊區(qū)域時(shí),可開啟此開關(guān)查看實(shí)際的點(diǎn)擊區(qū)域
  3. showZIndex:可開啟此開關(guān)查看各子元素的 z-Index
  4. showChildDepth:在后面的原理分析文章中再作介紹
  5. debugPrintConstraints:在后面的原理分析文章中再作介紹
  6. 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ù)并決定以下事情:

  1. 是否需要重新計(jì)算約束?
  2. 是否需要重新布局?
  3. 是否需要重新繪制?
  4. 是否需要重排繪制順序?
  5. 是否需要重排事件分發(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,一起成長!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容