【Flutter】さよなら、 Renderflex overflowed - 恐らくもっとも遠回りなUIエラーの解説 -
この記事の目的
この記事の目的はレイアウトを組んだ際に発生するRenderflex overflowed、あのデバッグモードで表示される黄色と黒の縞々と金輪際おさらばする事です
その為に、Widgetがどの様に描画されるのか、どの様にサイズが決まり、どの様なルールに則っているのかを明確にしていきます
結果的に Renderflex overflowed
含めたUIエラー全体についての解説となっています
理解しておく背景や情報量が多いので、目的に直接関係のないところは端折って行きますが、非常に参考になるリンクを貼っていくのでそちらをご覧頂ければと思います
TL;DR
恐らく長すぎて誰も最後まで読む事はないと思うので、要点だけまとめておきます
- サイズの計算処理は
Render
オブジェクトのperformLayout
メソッド に書かれている - サイズは親から渡される
BoxConstraints
とperformLayout
に記述された内容の組み合わせで決まる - UIエラーを引き起こす組み合わせは2つ
- 無制約 x 画面サイズ以上になろうとするWidget
- 無制約 x 無制限に大きくなろうとするWidget
-
無制約 x 画面サイズ以上になろうとするWidget の解決方法
- ScrollableなWidgetでラップしてスクロール可能にする
- Expanded, Flexibleでラップして画面サイズに合わせる
-
無制約 x 無制限に大きくなろうとするWidget の解決方法
- 子Widgetにサイズ制約を課す
今回の記事を書くにあたり参考にさせて頂いた情報を登壇や記事で発信してきた先人の方々に改めて感謝いたします
めちゃくちゃありがて〜〜〜と何度唸ったか分かりません
また今回解説するUIエラーとそれに対する解決法をコメントしたサンプルは全て下記レポジトリにまとめています
サイズ計算に至る描画の全体像
Flutterフレームワークの全体像
FlutterでUIを作るとはどういう事でしょうか?我々が見る画面に表示される絵はどうやって描かれるのでしょうか?それを理解する為には軽くFlutterフレームワークの構造について触れていきます
Flutterというフレームワークは実は我々が通常記述するWidget
以外にも非常に多くのレイヤーで出来ています
我々が記述するのは氷山の一角であり、水面下では非常に多くのコードが動いている事が分かりますね
大きくはDartで書かれたFramework
、CやC++で書かれたEngine
、各Platformに応じた言語で書かれたEmbedder
の三層で分かれています
この内で描画に関係するレイヤーを抜き出すと下記の様なレイヤーとなります
この中のMaterial
、Cupertino
、Widget
と書かれたレイヤーが普段よく使うクラスが存在するレイヤーです。描画をする際にはこの層を一層一層上から下に下っていく事になります
Widget
レイヤーのクラスがその下のRendering
レイヤーのAPIを呼び出し、Rendering
レイヤーがEngine
側にあるdart:ui
レイヤーのAPIを呼び出し、この層が実際に画面に描画を行います
ここの層で利用されるのがC++で書かれたSkia
(スキーア)というグラフィックスライブラリです
普段我々が実装するWidget
レイヤーは全体の設計図の役割をしており、それに応じてRendering
レイヤーではサイズと位置の計算が行われ、実際に描画を担当するdart:ui
レイヤーに伝える事でディスプレイ上にUIを描画してもらいます
今回の主題である「Widgetのサイズ」はこのRendering
レイヤーで行われるわけです
3つのツリー構造
それではこのRendering
レイヤーはどのようなクラスで構成されているのでしょうか?
ここで登場するのが有名なFlutterを構築する3つのツリー構造です
FlutterはWidget
ツリー、Element
ツリー、Render
ツリーの3つのツリー構造で出来ている為、非常に軽快かつ高パフォーマンスな描画処理が行われます
普段実装しているWidget
ツリーは主に設計図の役割を担います。Element
ツリーを構成するElement
は状態などを管理し、Render
ツリーを構成するRender Object
は描画を担います。
この描画を司るRender Object
こそが今回の主役であり、Rendering
レイヤーを構成するクラスです。Render
ツリー自体がRendering
レイヤーとも言えます
Constraints go down, Size go up
ではこのRender Object
で構成されたRender
ツリーはどのようにサイズと位置を決定していくのでしょうか?
ここで登場するのが「constraints go down, size go up」という有名なフレーズです。Flutterを学習する過程で一度は聞いた事のあるフレーズかもしれません。でも意味はいまいち分かっていないという方も多いかもしれません、私は少なくとも全然分かっていませんでした。
このフレーズはRender
ツリーがどの様にサイズと位置を決定するのかを端的に表した言葉です
まずWidgetはそれぞれに対応したRender Objectを生成し、Renderツリーが構成されます。
そのRenderツリーの各ノードとなるRender Objectはそれぞれ自身の子ノードに渡すサイズ制約constraints
を持っています。「自分自身」ではなく、「自分の子ノード」が取って良いサイズの制約です。
子ノードは親から渡されたそのサイズ制約を元に自分自身のサイズを決め、親クラスに自身が取るサイズを伝えます。
しかし親からconstraintsを渡されてすぐにサイズを決めるわけではありません
この「自身の子ノードにサイズ制約を渡す」という処理はRender
ツリーに沿って、末端のノードまで繰り返し、子ノードを持たないノードに到達して初めてサイズ計算が行われます。
その後、末端のノードは自身のサイズを計算し、自身の親に対して自分のサイズを伝えます
親ノードは子から子のサイズを受け取り、それを元に子の位置を決め、今度は自分のサイズを計算、親ノードに自身のサイズを伝えます
こうやってルートノードに到達するまでサイズ計算とその伝播がRender
ツリーを逆方向に行われていきます
フレーズの通り、このRender
ツリーに沿ってサイズ制約constraints
が末端まで下っていき(Constraints go down
)、今度は逆に末端から計算されたサイズがルートに到達するまでツリーを上って伝えられていく(Size go up
)わけです
ここについては下記動画が非常に分かりやすく解説頂いています
3つのツリーが構築されるまでの流れ
さて既にだいぶ迷子になっている人がいると思いますが、更に迷子の人を増やしていきましょう
もう少し具体的にどの様にWidgetからElementが生成され、ElementによってRender Obejctが生成され、更にそのRender Objectがサイズ計算を行うのかメソッド単位で見ていきましょう
まずWidgetが生成されるとそのWidgetが継承しているSingleRenderObjectWidget
が持つcreateElement
メソッドが呼ばれます
Elementを生成
abstract class SingleChildRenderObjectWidget extends RenderObjectWidget {
const SingleChildRenderObjectWidget({ super.key, this.child });
final Widget? child;
SingleChildRenderObjectElement createElement() => SingleChildRenderObjectElement(this);
}
これによりElementツリーに挿入されるElementが生成されます。この例ではSingleChildRenderObjectWidget
に対応したElementであるSingleChildRenderObjectElement
が生成されます
今度はElementが生成されたタイミングでElementが継承しているRenderObjectElement
のmounted
メソッドが実行され、このメソッド内でElementの生成元であるWidgetが実装するcreateRenderObject
メソッドが実行されます
createRenderObjectの呼び出し
abstract class RenderObjectElement extends Element {
/// Creates an element that uses the given widget as its configuration.
RenderObjectElement(RenderObjectWidget super.widget);
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
assert(() {
_debugDoingBuild = true;
return true;
}());
_renderObject = (widget as RenderObjectWidget).createRenderObject(this);
assert(!_renderObject!.debugDisposed!);
assert(() {
_debugDoingBuild = false;
return true;
}());
assert(() {
_debugUpdateRenderObjectOwner();
return true;
}());
assert(_slot == newSlot);
attachRenderObject(newSlot);
_dirty = false;
}
}
Renderオブジェクトを生成
class ConstrainedBox extends SingleChildRenderObjectWidget {
ConstrainedBox({
super.key,
required this.constraints,
super.child,
}) : assert(constraints != null),
assert(constraints.debugAssertIsValid());
final BoxConstraints constraints;
RenderConstrainedBox createRenderObject(BuildContext context) {
return RenderConstrainedBox(additionalConstraints: constraints);
}
これによりRenderツリーに挿入されるRender
オブジェクトが生成されました。この例ではConstrainedBox
Widgetに対応したRenderConstrainedBox
クラスが生成されました。
これでWidgetからElementが生成され、ElementによってRender Objectが生成され、それぞれのツリーに挿入される事でWidget
ツリー、Element
ツリー、Render
ツリーの3つのツリーがセットされました
3つのツリーが生成された後はいよいよ実際のレンダリング処理が走っていくわけですが、そちらの処理を頭から追い始めるととんでもない事になるので、すっ飛ばして実際のサイズ計算を行う処理だけピックアップしましょう
その処理はズバリRender Object
が持つperformLayout
というメソッドになります
performLayoutメソッド
class RenderConstrainedBox extends RenderProxyBox {
...
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);
}
}
...
}
レンダリングパイプラインについてはこちらの動画や記事が参考になるのでより深く知りたい方はご覧になってみてください
Widgetサイズを決める 3 x 5 の組み合わせ
BoxConstraints
とperformLayout
メソッド
さてここまでで親から子に渡されるconstraints
、そしてperformLayout
メソッドによってWidgetサイズが決められているという事が分かりました
ではもう少し詳しくそれぞれについて見てみましょう
BoxConstraints
この各Render Objectが持つcostraintsは子ノードが取って良い高さ(height
)と幅(width
)の最大、最小値を持つ非常にシンプルなクラスです
class BoxConstraints extends Constraints {
/// Creates box constraints with the given constraints.
const BoxConstraints({
this.minWidth = 0.0,
this.maxWidth = double.infinity,
this.minHeight = 0.0,
this.maxHeight = double.infinity,
}) : assert(minWidth != null),
assert(maxWidth != null),
assert(minHeight != null),
assert(maxHeight != null);
}
子に渡されるBoxConstraintsには以下3つのパターンしかありません
1. tightなconstraints
特定のサイズを強要するconstraintsです。幅、もしくは高さのminとmaxが同じ値となっており、子はその特定のサイズしか取る事はできません。
2. looseなconstraints
ある一定の幅のあるconstraintsです。幅、もしくは高さのminとmaxに乖離があり、子はその中で自由にサイズを取る事ができます。
3. unboundedなconstraints もしくは null
幅、もしくは高さのmaxがdouble.infinity
なconstraints、もしくはそもそもconstraintsを渡しません。この場合、子はその方向(幅、高さ)についてどんな値を取る事も可能です。SingleChildScrollView
, CustomScrollView
, Flex
, OverflowBox
などのWidgetがこの制約を子に渡しています
performLayout
メソッド
performLayout
メソッドは前述の通り、Render
オブジェクトが保持し、自身のサイズを決める処理を担います
親から受け取ったconstraints
やその他の引数を元に自身のサイズを計算したり、自分の子のperformLayout
メソッドを呼び出したりと中身はWidget(厳密にはそれぞれのWidgetに対応するRenderオブジェクト)によって異なります
その挙動は大まかには下記の5つのパターンに集約されます
-
制約内でなるべく大きくなろうとする (
Center
,ListView
) -
なるべく自分の子と同じサイズになろうとする (
Transform
,Opacity
) -
制約に関係なく特定のサイズになろうとする (
Image
,Text
) -
コンストラクタに渡された引数に応じて制約に対する反応を変える (
Container
) -
与えられた制約に応じて反応を変える (
Row
,Column
)
4,5について補足すると、例えば Container
はデフォルトでは1の様に制約内でなるべく大きくなろうとしますが、width
が渡されると3の様にその特定のサイズになろうとします。
また Row
や Column
は自身の伸びる向き(Rowならwidth、Columnならheight)について、デフォルトでは子が存在すれば2の様になるべく子のサイズになろうとしますが、その向きにconstraintsを与えられた場合、その制約内でなるべく大きくなろうとします。
UIが破綻する組み合わせ
以上からWidgetのサイズの決まり方は3パターンの BoxConstraints
と5パターンの performLayout
の組み合わせのどれかになるという事が分かります。
しかしここで実はUIが破綻する組み合わせがあります。それが今回の本題となる RenderFlex overflowed
やその他のUI関連エラーの原因です
その組み合わせは下記の2つです
- 「unboundedなconstraints もしくは null」 x 「制約に関係なく特定のサイズになろうとする」のサイズが更に上位(ex. ディスプレイサイズ)の制約を超えてしまう場合
- 「unboundedなconstraints もしくは null」 x 「制約内でなるべく大きくなろうとする」
言葉だけだと分かりづらいので具体例を見ていきましょう
破綻するパターン
Column
x サイズ指定したContainer
1. このパターンではunboundedなconstraintsを子に渡す Column
の子に
画面サイズを超える100 x 100の高さのContainer群が配置されています。画面サイズを超えるWidget群が配置された事で RenderFlex overflowed
のエラーが発生します
コード
/// 適合するパターン:unboundedな制約を子に渡す x 特定のサイズになろうとする
body: Center(
child: Column(
children: [
for (int i = 1; i < 100; i++)
Container(
margin: const EdgeInsets.all(5),
width: 100,
height: 100,
color: Colors.yellow,
),
],
),
),
Row
x Text
, Image
2. このパターンでは、unboundedなconstraintsを子に渡すColumn
の子に、画面サイズを超えるText
やImage
が渡されています
パターン1同様、Text
やImage
はconstraintsに関係なく自身のサイズになろうとする為、画面サイズを超えてしまいRenderFlex overflowed
のエラーが発生します
コード
/// 適合するパターン:unboundedな制約を子に渡す x 特定のサイズになろうとする
/// Text
body: Center(
child: Row(
children: const [
Text("But even now, I can't shake the memory of that cat. "
"It's not that I think about it all the time. It's just that "
" every once in a while, the image of that cat surfaces in my "
"mind, like an old photograph that I've filed away in some box."
" And when it does, I find myself wondering where that cat is now, "
"and what it's doing. It's a strange feeling, like a half-forgotten "
"dream that comes back to you briefly in the morning."),
],
),
),
/// Image
body: Center(
child: Row(
children: [
Image.asset('assets/images/claw_machine.png'),
],
),
),
Column
x ListView
3. このパターンでは、unboundedなconstraintsを子に渡すColumn
の子に、画面サイズを超えるListView
が渡されています
こちらも画面サイズを超えてしまいRenderFlex overflowed
のエラーが発生します
コード
/// 適合するパターン:unboundedな制約を子に渡す x 特定のサイズになろうとする
body: Center(
child: Column(
children: [
ListView.builder(
itemCount: 50,
shrinkWrap: true,
itemBuilder: (context, index) {
return ListTile(
title: Text(
index.toString(),
),
);
},
),
],
),
),
Column
x TextFormField
4. このパターンは少し特殊ですが、よく遭遇するパターンでもあります
このパターンではColumn
の子に、画面サイズを超えない高さのWidgetが配置されており、通常は問題ありません
しかしTextFormField
がfocus
されキーボードが画面に出現すると画面サイズが小さくなってしまい、子Widgetが画面サイズを超えてしまいRenderFlex overflowed
のエラーが発生します
コード
/// 適合するパターン:unboundedな制約を子に渡す x 特定のサイズになろうとする
child: Center(
child: Column(
children: <Widget>[
SizedBox(
height: MediaQuery.of(context).size.height * 0.3,
),
Padding(
padding: const EdgeInsets.all(32),
child: TextFormField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
),
),
SizedBox(
height: MediaQuery.of(context).size.height * 0.3,
child: const Placeholder(),
),
],
),
),
SingleChildScrollView
x Expanded
(x Column
)
5. スクロールUIを実現する際によく使うSingleChildScrollView
x Column
の組み合わせでも注意が必要です
Column
の子をExpanded
で囲う実装はよく見ると思いますが、そのColumn
を更にSingleChildScrollView
でラップする事で、Column
の子に対しunboundedなconstraintsを渡す事になります
その結果、制約がない中、Column
の子が無限に大きくなろうとしてしまう為、RenderFlex children have non-zero flex but incoming height constraints are unbounded.
というエラーを見る事になります
コード
/// 適合するパターン:unboundedな制約を子に渡す x なるべく大きくなろうとする
body: SingleChildScrollView(
child: Column(
children: [
const Expanded(child: Placeholder()),
Expanded(
child: Image.asset('assets/images/dash_game.jpeg'),
),
const Expanded(child: Placeholder()),
],
),
),
ListView
x ListView
6. このパターンではunboundedな制約を子に渡すListView
の子に、なるべく大きくなろうとするListView
を配置しています
この組み合わせでは子であるListView
が無限に大きくなろうとする為、レイアウトが破綻してしまい、Vertical viewport was given unbounded height.
のエラーが発生してしまいます
コード
/// 適合するパターン:unboundedな制約を子に渡す x なるべく大きくなろうとする
body: Center(
child: ListView(
children: [
ListView(
children: [
for (int i = 1; i < 3; i++)
Container(
margin: const EdgeInsets.all(10),
height: 100,
width: double.infinity,
color: Colors.yellow,
)
],
)
],
),
),
ListView
x TextField
7. このパターンでは横軸方向にunboundedな制約を子に渡すListView
の子に、なるべく大きくなろうとするTextField
を配置しています
結果、子であるTextField
が無限に大きくなろうとする為、レイアウトが破綻してエラーとなります
この例ではTextField
特有のエラーであるAn InputDecorator, which is typically created by a TextField, cannot have an unbounded width.
という表示がされます
コード
/// 適合するパターン:unboundedな制約を子に渡す x なるべく大きくなろうとする
body: Center(
child: ListView(
scrollDirection: Axis.horizontal,
children: const [
TextField(),
],
),
),
Flex
, CustomScrollView
気をつけるべきWidgetは 以上、UIが破綻しエラーとなってしまう組み合わせを見てきました
具体例を見て気づいた方もいるかもしれませんが、このUIの破綻が起きるのは Row
や Column
、ListView
という縦軸、横軸方向に複数のWidgetを持つWidgetを扱う時です
厳密には Row
や Column
は 「Flex
」 を、ListView
や GridView
は 「CustomScrollView
」 を継承しており、気を付けなければならないのはこれらを継承するクラスを使う際です
ちなみにタイトルにもなっている RederFlex overflowed
の RenderFlex
とはこのFlex
クラスの Render
オブジェクトの事です
つまり我々を苦しめ続けてきた RenderFlex overflowed
とはこの Flex
クラスを継承した Row
や Column
が誤った子Widgetと組み合わさった際に遭遇するエラーです
解決法
大体のUIエラーがFlex
とCustomScrollView
を扱う際に発生すると分かった所で、その解決法を見ていきましょう
結論から言ってしまうと以下の通りとなります
-
「unboundedなconstraints もしくは null」 x 「制約に関係なく特定のサイズになろうとする」のサイズが更に上位の制約を超えてしまう場合
- 解決法①: Scrollable widgetでラップし、スクロール可能にする
- 解決法②: 子Widgetを
Expanded
もしくはFlexible
でラップする
-
「unboundedなconstraints もしくは null」 x 「制約内でなるべく大きくなろうとする」
- 解決法: 子Widgetに
SizedBox
やConstrainedBox
で制約を課す
- 解決法: 子Widgetに
組み合わせ 1 の解決法
1の組み合わせでは子Widgetが画面サイズを超えてしまう事が問題でした
その為、以下のどちらかのアプローチで解決する事ができます
- 画面サイズを超えた場合、スクロール可能にしてしまう
- 画面サイズに合うように子Widgetのサイズを変えてしまう
解決後の挙動をどのようにしたいかでどちらのアプローチを取るかが変わるかと思います
先ほどの具体例1を解決してみましょう
BEFORE
body: Center(
child: Column(
children: [
for (int i = 1; i < 100; i++)
Container(
margin: const EdgeInsets.all(5),
width: 100,
height: 100,
color: Colors.yellow,
),
],
),
),
AFTER 1: スクロール可能にする
body: Center(
child: SingleChildScrollView( // <---Scrollableなwidgetでラップする
child: Column(
children: [
for (int i = 1; i < 10; i++)
Container(
margin: const EdgeInsets.all(5),
width: 100,
height: 100,
color: Colors.yellow,
),
],
),
),
),
AFTER 2: 画面サイズに合わせてサイズ変更する
body: Center(
child: Column(
children: [
for (int i = 1; i < 10; i++)
Expanded( // <--- `Expanded`でラップする
child: Container(
margin: const EdgeInsets.all(5),
width: 100,
height: 100,
color: Colors.yellow,
),
),
],
),
),
組み合わせ 2 の解決法
2の組み合わせでは制約がないのに、子Widgetが無制限に大きくなろうとする事が問題でした
その為、シンプルに「子Widgetに制限を課す」事で解決する事ができます
先ほどの具体例6を解決してみましょう
BEFORE
body: Center(
child: ListView(
children: [
ListView(
shrinkWrap: true,
children: [
for (int i = 1; i < 5; i++)
Container(
margin: const EdgeInsets.all(10),
height: 100,
width: double.infinity,
color: Colors.yellow,
),
],
),
],
),
),
AFTER
body: Center(
child: ListView(
children: [
SizedBox( // <--- `SizedBox`で制約を課す
height: 300,
child: ListView(
shrinkWrap: true,
children: [
for (int i = 1; i < 5; i++)
Container(
margin: const EdgeInsets.all(10),
height: 100,
width: double.infinity,
color: Colors.yellow,
),
],
),
),
],
),
),
以上の通り、非常にシンプルに解決出来てしまいました。具体例7も同様のアプローチで解決する事ができます
ただ具体例5だけは少し解決方法が特殊なので注意してください
BEFORE
body: SingleChildScrollView(
child: Column(
children: [
const Expanded(child: Placeholder()),
Expanded(
child: Image.asset('assets/images/dash_game.jpeg'),
),
const Expanded(child: Placeholder()),
],
),
),
AFTER
body: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, // <-- `MainAxisSize.min`に設定
children: [
// `Expanded`を`Flexible`に置き換える
const Flexible(child: Placeholder()),
Flexible(
child: Image.asset('assets/images/dash_game.jpeg'),
),
const Flexible(child: Placeholder()),
],
),
),
具体例5では Column
の子Widgetがサイズ制約を受ける様に2段階のサイズ制約を課しています
まず Column
自身が子Widgetのサイズになろうとする様に mainAxisSize: MainAxisSize.min
を設定します
その上で、Column
の子Widgetをなるべく大きくなろうとする Expanded
ではなく、viewportの範囲内で可能な限り大きくなると言う特性を持った Flexible
でラップする事でサイズ制約をかけています
この辺りのFlex
とFlexible
の関係性やアルゴリズムについては下記の動画で詳しく解説してくれています
先に挙げた具体例の解決方法は下記のサンプルレポジトリにコメントしているので、気になる方は答え合わせしてみてください
以上
以上で RenderFlex overflowed
の駆逐を目指す長い旅は終わりです
正直長すぎてここまで読んで頂いた方はいらっしゃらないと思いますが、これをキッカケにずっと気になっていたFlutterの低レイヤー世界を冒険する事ができ、読まれなかろうが自分にとって非常に大きな学びとなりました。
もしここまで読んでくれた方がいたら、数多くの誤字脱字や表記揺れ、間違いで目眩を催しているかと思いますので目に余る部分があれば、生暖かくご指摘いただければ幸いです。
また先人のエンジニアの方々が残してくれた貴重な資料なくしてはこれほど深掘りする事はできませんでした。改めて貴重な資料や情報を、登壇や執筆してくださった先人の方々に感謝したいと思います。自分もいつかそんな誰かの為になる情報を発信できるよう日々精進していきたいと思います。
それでは、皆様...
良いお年を!!!
参考
公式
- https://docs.flutter.dev/development/ui/layout/constraints
- https://docs.flutter.dev/development/ui/layout/box-constraints
- https://docs.flutter.dev/resources/architectural-overview#rendering-and-layout
- https://docs.flutter.dev/testing/common-errors
登壇・記事
- https://www.youtube.com/watch?v=gBfHYHvojvk&t=10722s
- https://developers.cyberagent.co.jp/blog/archives/36869/
- https://www.youtube.com/watch?v=UCuf1vXPH3A
- https://www.youtube.com/watch?v=UUfXWzp0-DU&t=706s
- https://www.youtube.com/watch?v=_jlXS8chb7g
- https://medium.com/flutter-jp/dive-into-flutter-4add38741d07
Discussion