← 開發日常

Container - 一個你最熟悉又最陌生的 Widget

Article image

每次在開發的時候,碰到不如預期的狀況時,都是一個非常好的機會,可以讓我們更深了解某些事。

最近在開發的時候又碰到一些意料之外的事,經過一些實驗,終於定位了問題點。

讓我們看看以下這段程式碼,在這段程式碼中,我們指定了 Container 的大小為 300 x 300,同時也指定 child 中的 Image 大小為 30 x 30。

dart
Container(
  width: 300,
  height: 300,
  color: Colors.pinkAccent,
  child: Image.asset(
    "assets/images/blog.png",
    width: 30,
    height: 30,
  ),
)

大家可以在腦海中想像一下,這段程式碼在畫面中會呈現成什麼樣子?是否會覺得下圖這樣呢?

Article image

但結果卻是 Container 把 Image 也拉大到 300 x 300 了。

Article image

觀眾們可能會想,都設定圖片大小了,怎麼還是會被放到最大呢?

顯然肯定有個人在搞鬼,今天就來看看這個搞鬼的人:Container

Container 的行為

Container 作為開發 Flutter App 最常用的 Widget 之一,其實有著相當複雜的行為。如果我們看到官方文件,會發現其中有一段文字在描述 Container 的行為。

Container 的佈局行為按以下順序進行:

  • 優先遵循 alignment
  • 根據 child 的大小來決定自身大小。
  • 遵循 widthheightconstraints
  • 擴展以適配父級大小。
  • 嘗試盡量小化自身大小。

若是調整一下剛剛的例子,把 widthheight 拿掉。

dart
Container(
  color: Colors.pinkAccent,
  child: Image.asset(
    "assets/images/blog.png",
    width: 30,
    height: 30,
  ),
)

此時就會發現,Container 就遵循了第二條規則:根據 child 大小來決定自身大小

Article image

設定了 widthheight 後,到底實際發生了什麼事呢?讓我們深入 Container 的 build 方法一探究竟。

Container 的 build 方法

當我們設定了 widthheight 而沒有給 constraints 時,實際上 Container 會幫我們生成一個 BoxConstraints.tightFor(width: width, height: height)

dart
Container({
    // 省略...
  }) : // 省略 ...,
       constraints =
        (width != null || height != null)
          ? constraints?.tighten(width: width, height: height)
            ?? BoxConstraints.tightFor(width: width, height: height)
          : constraints;

tighFor 方法會限制 Widget 的大小,指定 Widget 的寬高,那這個 BoxConstraints 會用在哪邊呢?

dart
const BoxConstraints.tightFor({
    double? width,
    double? height,
  }) : minWidth = width ?? 0.0,
       maxWidth = width ?? double.infinity,
       minHeight = height ?? 0.0,
       maxHeight = height ?? double.infinity;

在 build 方法中,我們可以看到剛剛的 constraints 被放在 ConstrainedBox 中,用來限制 Container 的子 Widget。以上面的例子來說,被限制的 Widget 就是放入的 child 的 Image。

dart
@override
Widget build(BuildContext context) {
  // Container 的 build 方法
  // 省略 ...

  if (constraints != null) {
    current = ConstrainedBox(constraints: constraints!, child: current);
  }

  // 省略 ...

  return current!;
}

所以也就使得了 Image 被拉到與 Container 一樣大小。

使用 alignment

熟悉 Flutter 的開發人員肯定對這狀況也不陌生,知道加上 alignment 參數就能解決問題。

dart
Container(
  width: 300,
  height: 300,
  color: Colors.pinkAccent,
  alignment: Alignment.center,
  child: Image.asset(
    "assets/images/blog.png",
    width: 30,
    height: 30,
  ),
)

那為什麼在 Container 中加上 alignment 時,圖片就能維持當初設定的大小呢?讓我們再次看回 Container 的 build 方法中。

dart
@override
Widget build(BuildContext context) {
  // Container 的 build 方法
  // 省略 ...

  if (child == null && (constraints == null || !constraints!.isTight)) {
    // 省略 ...
  } else if (alignment != null) {
    current = Align(alignment: alignment!, child: current);
  }

  // 省略 ...

  if (constraints != null) {
    current = ConstrainedBox(constraints: constraints!, child: current);
  }

  // 省略 ...

  return current!;
}

alignment 不為 null 時,就會在 child 外面包上一層 Align,接著才是在 Align 外面再包上 ConstrainedBox。這樣一來,就使得實際被拉大的是 Align,而非 Image。

如果有認真看 Container 原始碼的觀眾可能會問,即便我沒有設定 alignment,但我有設定 color,而 ConstrainedBox 的下一層 child 應該是 ColoredBox,所以要拉大也是拉大 ColoredBox,而不應該是 Image 吧?

dart
@override
Widget build(BuildContext context) {
  // Container 的 build 方法
  // 省略 ...

  if (child == null && (constraints == null || !constraints!.isTight)) {
    // 省略 ...
  } else if (alignment != null) {
    current = Align(alignment: alignment!, child: current);
  }

  if (color != null) {
    current = ColoredBox(color: color!, child: current);
  }

  // 省略 ...

  if (constraints != null) {
    current = ConstrainedBox(constraints: constraints!, child: current);
  }

  // 省略 ...

  return current!;
}

的確,ColoredBox 確實會被拉大,但是 ColoredBox 也直接把上頭來的 constraints 直接轉送給了他的 child。在這兩種狀況中,雖然 Image 上層還有其他 Widget,但是卻有不同的結果。

若繼續深入 Align 與 ColoredBox 的佈局方式,很快就有答案了。

一路追蹤 ColoredBox 的原始碼:ColoredBox -> _RenderColoredBox -> RenderProxyBoxWithHitTestBehavior -> RenderProxyBox,最後可以發現 ColoredBox 繼承了 RenderProxyBox。在 RenderProxyBox 的佈局中,其實也就只是把自己收到的限制,直接原封不動的傳給子 Widget,所以即便中間多墊了一層 ColoredBox,也不能避免 Image 被拉大的效果。

dart
@override
void performLayout() {
  size = (child?..layout(constraints, parentUsesSize: true))?.size
      ?? computeSizeForNoChild(constraints);
  return;
}

接著看到 Align,Align 繼承了 RenderPositionedBox。在 RenderPositionedBox 的佈局中,我們可以發現,它從上頭接收到了限制,接著轉頭就將限制放寬,讓子 Widget 可以挑選他希望的大小。所以在 Align 中,Image 可以維持當初設定的 30 x 30 的大小。

dart
@override
void performLayout() {
  final BoxConstraints constraints = this.constraints;

  // 省略 ...

  if (child != null) {
    child!.layout(constraints.loosen(), parentUsesSize: true);

    // 省略 ...

  } else {...}
}

實驗放入不同的 Widget

最後讓我們做一些實驗,如果採用相同 Container 設定,但是在 child 中放入不同東西,看看會發生什麼事?

放入指定大小的 Container

與 Image 一樣,放入了指定大小的 Container,結果這個 Container 還是被拉大到 300 x 300。

dart
Container(
  width: 300,
  height: 300,
  color: Colors.pinkAccent,
  child: Container(
    width: 100,
    height: 100,
    color: Colors.blueAccent,
  ),
)
Article image

放入 TextButton

乍看之下,放入的 TextButton 好像沒被拉大,但實際上卻是有的,我們可以從 Hover 效果看出,按鈕還是被拉大了。

dart
Container(
  width: 300,
  height: 300,
  color: Colors.pinkAccent,
  child: TextButton(
    onPressed: () {},
    child: const Text('Click me'),
  ),
)
Article image

放入 Text

當我們試到 Text 的時候,卻發現 Text 好像就沒被拉大的問題,這又是怎麼一回事呢?

dart
Container(
  width: 300,
  height: 300,
  color: Colors.pinkAccent,
  child: const Text("Hello World"),
)
Article image

關於這個問題,有機會再讓我們深入探討,好奇的觀眾也可以先自行研究看看。

小結

Container 做為我們最常使用的 Widget 之一,了解他如何運作對於開發必然有些幫助。雖然不是每天都會碰到 Widget 排版不如預期的問題,但是每次碰上就會相當困擾,需要花許多時間嘗試才能解決。

追蹤原始碼,了解 Widget 底層的運作邏輯,能夠提供我們更多解決問題的思路。當未來碰上問題時,就能用正確又快速的方式解決,而不是留下更多的 Workaround。