不聽話的 Container

百瓶技術發表於2021-12-28


前言

在閱讀本文之前我們先來回顧下在 Flutter 開發過程中,是不是經常會遇到以下問題:

  • Container 設定了寬高無效
  • Column 溢位邊界,Row 溢位邊界
  • 什麼時候該使用 ConstrainedBox 和 UnconstrainedBox

每當遇到這種問題,我總是不斷地嘗試,費了九牛二虎之力,Widget 終於乖乖就範(達到理想效果)。痛定思過,我終於開始反抗(起來,不願做奴隸的人們,國歌唱起來~),為什麼 Container 設定寬高又無效了?Column 為什麼又溢位邊界了?懷揣著滿腔熱血,我終於鼓起勇氣首先從 Container 原始碼入手,逐一揭開它的神祕面紗。

佈局規則

在講本文之前,我們首先應該瞭解 Flutter 佈局中的以下規則:

  • 首先,上層 Widget 向下層 Widget 傳遞約束條件
  • 其次,下層 Widget 向上層 Widget 傳遞大小資訊
  • 最後,上層 Widget 決定下層 Widget 的位置

如果我們在開發時無法熟練運用這些規則,在佈局時就不能完全理解其原理,所以越早掌握這些規則越好。

  • Widget 會通過它的父級獲得自身約束。約束實際上就是 4 個浮點型別的集合:最大/最小寬度,以及最大/最小高度。
  • 然後這個 Widget 將會逐個遍歷它的 children 列表,向子級傳遞約束(子級之間的約束可能會有不同),然後詢問它的每一個子級需要用於佈局的大小。
  • 然後這個 Widget 將會對它子級 children 逐個進行佈局。
  • 最後,Widget 將會把它的大小資訊向上傳遞至父 Widget(包括其原始約束條件)。

嚴格約束(Tight)vs. 寬鬆約束(Loose)

嚴格約束就是獲得確切大小的選擇,換句話來說,它的最大/最小寬度是一致的,高度也是一樣。

// flutter/lib/src/rendering/box.dart
BoxConstraints.tight(Size size)
    : minWidth = size.width,
      maxWidth = size.width,
      minHeight = size.height,
      maxHeight = size.height;

寬鬆約束就是設定了最大寬度/高度,但是允許其子 Widget 獲得比它更小的任意大小,換句話說就是寬鬆約束的最小寬度/高度為 0。

// flutter/lib/src/rendering/box.dart
BoxConstraints.loose(Size size)
    : minWidth = 0.0,
      maxWidth = size.width,
      minHeight = 0.0,
      maxHeight = size.height;

Container 部分原始碼

首先奉上 Container 部分原始碼,下面我們會結合具體場景對原始碼進行逐一分析。

// flutter/lib/src/widgets/container.dart
class Container extends StatelessWidget {
  Container({
    Key key,
    this.alignment,
    this.padding,
    this.color,
    this.decoration,
    this.foregroundDecoration,
    double width,
    double height,
    BoxConstraints constraints,
    this.margin,
    this.transform,
    this.child,
    this.clipBehavior = Clip.none,
  })  : assert(margin == null || margin.isNonNegative),
        assert(padding == null || padding.isNonNegative),
        assert(decoration == null || decoration.debugAssertIsValid()),
        assert(constraints == null || constraints.debugAssertIsValid()),
        assert(clipBehavior != null),
        assert(
            color == null || decoration == null,
            'Cannot provide both a color and a decoration\n'
            'To provide both, use "decoration: BoxDecoration(color: color)".'),
        constraints = (width != null || height != null)
            ? constraints?.tighten(width: width, height: height) ??
                BoxConstraints.tightFor(width: width, height: height)
            : constraints,
        super(key: key);

  final Widget child;

  // child 元素在 Container 中的對齊方式
  final AlignmentGeometry alignment;

  // 填充內邊距
  final EdgeInsetsGeometry padding;

  // 顏色
  final Color color;

  // 背景裝飾
  final Decoration decoration;

  // 前景裝飾
  final Decoration foregroundDecoration;

  // 佈局約束
  final BoxConstraints constraints;

  // 外邊距
  final EdgeInsetsGeometry margin;

  // 繪製容器之前要應用的變換矩陣
  final Matrix4 transform;

  // decoration 引數具有 clipPath 時的剪輯行為
  final Clip clipBehavior;

  EdgeInsetsGeometry get _paddingIncludingDecoration {
    if (decoration == null || decoration.padding == null) return padding;
    final EdgeInsetsGeometry decorationPadding = decoration.padding;
    if (padding == null) return decorationPadding;
    return padding.add(decorationPadding);
  }

  @override
  Widget build(BuildContext context) {
    Widget current = child;

    if (child == null && (constraints == null || !constraints.isTight)) {
      current = LimitedBox(
        maxWidth: 0.0,
        maxHeight: 0.0,
        child: ConstrainedBox(constraints: const BoxConstraints.expand()),
      );
    }

    if (alignment != null)
      current = Align(alignment: alignment, child: current);

    final EdgeInsetsGeometry effectivePadding = _paddingIncludingDecoration;
    if (effectivePadding != null)
      current = Padding(padding: effectivePadding, child: current);

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

    if (decoration != null)
      current = DecoratedBox(decoration: decoration, child: current);

    if (foregroundDecoration != null) {
      current = DecoratedBox(
        decoration: foregroundDecoration,
        position: DecorationPosition.foreground,
        child: current,
      );
    }

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

    if (margin != null) current = Padding(padding: margin, child: current);

    if (transform != null)
      current = Transform(transform: transform, child: current);

    if (clipBehavior != Clip.none) {
      current = ClipPath(
        clipper: _DecorationClipper(
            textDirection: Directionality.of(context), decoration: decoration),
        clipBehavior: clipBehavior,
        child: current,
      );
    }

    return current;
  }
}

場景分析

場景一

Scaffold(
  appBar: AppBar(
    title: Text('Flutter Container'),
  ),
  body: Container(
    color: Colors.red,
  ),
),

在 Scaffold body 中單獨使用 Container,並且 Container 設定 color 為 Colors.red。

開啟 DevTools 進行元素檢查我們可以發現 Widget Tree 的結構 Container -> ColoredBox -> LimitedBox -> ConstrainedBox,最後會建立 RenderConstrainedBox,寬度和高度撐滿整個螢幕(除了 AppBar)。

那我們不禁會問,為什麼會這樣,我並沒有設定 Container 的寬度和高度,那麼我們再次回到上面的原始碼,如果 Container 沒有設定 child 引數並且滿足 constraints == null || !constraints.isTight 會返回一個 maxWidth 為 0,maxHeight 為 0 的 LimitedBox 的元素,並且 LimitedBox 的 child 是一個 constraints 引數為 const BoxConstraints.expand() 的 ConstrainedBox 的元素,所以 Container 會撐滿整個螢幕(除了 AppBar)。

// flutter/lib/src/widgets/container.dart

if (child == null && (constraints == null || !constraints.isTight)) {
    current = LimitedBox(
      maxWidth: 0.0,
      maxHeight: 0.0,
      child: ConstrainedBox(constraints: const BoxConstraints.expand()),
    );
  }
// flutter/lib/src/rendering/box.dart
const BoxConstraints.expand({
    double width,
    double height,
  }) : minWidth = width ?? double.infinity,
       maxWidth = width ?? double.infinity,
       minHeight = height ?? double.infinity,
       maxHeight = height ?? double.infinity;

場景二

Scaffold(
    appBar: AppBar(
      title: Text('Flutter Container'),
    ),
    body: Container(
      width: 100,
      height: 100,
      color: Colors.red,
    ),
  ),

在場景一的基礎上進行修改,此時給 Container 設定 width 為 100,height 為 100,color 為 Colors.red。

同樣開啟 DevTools 進行元素檢查我們可以發現 Widget Tree 的結構 Container -> ConstrainedBox -> ColorededBox,最後會建立 _RenderColoredBox,寬度和高度均為 100,顏色為紅色的正方形。

通過原始碼分析我們可以得出,如果 Container 中設定了 width、height 並且沒有設定 constraints 屬性,首先會在建構函式中對 constraints 進行賦值,所以 constraints = BoxConstraints.tightFor(width:100, height:100),然後會在外層巢狀一個 ColoredBox,最後再巢狀一個 ConstrainedBox 返回。

Container({
    Key key,
    this.alignment,
    this.padding,
    this.color,
    this.decoration,
    this.foregroundDecoration,
    double width,
    double height,
    BoxConstraints constraints,
    this.margin,
    this.transform,
    this.child,
    this.clipBehavior = Clip.none,
  }) : assert(margin == null || margin.isNonNegative),
       assert(padding == null || padding.isNonNegative),
       assert(decoration == null || decoration.debugAssertIsValid()),
       assert(constraints == null || constraints.debugAssertIsValid()),
       assert(clipBehavior != null),
       assert(color == null || decoration == null,
         'Cannot provide both a color and a decoration\n'
         'To provide both, use "decoration: BoxDecoration(color: color)".'
       ),
       constraints =
        (width != null || height != null)
          ? constraints?.tighten(width: width, height: height)
            ?? BoxConstraints.tightFor(width: width, height: height)
          : constraints,
       super(key: key);
if (decoration != null)
      current = DecoratedBox(decoration: decoration, child: current);

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

場景三

Scaffold(
    appBar: AppBar(
      title: Text('Flutter Container'),
    ),
    body: Container(
      width: 100,
      height: 100,
      color: Colors.red,
      alignment: Alignment.center,
    ),
  ),

接下來,我們在場景二的基礎上繼續新增 alignment:Alignment.center 屬性。

此時我們會發現為什麼沒有居中顯示呢?通過檢視 Align 原始碼不難發現,它是設定子 Widget 與自身的對齊方式。

A widget that aligns its child within itself and optionally sizes itself based on the child's size.

那麼此時我們再來改變程式碼,給當前 Container 新增子 Widget,終於達到了我們想要的居中效果。

Scaffold(
    appBar: AppBar(
      title: Text('Flutter Container'),
    ),
    body: Container(
      width: 100,
      height: 100,
      color: Colors.red,
      alignment: Alignment.center,
      child: Container(
        width: 10,
        height: 10,
        color: Colors.blue,
      ),
    ),
  ),

場景四

Scaffold(
    appBar: AppBar(
      title: Text('Flutter Container'),
    ),
    body: Center(
      child: Container(
        color: Colors.red,
        width: 200,
      ),
    ),
  ),

由於 Scaffold 中的 body 元素會撐滿整個螢幕(除了 AppBar),body 告訴 Center 佔滿整個螢幕,然後 Center 告訴 Container 可以變成任意大小,但是 Container 設定 width 為 200,所以 Container 的大小為寬度 200, 高度無限大。

The primary content of the scaffold.
Displayed below the [appBar], above the bottom of the ambient

場景五

Scaffold(
    appBar: AppBar(
      title: Text('Flutter Container'),
    ),
    body: Center(
      child: Row(
        children: <Widget>[
          Container(
            color: Colors.red,
            child: Text(
              '我是一段很長很長很長的文字',
              style: TextStyle(
                fontSize: 30,
              ),
            ),
          ),
          Container(
            color: Colors.red,
            child: Text(
              '我是一段很短的文字',
            ),
          ),
        ],
      ),
    ),
  ),

由於 Row 不會對其子元素施加任何約束,因此它的 children 很有可能太大而超出 Row 的寬度,在這種情況下,Row 就會顯示出溢位警告了。

場景六

Scaffold(
    appBar: AppBar(
      title: Text('Flutter Container'),
    ),
    body: Center(
      child: Container(
        constraints: BoxConstraints(
          maxHeight: 400,
          minHeight: 300,
          minWidth: 300,
          maxWidth: 400,
        ),
        color: Colors.red,
        width: 200,
      ),
    ),
  ),

這裡我們設定了 Container 的 constraints 屬性值為 BoxConstraints(minHeight:300, maxHeight:400, minWidth:300, maxWidth:400), 並且設定了 width 為 200。所以在建構函式初始化引數時,會進行設定 constraints = BoxConstraints(minHeight:300, maxHeight:400, minWidth:300, maxWidth:300) , 在 Container build 函式中會返回一個這樣的 Widget Tree 的結構(Container -> ConstrainedBox -> ColoredBox -> LimitedBox -> ConstrainedBox)。

此時 Center 告訴 Container 可以變成任意大小,但是 Container 設定 constraints 約束條件為寬度最小為 300,最大為 300,也就是寬度為 300, 最小高度為 300, 最大高度為 400,所以在 Container 中設定的 width 為 200 也就無效了,這個時候你也許會問,那高度到底是多少?答案是 400,因為 Container 中沒有設定 child ,滿足 child == null && (constraints == null || !constraints.isTight) 條件,所以會巢狀一個 ConstrainedBox(constraints: const BoxConstraints.expand() 所以高度會為最大高度 400。

// flutter/lib/src/rendering/box.dart
BoxConstraints tighten({ double width, double height }) {
  return BoxConstraints(
    minWidth: width == null ? minWidth : width.clamp(minWidth, maxWidth) as double,
    maxWidth: width == null ? maxWidth : width.clamp(minWidth, maxWidth) as double,
    minHeight: height == null ? minHeight : height.clamp(minHeight, maxHeight) as double,
    maxHeight: height == null ? maxHeight : height.clamp(minHeight, maxHeight) as double,
  );
}
// flutter/lib/src/rendering/box.dart
/// Whether there is exactly one width value that satisfies the constraints.
bool get hasTightWidth => minWidth >= maxWidth;

/// Whether there is exactly one height value that satisfies the constraints.
bool get hasTightHeight => minHeight >= maxHeight;

/// Whether there is exactly one size that satisfies the constraints.
@override
bool get isTight => hasTightWidth && hasTightHeight;
// flutter/lib/src/widgets/container.dart
if (child == null && (constraints == null || !constraints.isTight)) {
    current = LimitedBox(
      maxWidth: 0.0,
      maxHeight: 0.0,
      child: ConstrainedBox(constraints: const BoxConstraints.expand()),
    );
  }

最後

通過以上原始碼分析以及不同的場景,我們不難發現 Container 主要就是通過設定不同的引數,然後使用 LimitedBox、ConstrainedBox、Align、Padding、ColoredBox、DecoratedBox、Transform、ClipPath 等 Widget 進行組合而來。

更多精彩請關注我們的公眾號「百瓶技術」,有不定期福利呦!

相關文章