🥡

SizedBox で理解する Flutter のレイアウト計算

2022/12/30に公開約11,700字

いきなりですが問題です。
以下の Flutter のコードを実行した場合、どのような UI になるでしょうか。

main.dart
void main() => runApp(
      const MaterialApp(
        home: Scaffold(
          body: SizedBox(
            // 幅300高さ500の箱の中に
            width: 300,
            height: 500,
            child: SizedBox(
              // 幅100高さ200の
              width: 100,
              height: 200,
              // 青い箱を置く
              child: ColoredBox(color: Colors.blue),
            ),
          ),
        ),
      ),
    );

答えは以下です。

サンプルコードの実行結果

端末の画面サイズが 390x844 なので、指定通り 100x200 の青い箱を置いたら画面の半分よりも小さく表示されそうですが、そうはなっていません。100x200SizedBox の親が指定している 300x500 が適用されていますね。

この記事では、なぜこのような挙動になるのかを実際にドキュメントやコードを読みながら考えることで、Flutter のレイアウト計算の仕組みについて理解を深めていきます。

なお、この記事は Flutter Advent Calendar 2022 の2日目です。実にひと月遅れでの投稿になってしまってすみません、、

あわせて読みたい

この記事を読む上で、2022 年の Flutter 関連のアドベントカレンダーにいくつか同じトピックの記事が投稿されていますので、先に紹介しておきます。

どれも RenderObject によるレイアウト計算や描画についてもっと理解を深められる記事ですので、ぜひ読んでみてください。

https://zenn.dev/mjhd/articles/81577dd49000eb
https://zenn.dev/kurogoma4d/articles/e0155b700ac2da
https://zenn.dev/heyhey1028/articles/532d0a9464c562

本題

では、冒頭のコードでなぜ青い箱が 300x500 で描画されてしまったのかについて考えていきましょう。

Constraints と Size

Flutter のレイアウト計算の基礎には Constraints go down. Sizes go up. Parent sets position. の考え方があります。

親の Widget(が生成した RenderObject )は子の Widget(が生成した RenderObject)に対して Constraints(制約)を渡し、子はその制約に基づいてレイアウト計算した結果得られた「自身のサイズ」を親に返却します。最後に親はそのサイズを見ながら配置する場所を決める、という流れです。

重要な点はいくつかありますが、特に我々アプリ開発者が UI を作る上で意識したいのが

  • Widget のサイズは親から与えられた制約を元に計算される
  • 親から与えられた制約をどう使うかは個々の Widget(が生成した RenderObject)の実装次第である

の2点です。

今回は、SizedBox と、SizedBox が生成する RenderObject である RenderConstrainedBox を例にとって、実際にコードを読みながらこの2点を理解していきたいと思います。

最大/最小 の 幅/高さ を持つ BoxConstraints

Constraints はその名の通りのクラスがフレームワークの object.dart に定義されています。

ただし、これは abstract なクラスで、RenderConstrainedBox が実際に扱うのは Constraints を継承した BoxConstraints というオブジェクトです。

BoxConstraints は、枠の「最小サイズ」と「最大サイズ」を保持するオブジェクトです。実装を一部抜粋すると以下のようになっています。

box.dart
class BoxConstraints extends Constraints {

  /// The minimum width that satisfies the constraints.
  final double minWidth;

  /// The maximum width that satisfies the constraints.
  ///
  /// Might be [double.infinity].
  final double maxWidth;

  /// The minimum height that satisfies the constraints.
  final double minHeight;

  /// The maximum height that satisfies the constraints.
  ///
  /// Might be [double.infinity].
  final double maxHeight;
}

注目したいのが、このオブジェクトが保持するのはあくまで「ここからここまでの大きさでレイアウトを作ってくださいね」という情報である ということです。最終的に画面に表示されるサイズそのものではありません。

Widget が最終的にどのサイズで画面に描画されるかは、この BoxConstraints を元に RenderConstrainedBox がレイアウト計算をした結果によって決定します。

レイアウトを計算する performLayout() メソッド

RenderObject は、performLayout() というメソッドをオーバーライドすることで各具象クラスごとのレイアウト計算ロジックを実装します。つまり、冒頭の 100x200 を指定した SizedBox がなぜその通りのサイズで表示されなかったのかについてはこのメソッドを追うことで理解できます。

RenderConstrainedBoxperformLayout() メソッドは以下のように実装されています。

proxy_box.dart
  
  void performLayout() {
    final BoxConstraints constraints = this.constraints;
    if (child != null) {
      child!.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child!.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }

今回は childnull ではない場合なので、最初の条件分岐の中に注目していきましょう。

なお今回のコードでは、childSizedBoxchild に渡した ColoredBox(color: Colors.blue) (が生成した RenderObject)のことになります。

最初の条件分岐の中のコードだけ抜粋すると、以下の2行になります。

child!.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
size = child!.size;

基本的には「子のレイアウト計算の結果得られたサイズを自身のサイズとする」という処理が書かれているのみでとてもシンプルではありますが、注目したいのは child.layout() の第1引数に渡している BoxConstraint の内容です。

_additionalConstraints.enforce(constraints) と書かれていますので、_additionalConstraintsconstraints、そして enforce() が何なのかをそれぞれ確認していきましょう。

_additinalConstraints

_additionalConstraints は、コードを追っていくと SizedBoxcreateRenderObject() メソッドで RenderConstrainedBox を作る際にコンストラクに渡しているオブジェクトです。

basic.dart
  
  RenderConstrainedBox createRenderObject(BuildContext context) {
    return RenderConstrainedBox(
      additionalConstraints: _additionalConstraints,
    );
  }

  BoxConstraints get _additionalConstraints {
    return BoxConstraints.tightFor(width: width, height: height);
  }

width height はまさに SizedBox を配置するときにコンストラクタで渡した値ですね。今回は width: 100 height: 200 を指定しています。

BoxConstraints.tightFor() コンストラクタを呼び出すことで、受け取った widthheight から BoxConstraints オブジェクトを生成しているみたいです。.tightFor() の実装も確認してみましょう。

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

今回のように width height のどちらも指定した場合は、max / min ともに与えられた値の widthheight の値となるようです。なお、BoxConstraints において tight とは「最小値と最大値が同じ値」である状態を指しています。反対は loose です。

When the minimum constraints and the maximum constraint in an axis are the
same, that axis is tightly constrained. See: [
BoxConstraints.tightFor], [BoxConstraints.tightForFinite], [tighten],
[hasTightWidth], [hasTightHeight], [isTight].

An axis with a minimum constraint of 0.0 is loose (regardless of the
maximum constraint; if it is also 0.0, then the axis is simultaneously tight
and loose!). See: [BoxConstraints.loose], [loosen].

https://api.flutter.dev/flutter/rendering/BoxConstraints-class.html

まとめると、 _additinalConstraints は「最小値、最大値ともに与えられた widthheight を保持する BoxConstraints オブジェクト」 ということになります。[1]

constraints

constraintsperformLayout() の最初のステップに書かれている通り、this.constraint です。定義にジャンプすると以下のように書かれており、

  /// The box constraints most recently received from the parent.
  
  BoxConstraints get constraints => super.constraints as BoxConstraints;

コメントから「親から受け取った BoxConstraints オブジェクト」であることが読み取れます。

今回の場合、親は 300x500 を指定した SizedBox ですので、(いろいろ説明は省きますが) 「最小値・最大値ともに 300x500 が指定された BoxConstraints オブジェクト」 となります。

.enforce()

最後に .enforce() メソッドです。これは _additionalConstraints オブジェクトが持つメソッドで、引数には親から受け取った constraints を渡す形になっています。

実装は以下の通りです。

box.dart
  /// Returns new box constraints that respect the given constraints while being
  /// as close as possible to the original constraints.
  BoxConstraints enforce(BoxConstraints constraints) {
    return BoxConstraints(
      minWidth: clampDouble(minWidth, constraints.minWidth, constraints.maxWidth),
      maxWidth: clampDouble(maxWidth, constraints.minWidth, constraints.maxWidth),
      minHeight: clampDouble(minHeight, constraints.minHeight, constraints.maxHeight),
      maxHeight: clampDouble(maxHeight, constraints.minHeight, constraints.maxHeight),
    );
  }

なお、clampDoublenum.clamp と同様の処理を行うメソッドで、第1引数で受け取った値が第2引数の最小値と第3引数の最大値の範囲内であればそのままの値を、そうでなければ指定した最小値 / 最大値に寄せた値を返却するメソッドです。

math.dart
double clampDouble(double x, double min, double max) {
  assert(min <= max && !max.isNaN && !min.isNaN);
  if (x < min) {
    return min;
  }
  if (x > max) {
    return max;
  }
  if (x.isNaN) {
    return max;
  }
  return x;
}

つまり、今回の値を当てはめると以下のような結果になります。

return BoxConstraints(
  minWidth: clampDouble(100, 300, 300), // -> 300
  maxWidth: clampDouble(100, 300, 300), // -> 300
  minHeight: clampDouble(200, 500, 500), // -> 500
  maxHeight: clampDouble(200, 500, 500), // -> 500
);

ということで、最小値、最大値とも親の SizedBox が指定した 300x500 に上書きされている(!)ことがわかりました。

performLayout() に戻ると、ここで生成された BoxConstraintsColoredBox に渡されてそのサイズの箱が青色で描画されることになります。今回の BoxConstraints は「最小で 300x500、最大で 300x500、つまりどんな場合でも 300x500 でよろしく!」という内容になっているため、結果として冒頭で見たような 300x500 の青い箱が表示される、というわけです。

いったんまとめ

ということで、ここまで SizedBox とその RenderObject である RenderConstrainedBox の実装を追いながら、なぜ冒頭のコードが指定した通りに 100x200 の青い箱を描画してくれないのかについて確認しました。

実際には冒頭のような「サイズを指定した SizedBoxchild に別のサイズを指定した SizedBox を渡す」ようなレイアウトを組むことは無いと思いますが、このように渡された BoxConstraints の内容と performLayout() の実装次第で直感と反するレイアウトが出来上がってしまう場合があることはイメージできたのではないでしょうか。

Flutter において Widget のサイズは親が渡した Constraints とそれを受け取った RenderObject の実装によって決定する ということは頭に入れておくと、Widget を使ってレイアウトを組み上げる時(特にイメージしたレイアウトがなかなか実現してくれない時)に役に立つのではないかと思います。

Widget 自体にサイズを指定しようとするのではなく、親からサイズを渡してあげる という発想があるだけでだいぶ build() メソッドの書き方が変わるのではないかと思います。

ついでに、いざとなったらフレームワークのコードを追えば「なぜそんなレイアウトが出来上がるのか」を確認できることも体験できましたね。

おまけ

ここまでの話が理解できると、これを応用していくつかの問題が理解、解決できます。

問題1: 冒頭のコード、100x200 の青い箱を表示するにはどうしたらいいの?

最小値が 0x0, 最大値が 300x500BoxConstraintschild に渡してくれる Widget を間に挟みましょう。たとえば Align が生成する RenderPositionedBox などは performLayout() で以下のように loose な BoxConstraints を子の RenderObject に渡してくれます。

shifted_box.dart
class RenderPositionedBox extends RenderAligningShiftedBox {
  
  void performLayout() {

    // いろいろ省略

    child!.layout(constraints.loosen(), parentUsesSize: true);
  }

これを使うと

main.dart
void main() => runApp(
      const MaterialApp(
        home: Scaffold(
          body: SizedBox(
            // 幅300高さ500の箱の中に
            width: 300,
            height: 500,
            child: Align(
              child: SizedBox(
                // 幅100高さ200の
                width: 100,
                height: 200,
                // 青い箱を置く
                child: ColoredBox(color: Colors.blue),
              ),
            ),
          ),
        ),
      ),
    );

Align を使って改善した画像

こんな感じで 100x200 のサイズと思われる青い箱が表示されました。ただし場所は Align のデフォルト値である「親の中心」になってしまうので、適宜 alignment に値を指定してあげる必要がありそうですね。

問題2: LayoutBuilder って Widget のサイズを教えてくれるんじゃないの?

LayoutBuilder が教えてくれるのは Widget のサイズではありません。ここまで見てきた通り、レイアウト計算の元になる BoxConstraints を渡してくれるのみです。そのため、その BoxConstraints を使って child がどんなレイアウト計算をし、その結果どんなサイズになるかは child 次第です。

LayoutBuilder「特定の Widget のサイズを使ってレイアウトを組む」を実現するための Widget ではない ことは覚えておくと良いでしょう。

問題3: OrientationBuilder を使えば端末の向きが検知できる?

できません、ということは ドキュメント にも記載されていますが、その理由はここまでの話を元に OrientationBuilder が何をしているかを見れば一目瞭然です。

class OrientationBuilder extends StatelessWidget {

  Widget _buildWithConstraints(BuildContext context, BoxConstraints constraints) {
    final Orientation orientation = constraints.maxWidth > constraints.maxHeight ? Orientation.landscape : Orientation.portrait;
    return builder(context, orientation);
  }

  
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: _buildWithConstraints);
  }
}

OrientaionBuilder は中で LayoutBuilder を使って BoxConstraints を取得し、さらにその maxHeightmaxWidth のどちらが大きいかで Orientation.landscapeOrientation.portrait かを判断しているだけです。

端末の向きは全く関係なく OrientationBuilder に渡された BoxConstraints のみで判断している上に、minWidthminHeight については全て無視して判定しています。そのことを理解して利用するようにしましょう。


以上です。

最初のうちはこのあたりのことを知らなくてもある程度 UI が構築できるようにうまく API がデザインされているのが Flutter の良いところではありますが、少し複雑なレイアウトを実現しようとしたり、Widget が画面をはみ出してしまったり、使いやすい共通 Widget を設計しようとするとこの辺りの知識の有無でだいぶ無駄な試行錯誤や「おまじない」を入れることになってしまいます。

ある程度 Flutter に慣れてきたタイミングで、このあたりの内部的なレイアウト計算ロジックについても理解を深めてみるとよいでしょう。

脚注
  1. 実装を読んでいただければわかる通り、値を指定しなかった場合は最小値が 0、最大値が double.infinity が指定され、子供のレイアウト次第で伸縮する設定になります。 ↩︎

GitHubで編集を提案

Discussion

ログインするとコメントできます