CustomSingleChildLayout と仲良くなって自由にウィジェットを配置しよう

作成日: 2025-12-04 /

サンプルコード

本記事で使用するコードは DartPad で動作確認できる。

https://dartpad.dev/?id=cce89b54af3a2b8f4d1524c886d81402

https://dartpad.dev

はじめに

Flutter でツールチップを実装したことがあるだろうか。「対象ウィジェットから相対的に配置するだけだから、Stack + Positioned で問題ないよね。さくっと実装できそう」——そう思った矢先に詰まった経験はないだろうか。

本記事では、そのフラグをしっかり回収しながら、CustomSingleChildLayout というウィジェットがなぜ有用なのか、そしてどう使えばいいのかを解説する。

直面した課題

きっかけは、Web(主要サービス)と同じ UI のツールチップを Flutter で実装することになったことだ。「対象ウィジェットから相対的に配置するだけだから Stack + Positioned で問題ないよね」と実装を始めたが、すぐに壁にぶつかった。

問題(1) Positionedtop に渡す値がツールチップのコンテンツ量に依存してしまい、静的な値を渡せない

ツールチップの高さはコンテンツによって変わる。なのに Positioned は静的な値しか受け取れないため、動的な高さに応じた位置調整ができない。

問題(2) 対象ウィジェットの位置によってツールチップの最低幅を担保できないため、x の offset 値を均一にできない。**

対象ウィジェットが画面端に近い場合、ツールチップが画面外にはみ出してしまう。x 方向の offset を一律に設定しても、画面端での折り返し処理ができないのだ。

つまり本題は、「対象ウィジェットの位置やサイズによって、動的に配置するにはどうしたらいいか」 ということになる。

Flutter が UI を構築するまで

CustomSingleChildLayout を理解するには、Flutter が UI を描画するまでの流れを知っておく必要がある。Flutter の UI 構築は以下の 3 フェーズで行われる。

  • 1. Build Phase — ウィジェット ツリーの構築
  • 2. Layout Phase — サイズ・位置の確定
  • 3. Paint Phase — 画面の描画。

Flutter のレイアウトの原則として、こんな言葉がある。

Constraints go down. Sizes go up. Parent sets position.

訳すと、「制約は親から子へ渡り、サイズは子から親へ返り、位置は親が決める」ということだ。

Layout Phase をさらに詳しく見ると、次の 3 ステップで構成されている。

  1. Constraints を親から子へ流す(Top-down)
  2. 子が自分のサイズを決めて親に返す(Bottom-up)
  3. 親が子の位置(Offset)を決める(Top-down)

ここが CustomSingleChildLayout のポイントになる。

CustomSingleChildLayout とは何か

CustomSingleChildLayout は、子ウィジェットのレイアウト(制約・位置)を、子のサイズが確定した後に動的に制御できる ウィジェット だ。Flutter 公式の Tooltip や OSS ライブラリで公開されているカスタム Tooltip でも使われている。

主なユースケースは、ツールチップやドロップダウンの位置決めだ。

なぜ Stack + Positioned だとだめなのか

StackPositioned はビルド時に決められた値を参照するため、Layout Phase の Step 2 で確定する子ウィジェットのサイズを参照できない。つまり、コンテンツ量によって変わる動的なサイズをレイアウトに反映できないのだ。

CustomSingleChildLayout が解決する理由

CustomSingleChildLayout では、SingleChildLayoutDelegate を継承したクラスを用意する。そのクラスに getPositionForChild(Size parentSize, Size childSize) を実装する。

このメソッドは Layout Phase の Step 3(親が子の位置を決めるタイミング) で呼ばれる。そのため、Step 2 で確定した childSize(子ウィジェットの実際のサイズ)を使って位置を動的に計算できる。

custom_single_child_layout.dart
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 では実現できなかった処理だ。


まとめ

  • Flutter の UI 構築は Build → Layout → Paint の 3 フェーズで行われる
  • Layout Phase では「制約が下へ流れ、サイズが上へ返り、親が位置を決める」
  • Stack + Positioned は子のサイズが確定する前に位置を決めてしまうため、動的な配置が難しい
  • CustomSingleChildLayout は子のサイズが確定した後に位置を動的に計算できるため、ツールチップやドロップダウンのような柔軟な配置に向いている
  • 周りがどう対処しているかをオープンソースで調べるなど、泥臭く調査することも解決への近道だ

「さくっと実装できそう」と思った瞬間こそ、深い理解への入口だったりする。