SizedBox で理解する Flutter のレイアウト計算
いきなりですが問題です。
以下の Flutter のコードを実行した場合、どのような UI になるでしょうか。
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
の青い箱を置いたら画面の半分よりも小さく表示されそうですが、そうはなっていません。100x200
の SizedBox
の親が指定している 300x500
が適用されていますね。
この記事では、なぜこのような挙動になるのかを実際にドキュメントやコードを読みながら考えることで、Flutter のレイアウト計算の仕組みについて理解を深めていきます。
なお、この記事は Flutter Advent Calendar 2022 の2日目です。実にひと月遅れでの投稿になってしまってすみません、、
あわせて読みたい
この記事を読む上で、2022 年の Flutter 関連のアドベントカレンダーにいくつか同じトピックの記事が投稿されていますので、先に紹介しておきます。
どれも RenderObject によるレイアウト計算や描画についてもっと理解を深められる記事ですので、ぜひ読んでみてください。
本題
では、冒頭のコードでなぜ青い箱が 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
は、枠の「最小サイズ」と「最大サイズ」を保持するオブジェクトです。実装を一部抜粋すると以下のようになっています。
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
がなぜその通りのサイズで表示されなかったのかについてはこのメソッドを追うことで理解できます。
RenderConstrainedBox
の performLayout()
メソッドは以下のように実装されています。
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);
}
}
今回は child
が null
ではない場合なので、最初の条件分岐の中に注目していきましょう。
なお今回のコードでは、child
は SizedBox
の child
に渡した ColoredBox(color: Colors.blue)
(が生成した RenderObject)のことになります。
最初の条件分岐の中のコードだけ抜粋すると、以下の2行になります。
child!.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
size = child!.size;
基本的には「子のレイアウト計算の結果得られたサイズを自身のサイズとする」という処理が書かれているのみでとてもシンプルではありますが、注目したいのは child.layout()
の第1引数に渡している BoxConstraint
の内容です。
_additionalConstraints.enforce(constraints)
と書かれていますので、_additionalConstraints
と constraints
、そして enforce()
が何なのかをそれぞれ確認していきましょう。
_additinalConstraints
_additionalConstraints
は、コードを追っていくと SizedBox
の createRenderObject()
メソッドで RenderConstrainedBox
を作る際にコンストラクに渡しているオブジェクトです。
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()
コンストラクタを呼び出すことで、受け取った width
と height
から BoxConstraints
オブジェクトを生成しているみたいです。.tightFor()
の実装も確認してみましょう。
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 ともに与えられた値の width
と height
の値となるようです。なお、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
は「最小値、最大値ともに与えられた width
と height
を保持する BoxConstraints
オブジェクト」 ということになります。[1]
constraints
constraints
は performLayout()
の最初のステップに書かれている通り、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
を渡す形になっています。
実装は以下の通りです。
/// 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),
);
}
なお、clampDouble
は num.clamp
と同様の処理を行うメソッドで、第1引数で受け取った値が第2引数の最小値と第3引数の最大値の範囲内であればそのままの値を、そうでなければ指定した最小値 / 最大値に寄せた値を返却するメソッドです。
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()
に戻ると、ここで生成された BoxConstraints
が ColoredBox
に渡されてそのサイズの箱が青色で描画されることになります。今回の BoxConstraints
は「最小で 300x500
、最大で 300x500
、つまりどんな場合でも 300x500
でよろしく!」という内容になっているため、結果として冒頭で見たような 300x500
の青い箱が表示される、というわけです。
いったんまとめ
ということで、ここまで SizedBox
とその RenderObject である RenderConstrainedBox
の実装を追いながら、なぜ冒頭のコードが指定した通りに 100x200
の青い箱を描画してくれないのかについて確認しました。
実際には冒頭のような「サイズを指定した SizedBox
の child
に別のサイズを指定した SizedBox
を渡す」ようなレイアウトを組むことは無いと思いますが、このように渡された BoxConstraints
の内容と performLayout()
の実装次第で直感と反するレイアウトが出来上がってしまう場合があることはイメージできたのではないでしょうか。
Flutter において Widget のサイズは親が渡した Constraints
とそれを受け取った RenderObject
の実装によって決定する ということは頭に入れておくと、Widget を使ってレイアウトを組み上げる時(特にイメージしたレイアウトがなかなか実現してくれない時)に役に立つのではないかと思います。
Widget 自体にサイズを指定しようとするのではなく、親からサイズを渡してあげる という発想があるだけでだいぶ build()
メソッドの書き方が変わるのではないかと思います。
ついでに、いざとなったらフレームワークのコードを追えば「なぜそんなレイアウトが出来上がるのか」を確認できることも体験できましたね。
おまけ
ここまでの話が理解できると、これを応用していくつかの問題が理解、解決できます。
問題1: 冒頭のコード、100x200 の青い箱を表示するにはどうしたらいいの?
最小値が 0x0
, 最大値が 300x500
の BoxConstraints
を child
に渡してくれる Widget を間に挟みましょう。たとえば Align
が生成する RenderPositionedBox
などは performLayout()
で以下のように loose な BoxConstraints
を子の RenderObject に渡してくれます。
class RenderPositionedBox extends RenderAligningShiftedBox {
void performLayout() {
// いろいろ省略
child!.layout(constraints.loosen(), parentUsesSize: true);
}
これを使うと
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),
),
),
),
),
),
);
こんな感じで 100x200
のサイズと思われる青い箱が表示されました。ただし場所は Align
のデフォルト値である「親の中心」になってしまうので、適宜 alignment
に値を指定してあげる必要がありそうですね。
LayoutBuilder
って Widget のサイズを教えてくれるんじゃないの?
問題2: LayoutBuilder
が教えてくれるのは Widget のサイズではありません。ここまで見てきた通り、レイアウト計算の元になる BoxConstraints
を渡してくれるのみです。そのため、その BoxConstraints
を使って child
がどんなレイアウト計算をし、その結果どんなサイズになるかは child
次第です。
LayoutBuilder
は 「特定の Widget のサイズを使ってレイアウトを組む」を実現するための Widget ではない ことは覚えておくと良いでしょう。
OrientationBuilder
を使えば端末の向きが検知できる?
問題3: できません、ということは ドキュメント にも記載されていますが、その理由はここまでの話を元に 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
を取得し、さらにその maxHeight
と maxWidth
のどちらが大きいかで Orientation.landscape
か Orientation.portrait
かを判断しているだけです。
端末の向きは全く関係なく OrientationBuilder
に渡された BoxConstraints
のみで判断している上に、minWidth
や minHeight
については全て無視して判定しています。そのことを理解して利用するようにしましょう。
以上です。
最初のうちはこのあたりのことを知らなくてもある程度 UI が構築できるようにうまく API がデザインされているのが Flutter の良いところではありますが、少し複雑なレイアウトを実現しようとしたり、Widget が画面をはみ出してしまったり、使いやすい共通 Widget を設計しようとするとこの辺りの知識の有無でだいぶ無駄な試行錯誤や「おまじない」を入れることになってしまいます。
ある程度 Flutter に慣れてきたタイミングで、このあたりの内部的なレイアウト計算ロジックについても理解を深めてみるとよいでしょう。
-
実装を読んでいただければわかる通り、値を指定しなかった場合は最小値が
0
、最大値がdouble.infinity
が指定され、子供のレイアウト次第で伸縮する設定になります。 ↩︎
Discussion