この記事は、Flutter Advent Calendar 2021の17日目の記事です。
昨日の記事は、 @welchi さんのFlutterとRiverpodで簡単に将棋アプリを作ってみるでした。
こんにちは
たかせです。今日のお話はこのUIについてです。
4通のメッセージが来てますね。それぞれのメッセージにはプロフィール画像と本文、送信時刻が表示されていて、これら3つの要素がいい感じに横に並んでます。
メッセージ単体のUIを詳しく見ていくと、このUIはテキストの行数に応じて背景領域が拡大されるようになっています。また、テキストの行数によらず、プロフィール画像は最上部に、送信時刻は最下部に表示されるようになっています。
今日は、このUIをFlutterで実装します。
見かけどおり、それほど難しいUIではありません。IntrinsicHeight
というWigetを知っている方は10分とかからずに実装してしまうことでしょう。
ただしこのIntrinsicHeight
というWidgetを知らないと、結構沼りがちです。ググろうにも
Flutter Row 高さ 子供 可変
とか
Flutter Row 高さ 合わせる 親
みたいなワードではうまくヒットせず、頭を抱えます(僕は抱えました)。
- 必要なレイアウトルールを言語化する
- IntrinsicHeightを使わずに試行錯誤してみる
- IntrinsicHeightのありがたみを知る
の流れで解説していこうと思います。
レイアウトルールを言語化する
プロフィール画像は黄色、本文は青、送信時刻は緑、そして背景には赤を付けています。さて、このUIにおける
- 子供の高さとAlignmentに関するルール
- 親の高さに関するルール
という2点について言語化していきます。ここでいう子供とは、プロフィール画像と本文、送信時刻の3者です。
子供の高さとAlignmentに関するルール
プロフィール画像の高さは48で固定とします。また、Alignmentは親の上辺に合わせることとします。
本文の高さは、フォントタイプとフォントサイズ、及びWidthが与えられたときに全てのテキストを表示できる最小の高さとします。
小難しく書きましたが、基本的にはText Widgetがよしなにやってくれるので、我々はテキストの横幅を固定してあげるだけで大丈夫です。
本文のAlignmentは親の上辺に合わせることとします。テキストが2行以上の場合は無縁な設定なので忘れがちなやつです。
送信日時の高さは、フォントタイプとフォントサイズ、及びWidthが与えられたときに全てのテキストを表示できる最小の高さとします。
本文の高さと同じルールですが、本文とは違い入力される文字列を制御できる部分なので、ほどよいフォントサイズに調整しておきましょう。
送信日時のAlignmentは、親の底辺に合わせることとします。
最後に、親の高さは「いずれかの子供のうち最も高さが高い子供と同じ」とします。
ここを固定にしてしまうと、長すぎるテキストで見切れたり、逆に短すぎるテキストで無駄な余白が生まれてしまいます。
IntrinsicHeightを使わずに試行錯誤してみる
さて、実装したいレイアウトルールの言語化が終わりました。実際に実装してみて、何が難しいのかを見ていこうと思います。
先に結論を言ってしまうと、FlutterのRowは「複数種類のAlignmentを子供に適用する」ことができません。つまり、Rowは「プロフィール画像を上辺に、送信日時を底辺にあわせる」というレイアウトルールを実現できません。
それでは試行錯誤の始まりです。
とりあえずRowで囲めばいいんじゃない?
Rowで囲みました。きれいに中央揃えされてます。寄せていきましょう。
Align使えば行けるっしょ?
プロフィール画像をAlignで囲い、Alignment.topCenterを設定しました。
通常であればこれでうまくいくんですが、どうにもRowの中のAlignは効果がないようです(要出典)。きれいに中央揃えされたままでした。想像するに、RowのcrossAxisAlignmentに上書きされてるんじゃないでしょうか。
じゃあcrossAxisAlignment使おうぜ!
RowのcrossAxisAlignmentにCrossAxisAlignment.startを設定しました。
プロフィール画像は正しくAlignmentされましたが、送信日時も巻き込まれてしまいました。送信日時は底辺に合わせたいのです。
crossAxisAlignmentとAlign組み合わせたらいけんじゃね?
RowのcrossAxisAlignmentにCrossAxisAlignment.startを設定した上で、送信日時をAlignで囲い、Alignment.bottomCenterを設定しました。
送信日時は上辺に合わさったままです。やはりRowの中のAlignは効果がないようですね。
crossAxisAlignmentのstretchって使えないの?
RowのcrossAxisAlignmentにCrossAxisAlignment.stretchにしました。
UIがきれいに無くなりました。無です。
てっきり画面が真っ赤になる例のアレが出るかと思いましたが出ませんでしたね。Flutterのログを見るとエラーが出力されています。
The following assertion was thrown during performLayout():
BoxConstraints forces an infinite height.
These invalid constraints were provided to _RenderColoredBox's layout() function by the following function, which probably computed the invalid constraints in question:
ChildLayoutHelper.layoutChild (package:flutter/src/rendering/layout_helper.dart:56:11)
The offending constraints were: BoxConstraints(0.0<=w<=Infinity, h=Infinity)
The relevant error-causing widget was:
...
一言で言えば、「メッセージUIの高さが無限だよ描画できないよ!」というエラーです。
高さが無限であること自体は問題ではなく、その無限の高さに制約をつける親がいないことが問題です。今回はメッセージのUIであり、複数のメッセージを並べることを想定していたため、メッセージUIの親はColumnでした。Columnは、任意の数のWidgetを縦に並べるために無限の高さを持ちます。結果親子ともに無限の高さを持ってしまい、エラーとなりました。
とはいえ惜しいところまで来ています。試しに、メッセージUIの高さを200で固定してみましょう。
高さを固定してcrossAxisAlignment.stretchに再チャレンジ
メッセージUIの高さを200で固定した後、RowのcrossAxisAlignmentをCrossAxisAlignment.stretchにしました。また、プロフィール画像と送信日時をAlignで囲い、それぞれtopCenterとbottomCenterにしました。
プロフィール画像が上辺に合わせっています!送信日時も底辺に合わさっています!無駄な余白に目を瞑ればこれで完成と言って差し支えないでしょう!
これではだめですね。デザイナの方に怒られます。
ここで思い出してほしいのは、我々はメッセージUIの親となる要素の高さに「いずれかの子供のうち最も高い子供と同じ高さ」というルールを適用したかったことです。上記の無駄な余白がある状態から、テキストが丁度収まるくらいまで親を縮めたいのです。
IntrinsicHeightを使うとこれを実現できます。試行錯誤を終わりましょう。
IntrinsicHeightのありがたみを知る
IntrinsicHeightを使うと、「いずれかの子供のうち最も高い子供と同じ高さ」というルールを適用できます。
冒頭で掲載した画像と同じものですね。
高さが固定ではない親の高さは次のようなフローで決まります(要出典)。
- 子供1を描画し、そのHeightを親のHeightとする
- 子供2を描画し、その子供が親より高ければ、その子供のHeightを親のHeightとする
- ...
- 子供nを描画し、その子供が親より高ければ、その子供のHeightを親のHeightとする
- 終了
対してIntrinsicHeightを使う場合は次のようなフローに変化します(要出典)。
- 子供1を描画し、そのHeightを親のHeightとする
- 子供2を描画し、その子供が親より高ければ、その子供のHeightを親のHeightとする。親の高さが変わった場合は子供1を再描画する
- ...
- 子供nを描画し、その子供が親より高ければ、その子供のHeightを親のHeightとする。親の高さが変わった場合は子供1,2,...,n-1を再描画する
例として、IntrinsicHeightを使う場合と使わない場合の子供の高さを色付けしてみましょう(説明のためにAlignを外してあります)。
使う場合はプロフィール画像も送信時刻も親と同じ高さになるよう引き伸ばされていますが、使わない場合は最小限の高さとなっています。
ここで嬉しいのは、「Rowの中でAlignが効かない」という仕様を回避できることです。
Rowの直接の子供をAlignで囲うのではなく、Rowと子供を同じ高さにして、その子供(Rowから見ると孫)をAlignで囲うことで、「複数種類のAlignmentをRowの子供に適用する」というルールを実現しています。
IntrinsicHeight < ドヤッ
終わりに
IntrinsicHeightを使うフローと使わないフローを根気強く読んでくれた方であればお気づきかもしれませんが、IntrinsicHeightを使うと描画回数が増えます。
子供となるWidgetがn個あるとき、通常のフローであれば最大n回の描画で済みますが、IntrinsicHeightを使うフローだと1からnの総和になります。子供が5人いる場合は15回ですね。使わない場合と比べると3倍です。
IntrinsicHeightのコメントでも「relatively expensive」と説明されています。必要なときだけ使うよう心がけたいですね。
/// This class is relatively expensive, because it adds a speculative layout
/// pass before the final layout phase. Avoid using it where possible. In the
/// worst case, this widget can result in a layout that is O(N²) in the depth of
/// the tree.
当初はUIごとにソースコードを貼っていこうと思っていたんですが思っていました。整理されていないものを一応置いて置こうと思います
https://gist.github.com/niwatly/5ffd3c1f9302ecaab305f1b0d609279f