CustomMultiChildLayoutを使って、あなたの思い描く自由なレイアウトを作ろう!
今回はFlutterKaigi2024の前夜祭のLTの資料です
プロポーザルはこちら!
なお今回の資料はiOSDC2024同様、TestFlightで確認できるので、iOS/iPadOSアプリを持っている方はダウンロードして、確認いただけます
https://testflight.apple.com/join/s5j2zJbS
なお今回はSwiftUIの中にFlutterのUIを表示しています
FlutterのUIをネイティブで表示する機能は、まだmacOSではサポートされていないようなので、この発表のスライドはmacOS版のアプリでは確認できないようになっています(参考)
今回の発表では、複雑なレイアウトをCustomMultiChildLayoutを使って、実装する方法をいくつかのサンプルを踏まえて、紹介する発表です!
CustomMultiChildLayoutとは?
CustomMultiChildLayoutとは、複数のWidgetのレイアウトを制御し、それぞれ任意の位置に配置するWidgetのContainerです
ColumnやRow, Stackのようなものを自作することができ、WidgetごとにIDを割り振るので、IDごとに条件も指定できます
また親のWidgetのサイズを考慮して、子どものWidgetのサイズや位置を指定することもできます
登場人物としては以下の通りです
- MyCustomLayoutDelegate
- CustomMultiChildLayoutのchildrenに渡すwidgetたちの制御条件を指定する
- performLayout
- MyCustomLayoutDelegateのメソッドで具体的な条件を指定
- layoutChildメソッドでサイズを指定し、positionChildメソッドで位置を指定する
- shouldRelayout
レイアウトの再計算を行うかを指定
次のセクション以降で、いくつかの実装例をお見せします
円環
こちらのサンプルコードはこちらです
以下はMyCustomLayoutDelegateのperformLayoutメソッドの処理です
final int count;
@override
void performLayout(Size size) {
// Calculate the radius of a circle
final radius = math.min(size.width, size.height) / 2;
// Calculate the angle between each widget
final angleIncrement = 2 * math.pi / count;
for (var i = 0; i < count; i++) {
// Calculate the size of the widget
if (hasChild('child$i')) {
final childSize = layoutChild('child$i', BoxConstraints.loose(size));
// Calculate the center of a widget
final angle = angleIncrement * i - math.pi / 2;
final xPos = math.cos(angle) * (radius - childSize.width / 2);
final yPos = math.sin(angle) * (radius - childSize.height / 2);
// Positioned at coordinates calculated from the center
positionChild(
'child$i',
Offset(
size.width / 2 + xPos - childSize.width / 2,
size.height / 2 + yPos - childSize.height / 2,
),
);
}
}
}
次のコードが上記で実装したCircleLayoutDelegateを利用する箇所のコードです
CustomMultiChildLayout(
delegate:
(
childCount: children.length,
),
children: [
for (int i = 0; i < children.length; i++)
LayoutId(
id: 'child$i',
child: children[i],
),
],
);
ピアノのUI
こちらのサンプルコードはこちらです
これは既述のiOSDC2024のLTで発表したSwiftUIで実装したピアノのUIを同じ仕組みでFlutterで実装したものです
基本的なUIの仕組みはSwiftで実装したものと同じなので、仕組みについては以下の記事と上記のサンプルコードを確認してください
また既述のFlutterのCustomMultiChildLayoutとSwiftUIのLayout Protocolは似たような仕組みですが、異なる点もあるので、その違いを以下にピックアップします
CustomMultiChildLayout(Flutter) | Layout Protocol(SwiftUI) |
各SubviewのIDをhasChildに渡すことで、存在確認ができる | 各Subviewに明示的なIDが設定されていないので、厳密な存在確認は難しい |
shouldRelayoutで再描画タイミングを明示的に指定可能 | Viewのライフサイクルや利用しているStateの変更タイミングに依存 |
親のContainerはLayoutBuilderなどで計算できるけど、Childを元にサイズを決定することはできない | 各Subviewのサイズ取得が可能 → Subviewサイズに合わせて、親のContainerのWidgetのサイズの指定が可能 |
childrenに渡した順にWidgetが重なるので、その順番で制御する必要がある | zIndexのmodifierがあるので、z軸方向の順番の制御がしやすい |
Googleカレンダー風のタイムラインUI
こちらのサンプルコードはこちらです
このUIの仕組みをざっくり説明すると以下のような仕組みになっています
- カレンダーの各予定をCalendarItemというclassに変換する
- 上記のclassにはzIndexの項目があり、それによってどの階層に表示するかを決める
- 上の階層にいくほど、左側のマージンを広めに確保する
- CalendarMultiColumnTypeという項目もあり、これは同じ時間に複数のイベントがあった場合に、イベントのcolumnとして設定できるようにするためのものです
- CalendarEventTypeという項目で、仕事や家族、プライベートの予定など、イベントの種類を表現できるようにしています
これを踏まえたCalendarUiWidgetを実装しました
今回はUI実装に特化しているので、上記の各予定から、CalendarItemに変換するロジックは持っていませんが、それを実装すればおおむね上記のUIを再現することができるでしょう
(ただGoogleカレンダーを触っているとわかりますが、例えば同じ時間に10個の予定を登録した時も、予定の文字は見えないですが、枠自体は正しく表示されるので、そのようなレアケースまで考慮すると、完成度を高めるのは難しいかと思います)
円環(アニメーション)
最後は円環の表示を時計回りに動かすアニメーションの実装についてです
- AnimatedBuilderを使って、angleを0->2 * math.piに変化させるアニメーションを実行し、かつそれを繰り返す設定をしている
- そのangleをCustomMultiChildLayoutのContainerに渡すことで実現している
- 工夫次第で複雑なレイアウトのアニメーションも実現できる
まとめ
CustomMultiChildLayoutは、既存のWidgetやContainerでは表現が難しいデザインの時に活躍する強い味方だと思います
しかしパフォーマンスを考慮して使うことが必要であり、特にスクロールが必要な場合は、Sliverのような最適化はされないので、注意が必要です
また学習コストが高く、運用にも明確なルールの設定や周知などが必要になるので、RowやColumn、Stackの基本的な使い方で実装できない場合の最終手段として使うのがよく、乱用は注意です
参考資料
- CustomMultiChildLayout class
- https://api.flutter.dev/flutter/widgets/CustomMultiChildLayout-class.html
- FlutterのCustomMultiChildLayoutとCustomSingleChildLayoutの使い方