Flutter 佈局真經

雨三樓發表於2021-03-30

參考: 深入理解 Flutter 佈局約束

flutter.cn/docs/develo…

總結了30個例子之後,我悟到了Flutter的佈局原理

juejin.cn/post/691415…

引言

對於剛剛接觸flutter的新手甚至是使用過一段時間的老手來說,佈局就像是一個熟悉的陌生人,我們無時不刻不與它打交道,但是它總會出現莫名其妙的問題。 在fluter中要實現一個佈局效果很簡單,各種佈局widget相互組合就能很輕易實現,可組合的方式往往不一而足,而這些佈局方案雖然都能表現出一樣的佈局效果,但在效率、可讀性、甚至對外部佈局的影響都不一樣,那如何從這些組合中挑選出最合適的佈局方案呢?這就要從佈局原理說起了……

佈局原理概覽

個人想法

對於佈局來說,必要條件只有兩個:大小和位置。 確定了大小和位置,佈局也就確定了,而在flutter中,由於其樹形結構的特性,各個節點的聯絡往往密不可分而又相互隔離。 密不可分是因為整個佈局樹的確定是相鄰節點互相確認、傳遞約束才完成的;相互隔離是因為每個節點其實只關心自身的大小以及其子節點的位置。這就導致糟糕的佈局設計下,很可能會有出現牽一髮而動全身的窘迫情況,這對程式設計是很不利的!

官方指導

widget佈局規則有三:

  1. 上層 widget 向下層 widget 傳遞約束條件
  2. 下層 widget 向上層 widget 傳遞大小資訊
  3. 上層 widget 決定下層 widget 的位置

更多細節:

  • Widget 會通過它的 父級 獲得自身的約束。約束實際上就是 4 個浮點型別的集合:最大/最小寬度,以及最大/最小高度。

  • 然後,這個 widget 將會逐個遍歷它的 children 列表。向子級傳遞 約束(子級之間的約束可能會有所不同),然後詢問它的每一個子級需要用於佈局的大小。

  • 然後,這個 widget 就會對它子級的 children 逐個進行佈局。(水平方向是 x 軸,豎直是 y 軸)

  • 最後,widget 將會把它的大小資訊向上傳遞至父 widget(包括其原始約束條件)。

官方對話(構建一個帶有padding的column):

Widget: “嘿!我的父級。我的約束是多少?”

Parent: “你的寬度必須在 80300 畫素之間,高度必須在 3085 之間。”

Widget: “嗯…我想要 5 個畫素的內邊距,這樣我的子級能最多擁有 290 個畫素寬度和 75 個畫素高度。”

Widget: “嘿,我的第一個子級,你的寬度必須要在 0290,長度在 075 之間。”

First child: “OK,那我想要 290 畫素的寬度,20 個畫素的長度。”

Widget: “嗯…由於我想要將我的第二個子級放在第一個子級下面,所以我們僅剩 55 個畫素的高度給第二個子級了。”

Widget: “嘿,我的第二個子級,你的寬度必須要在 0290,長度在 055 之間。”

Second child: “OK,那我想要 140 畫素的寬度,30 個畫素的長度。”

Widget: “很好。我的第一個子級將被放在 x: 5 & y: 5 的位置,而我的第二個子級將在 x: 80 & y: 25 的位置。”

Widget: “嘿,我的父級,我決定我的大小為 300 畫素寬度,60 畫素高度。”

什麼是約束?

約束Constraints 在Flutter中是一種_佈局協議_,Flutter中有兩大布局協議BoxConstraintsSliverConstraints。對於非滑動的控制元件例如Padding,Flex等一般都使用_BoxConstraints盒約束_。

約束可以分為:寬鬆約束(loose)和 嚴格約束(tight) 就widget佈局樹而言,約束預設向下傳遞,可有些佈局類複寫了performLayout改變了約束行為,將向下傳遞的約束資訊改變甚至重建,從而導致有些佈局行為變得不可預測,因此就需要掌握一些常用佈局的佈局原理,才能對整體的佈局行為加以分析。

案例分析   

案例一:Container的約束失效

ConstrainedBox(
	constraints: BoxConstraints.tightFor(
    width: double.infinity,
    height: double.infinity),
    child: Container(width: 100, height: 100, color: red)
)
複製程式碼

上邊的案例,展示了一個預期寬100,高100的紅色方塊,可實際上只會得到一個滿螢幕的紅色。 雖然Container內部有寬高的約束,但是在這裡是不起作用的,究其原因,是因為Container內部的佈局特性引起的,父部件傳遞一個tight緊佈局,而到了Container由於內部使用的ConstrainedBox,其佈局行為會做如下改變:

@override
// this.constraints 為父部件傳遞的約束
  void performLayout() {
    final BoxConstraints constraints = this.constraints; // this.constraints為父部件傳遞過來的約束
    if (child != null) {
      // 在這裡改變了約束,可能會導致自身設定的約束失效
      child!.layout(_additionalConstraints.enforce(constraints),
          parentUsesSize: true);
      size = child!.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }

// clamp方法會自行在約束範圍境內選擇
  BoxConstraints enforce(BoxConstraints constraints) {
    return BoxConstraints(
      minWidth: minWidth.clamp(constraints.minWidth, constraints.maxWidth),
      maxWidth: maxWidth.clamp(constraints.minWidth, constraints.maxWidth),
      minHeight: minHeight.clamp(constraints.minHeight, constraints.maxHeight),
      maxHeight: maxHeight.clamp(constraints.minHeight, constraints.maxHeight),
    );
  }

複製程式碼

按照先前的佈局原理來說,Container告訴父佈局自己需要一個100x100的空間,而父佈局也有充分的空間提供,那預期的效果應該會很好的呈現,可在這裡由於ConstrainedBox內部改變了佈局行為,導致預期結果不生效,ConstrainedBox會通過enforce函式衡量自身的約束屬性即 _additionalConstraints,和父佈局傳遞的約束,在其中取臨近值,在這裡由於父部件約束為:

BoxConstraints(
      minWidth: double.infinity,
      maxWidth: double.infinity,
      minHeight: double.infinity,
      maxHeight: double.infinity,
    );
複製程式碼

故所以_additionalConstraints的約束行為會被改成double.infinity~double.infinity之間的值即double.infinity。

案例二:奇怪的LimitedBox

現有這樣兩個場景:

ConstrainedBox(
  constraints: BoxConstraints.tightFor(
      width: double.infinity,
      height: double.infinity),
  child: UnconstrainedBox(
      child: LimitedBox(
        maxWidth: 100,
        child:
            Container(color: Colors.red, width: double.infinity, height: 100),
      ),
  )
)
複製程式碼
ConstrainedBox(
  constraints: BoxConstraints.tightFor(
      width: double.infinity,
      height: double.infinity),
  child: Center(
      child: LimitedBox(
        maxWidth: 100,
        child:
            Container(color: Colors.red, width: double.infinity, height: 100),
      ),
  )
)
複製程式碼

兩者的區別不是很明顯,只是前者的LimitedBox由UnconstrainedBox包裹,而後由Center包裹,但兩個例子所展示的UI卻大相徑庭: image.pngimage.png

這樣看來是Center致使LimitedBox的maxWidth約束失效了,為什麼會這樣呢?

// LimitedBox的佈局過程
  BoxConstraints _limitConstraints(BoxConstraints constraints) {
    return BoxConstraints(
      minWidth: constraints.minWidth,
      // 判斷是否有邊界,如果有,則maxWidth會失效
      maxWidth: constraints.hasBoundedWidth ? constraints.maxWidth : constraints.constrainWidth(maxWidth),
      minHeight: constraints.minHeight,
      maxHeight: constraints.hasBoundedHeight
          ? constraints.maxHeight
          : constraints.constrainHeight(maxHeight),
    );
  }

  @override
  void performLayout() {
    if (child != null) {
      final BoxConstraints constraints = this.constraints;
      child!.layout(_limitConstraints(constraints), parentUsesSize: true);
      size = constraints.constrain(child!.size);
    } else {
      size = _limitConstraints(constraints).constrain(Size.zero);
    }
  }

複製程式碼
  bool get hasBoundedWidth => maxWidth < double.infinity;
複製程式碼

引起LimitedBox的maxWidth失效的原因已經很明顯了, 關鍵就在於這行程式碼:

constraints.hasBoundedWidth ? constraints.maxWidth : constraints.constrainWidth(maxWidth)
複製程式碼

分為兩種情況:

  1. 父佈局傳遞constraints的hasBoundedWidth為true,這種情況下,maxWidth是肯定失效的
  2. 父佈局必須是寬鬆佈局才行,constraints.constrainWidth(maxWidth)會取constraints中的maxWidth臨近值,所以緊佈局也可能會使maxWidth失效。

所以這裡LimitedBox的maxWidth約束是否失效的關鍵,在於父部件傳遞來的佈局是否有邊界,而Center和UnconstrainedBox在傳遞給子部件約束資訊的處理上是有區別的: Center呼叫的constraints.loosen()方法,將當前約束進行鬆綁,而當前約束的hasBoundedWidth是不確定的!(依賴父部件)傳遞的約束。 UnconstrainedBox則重新構建的了一個無限大小的約束:childConstraints = const BoxConstraints(); 這樣子部件是肯定可以收到一個hasBoundedWidth = false 並且為寬鬆特性的約束,所以在這裡Center是有可能致使LimitedBox的maxWidth約束失效的,而UnconstrainedBox則可以百分百保證LimitedBox的佈局行為符合預期。

位置的確定

約束確定後,想要繪製上屏,還缺一個很重要的屬性——位置

ParentData

子部件的大小是通過父類傳遞的約束來確定的,同樣的,位置也是由父部件確定,以RenderBaseline為例,

  @override
  void performLayout() {
    if (child != null) {
      final BoxConstraints constraints = this.constraints;
      child!.layout(constraints.loosen(), parentUsesSize: true);
      final double childBaseline = child!.getDistanceToBaseline(baselineType)!;
      final double actualBaseline = baseline;
      final double top = actualBaseline - childBaseline;
      final BoxParentData childParentData = child!.parentData as BoxParentData;
      childParentData.offset = Offset(0.0, top);
      final Size childSize = child!.size;
      size = constraints.constrain(Size(childSize.width, top + childSize.height));
    } else {
      performResize();
    }

複製程式碼

可以看到在子部件不為空的情況下,先對子部件傳遞約束,隨之通過自身的特性,確定下一個Offset型別的位置點,然後傳遞給child的parentData,幫助child部件確定位置屬性,當子部件進行繪製的時候,直接讀取即可!

後續

Flutter佈局的基本原理無外乎父部件對子部件位置和大小的確定,不同型別的widget有不同的‘脾氣’,會在傳遞約束以及位置的時候做出不同的更改。 不過,在Flutter中,佈局還牽扯到很多其他功能,例如relayoutBoundary對於重繪的處理、部件點選事件的處理,這些都是和佈局原理密不可分的,在搞懂佈局原理的情況下,在去看這些實現,會輕鬆很多。

相關文章