制約は上から、サイズは下から
Flutter のレイアウトは、親から子へ制約が下り、子から親へサイズが上る一往復で決まる。Row や Column、Expanded、スクロール、Stack、画面幅への対応まで、この一つのモデルで読み解けるようにする。
前の連載では、起動フローから Widget ツリー、状態、Riverpod、非同期、テストまでを一周しました。 画面のコードを読むための道具はそろったので、ここからは作るための道具を積み上げていきます。
最初はレイアウトです。Flutter を書き始めて最も高い頻度で出会うのは、おそらくこの二つのエラー でしょう。
A RenderFlex overflowed by 42 pixels on the right.Vertical viewport was given unbounded height.
どちらも、原因を知らないうちは「なぜか怒られた」としか読めません。しかし実際には、両方とも たった一つの規則から導かれる帰結です。今回はその規則から始めて、Row・Column、Expanded、 スクロール、Stack、画面幅への対応までを一本の線でつなぎます。
§ 01CONSTRAINTS制約は上から、サイズは下から
Flutter のレイアウトは、次の一文に集約されます。
制約は下りてくる。サイズは上っていく。位置は親が決める。
順を追うと、こうです。親は子に「この範囲に収めてほしい」という制約を渡します。子はその範囲の
中から自分のサイズを選んで親に返します。最後に親が、返ってきたサイズを見て子をどこに置くか
決めます。ツリーを下って上る、この一往復でレイアウトが確定します1一往復で決まるからこそ、Flutter のレイアウトは子の数に対して線形コストで済む。IntrinsicHeight のように子へ問い合わせてから配置する Widget は、このパスを追加で走らせるため相対的に高くつく。。
制約の正体は BoxConstraints——最小幅・最大幅・最小高さ・最大高さの 4 つの数値です。
- tight(きつい) … 最小と最大が同じ。サイズは一択で、子に選ぶ余地がない
- loose(ゆるい) … 最小が 0。最大までの範囲で子が好きなサイズを選べる
- unbounded(無限) … 最大が
infinity。上限がなく、子は好きなだけ広がれる
ここから重要な帰結が出ます。子は、親が許した範囲の外のサイズにはなれません。SizedBox で
width: 400 と書いても、親が「最大 200」という tight な制約を渡していれば 200 になります。
「サイズを指定したのに効かない」の大半はこれです。
もう一つ。子は自分がどこに置かれるかを知りません。位置を決めるのは親です。だから build()
の中で「自分の画面上の座標」を前提にしたコードは書けません。
§ 02FLEXRow と Column
Row は横一列、Column は縦一列に子を並べます。両者は同じ仕組み(Flex)の向き違いなので、
主軸(main axis)と交差軸(cross axis)という言葉で覚えると一度で済みます。
| 主軸 | 交差軸 | |
|---|---|---|
Row | 横 | 縦 |
Column | 縦 | 横 |
配置は主軸が mainAxisAlignment、交差軸が crossAxisAlignment です。そして mainAxisSize は
「主軸方向に、与えられた空間をめいっぱい使うか(max、既定)/子の合計ぶんで済ませるか
(min)」を決めます。
Row(crossAxisAlignment: CrossAxisAlignment.center,children: [const Icon(Icons.inventory_2_outlined),const SizedBox(width: 8),Text(product.name),],)
さて、冒頭の overflow はここで起きます。Row は、後述する Expanded などを付けていない子に対し、
主軸方向に上限なしの制約を渡します。つまり「好きなだけ広がっていい」と言う。子の Text は
言葉どおり一行ぶんの幅を要求し、その合計が Row に許された幅を超えた瞬間、あの黄色と黒の縞模様
が出ます2黄色と黒の縞模様は debug ビルドだけの表示。release ビルドでは縞は出ないが、はみ出した内容が切れる状態は変わらない。。
Row が意地悪なのではなく、誰も「ここで折り返せ」と言っていないのが原因です。
§ 03EXPANDED余りをどう分けるか
そこで Expanded です。子を Expanded で包むと、その子は「余った空間を受け取る」対象になり、
上限なしではなくきっちりその幅の制約を渡されます。幅が確定するので、Text は折り返すか、
overflow: TextOverflow.ellipsis を指定していれば「…」で省略できます。
Row(children: [const Icon(Icons.inventory_2_outlined),const SizedBox(width: 8),Expanded(child: Text(product.name, overflow: TextOverflow.ellipsis),),Text('¥${product.price}'),],)
複数の Expanded があるときは flex の比で分けます(既定は 1)。flex: 2 と flex: 1 なら 2 : 1
です。
よく似た Flexible との違いは一点だけ。Expanded は与えられた空間を使い切らせる(tight)の
に対し、Flexible は上限だけ決めて、子が小さいままでいることを許す(loose)。Expanded は
Flexible(fit: FlexFit.tight) の別名にすぎません。「余白を埋めたい」なら Expanded、「はみ出させ
たくないが、中身が短ければ短いままでよい」なら Flexible です。
§ 04SPACINGPadding・SizedBox・Container
余白まわりの Widget は、役割で選びます。
Padding… 内側に余白を足すSizedBox… 固定サイズを与える。子なしなら「隙間」そのものになるAlign/Center… 与えられた空間の中で子を寄せるDecoratedBox… 背景色・角丸・枠線・影を描く
そして Container は、これらをまとめて引き受ける複合 Widgetです。内部では指定されたプロパティ
に応じて Padding や DecoratedBox などを組み立てています。手早く書けるので便利ですが、余白だけ
なら Padding、隙間だけなら SizedBox を使うほうが意図が明確で、しかもconst にできます
3Container のコンストラクタは内部で制約を組み立てるため const にできない。SizedBox や Padding は const で書ける。。前回の連載で見たとおり、const は再構築をスキップさせる実利につながります。
§ 05SCROLL無限の高さと向き合う
もう一方のエラー、Vertical viewport was given unbounded height に進みます。典型はこれです。
Column(children: [const Header(),ListView(children: items), // ここで落ちる],)
規則に当てはめれば理由は明らかです。Column は主軸(縦)方向に上限なしの制約を子へ渡します。
一方 ListView は「渡された高さいっぱいに広がり、中身がそれを超えたらスクロールさせる」Widget
です。上限が無限だと「いっぱい」が確定できず、レイアウトが破綻します。
解き方は、どちらの側に高さを決めさせるかで選びます。
Expandedで包む … 残りの高さをListViewに確定して渡す。画面いっぱいのリストはこれshrinkWrap: true… 中身の高さの合計をListView自身のサイズにする。ただし全要素を レイアウトするので、遅延生成の利点を失う。短く件数が限られたリスト向け4shrinkWrap: trueは自身の高さを決めるために全要素をレイアウトする。要素数が増えるほど初期表示が重くなるため、長いリストではExpandedかスライバーへの統合を選ぶ。- スクロールを一つにまとめる … 外側の
SingleChildScrollViewかCustomScrollViewに統合し、 内側のスクロールをやめる
同じ向きのスクロールを入れ子にしない、というのが原則です。そして件数が多い、あるいは不定のリスト
では ListView.builder を使います。画面に見えている範囲だけ Widget を生成するので、要素が
何千あっても初期表示のコストが変わりません。
Expanded(child: ListView.builder(itemCount: items.length,itemBuilder: (context, index) => StockTile(item: items[index]),),)
§ 06STACK重ねる
縦横に並べるのではなく重ねるのが Stack です。トーストやバッジ、画像の上のグラデーション、
右下に浮くボタンなどに使います。
Stack の中の子は 2 種類に分かれます。
- Positioned でない子 … 通常どおり制約を受けてサイズを決め、
alignmentの位置に置かれる。Stack自身の大きさは、この子たちのうち最大のものに合わせられる Positionedで包んだ子 … 確定したStackの大きさを基準に、top/leftなどで位置を指定 される
Stack(children: [const Avatar(), // これが Stack のサイズを決めるPositioned(right: 0, top: 0, child: const UnreadDot()),],)
順序は前後関係でもあり、後に書いた子ほど手前に描かれます。
§ 07RESPONSIVE画面幅で形を変える
スマートフォンで 1 カラム、タブレットで 2 カラム——のように形を変えたいときは、判断材料が 二つあります。
MediaQuery は画面(ウィンドウ)全体の情報です。サイズのほか、ノッチやホームインジケーター
の余白、キーボードが占める領域なども持ちます。画面全体を基準にした判断はこちらです。
final width = MediaQuery.sizeOf(context).width;
MediaQuery.of(context).size ではなく MediaQuery.sizeOf(context) を使うのがいまの作法です。
サイズの変化にだけ反応するので、キーボードの開閉など無関係な変化で再構築されずに済みます
5MediaQuery.of(context) は MediaQueryData 全体に依存するため、キーボード表示などで viewInsets が変わっただけでも再構築される。sizeOf / paddingOf など用途別のアクセサが用意されている。。
LayoutBuilder はその位置で親から渡された制約を教えてくれます。画面全体ではなく、自分が
実際に使える空間で判断できるので、ダイアログの中や分割ペインの片側に置かれても正しく振る舞い
ます。部品として再利用する Widget では、ふつうこちらが適切です。
LayoutBuilder(builder: (context, constraints) {return constraints.maxWidth >= 600? const TwoColumnLayout(): const SingleColumnLayout();},)
そして SafeArea は、ノッチや角丸、ホームインジケーターに重ならないよう余白を足す Widget です。
内部では MediaQuery の余白情報を使っています。全画面に敷くコンテンツでは忘れずに。
§ 08SUMMARYレイアウトを読む地図
- 制約は下り、サイズは上り、位置は親が決める。この一往復がすべての土台
- 子は親が許した範囲の外のサイズにはなれない。「指定が効かない」はたいていこれ
Row/Columnは主軸・交差軸で考える。非 flex な子には上限なしの制約が渡るので overflow するExpandedは空間を使い切らせる、Flexibleは上限だけ決める- スクロール Widget に上限なしの高さを渡さない。
Expanded/shrinkWrap/ 統合のいずれかで解く - 画面全体の判断は
MediaQuery.sizeOf、その場の空間での判断はLayoutBuilder
エラーメッセージに出会ったら、「いま親はどんな制約を渡しているか」「子は何を要求しているか」の 二点に還元してください。ほとんどの謎はそこで解けます6Flutter DevTools の Layout Explorer を使うと、選択した Widget に渡っている制約と決定されたサイズをその場で確認できる。Flex の余白配分も視覚的に追える。。
次回は、レイアウトの上にユーザーの入力を載せます。TextEditingController と Form、
バリデーション、そしてダイアログから値を受け取る流れを扱います。
- [1] 一往復で決まるからこそ、Flutter のレイアウトは子の数に対して線形コストで済む。
IntrinsicHeightのように子へ問い合わせてから配置する Widget は、このパスを追加で走らせるため相対的に高くつく。 ↩ - [2] 黄色と黒の縞模様は debug ビルドだけの表示。release ビルドでは縞は出ないが、はみ出した内容が切れる状態は変わらない。 ↩
- [3]
Containerのコンストラクタは内部で制約を組み立てるためconstにできない。SizedBoxやPaddingはconstで書ける。 ↩ - [4]
shrinkWrap: trueは自身の高さを決めるために全要素をレイアウトする。要素数が増えるほど初期表示が重くなるため、長いリストではExpandedかスライバーへの統合を選ぶ。 ↩ - [5]
MediaQuery.of(context)はMediaQueryData全体に依存するため、キーボード表示などでviewInsetsが変わっただけでも再構築される。sizeOf/paddingOfなど用途別のアクセサが用意されている。 ↩ - [6] Flutter DevTools の Layout Explorer を使うと、選択した Widget に渡っている制約と決定されたサイズをその場で確認できる。Flex の余白配分も視覚的に追える。 ↩