本記事で使用するコードは DartPad で動作確認できる。
https://dartpad.dev/?id=cce89b54af3a2b8f4d1524c886d81402
https://dartpad.dev
Flutter でツールチップを実装したことがあるだろうか。「対象ウィジェットから相対的に配置するだけだから、Stack + Positioned で問題ないよね。さくっと実装できそう」——そう思った矢先に詰まった経験はないだろうか。
本記事では、そのフラグをしっかり回収しながら、CustomSingleChildLayout というウィジェットがなぜ有用なのか、そしてどう使えばいいのかを解説する。
きっかけは、Web(主要サービス)と同じ UI のツールチップを Flutter で実装することになったことだ。「対象ウィジェットから相対的に配置するだけだから Stack + Positioned で問題ないよね」と実装を始めたが、すぐに壁にぶつかった。
Positioned の top に渡す値がツールチップのコンテンツ量に依存してしまい、静的な値を渡せないツールチップの高さはコンテンツによって変わる。なのに Positioned は静的な値しか受け取れないため、動的な高さに応じた位置調整ができない。
対象ウィジェットが画面端に近い場合、ツールチップが画面外にはみ出してしまう。x 方向の offset を一律に設定しても、画面端での折り返し処理ができないのだ。
つまり本題は、「対象ウィジェットの位置やサイズによって、動的に配置するにはどうしたらいいか」 ということになる。
CustomSingleChildLayout を理解するには、Flutter が UI を描画するまでの流れを知っておく必要がある。Flutter の UI 構築は以下の 3 フェーズで行われる。
Flutter のレイアウトの原則として、こんな言葉がある。
Constraints go down. Sizes go up. Parent sets position.
訳すと、「制約は親から子へ渡り、サイズは子から親へ返り、位置は親が決める」ということだ。
Layout Phase をさらに詳しく見ると、次の 3 ステップで構成されている。
ここが CustomSingleChildLayout のポイントになる。
CustomSingleChildLayout は、子ウィジェットのレイアウト(制約・位置)を、子のサイズが確定した後に動的に制御できる ウィジェット だ。Flutter 公式の Tooltip や OSS ライブラリで公開されているカスタム Tooltip でも使われている。
主なユースケースは、ツールチップやドロップダウンの位置決めだ。
Stack + Positioned だとだめなのかStack の Positioned はビルド時に決められた値を参照するため、Layout Phase の Step 2 で確定する子ウィジェットのサイズを参照できない。つまり、コンテンツ量によって変わる動的なサイズをレイアウトに反映できないのだ。
CustomSingleChildLayout では、SingleChildLayoutDelegate を継承したクラスを用意する。そのクラスに getPositionForChild(Size parentSize, Size childSize) を実装する。
このメソッドは Layout Phase の Step 3(親が子の位置を決めるタイミング) で呼ばれる。そのため、Step 2 で確定した childSize(子ウィジェットの実際のサイズ)を使って位置を動的に計算できる。
class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
_TooltipPositionDelegate({
required this.targetOffset,
required this.context,
});
final Offset targetOffset;
final BuildContext context;
final double verticalSpacing = 8.0;
@override
Offset getPositionForChild(Size size, Size childSize) {
final fullWidth = MediaQuery.sizeOf(context).width;
// 対象ウィジェットが右に寄っていて、ツールチップが画面外にはみ出すかどうかを判定する
final overflow = (fullWidth - targetOffset.dx + 200) < 0;
return Offset(
// はみ出す場合はツールチップを左に寄せ、画面端から 16px のマージンを確保する
overflow ? -targetOffset.dx + 16 : 0,
// ツールチップを対象ウィジェットの上に配置し、verticalSpacing 分のスペースを開ける
-verticalSpacing - childSize.height,
);
}
}
上記のコードでは、childSize.height を使ってツールチップを対象ウィジェットの上に正確に配置している。また overflow の判定で画面端を超える場合は x 方向の offset を調整している。これが Stack + Positioned では実現できなかった処理だ。
Stack + Positioned は子のサイズが確定する前に位置を決めてしまうため、動的な配置が難しいCustomSingleChildLayout は子のサイズが確定した後に位置を動的に計算できるため、ツールチップやドロップダウンのような柔軟な配置に向いている「さくっと実装できそう」と思った瞬間こそ、深い理解への入口だったりする。
pnpm monorepo でビルドいらずのパッケージ参照を実現する — Custom Condition という選択肢
pnpm monorepo でコンポーネントライブラリを変更するたびにビルドが必要な問題を、package.json の Custom Condition を使ってビルドなしで TypeScript ソースを直接参照できるように解決する方法を紹介します。
リストの要素が少なくてもプルリフレッシュできるようにする
要素が少ない ListView でプルリフレッシュが効かない問題を、LayoutBuilder と ConstrainedBox を組み合わせたシンプルなウィジェットで解決する方法を紹介します。