[{"data":1,"prerenderedAt":683},["ShallowReactive",2],{"docs-/posts/tech/custom-single-child-layout":3,"docs-/tech/custom-single-child-layout-surround":670,"embed-https://dartpad.dev/?id=cce89b54af3a2b8f4d1524c886d81402":681},{"id":4,"title":5,"body":6,"categories":657,"description":660,"draft":661,"extension":662,"meta":663,"navigation":324,"path":664,"priority":665,"publishedAt":666,"secret":661,"seo":667,"stem":668,"updatedAt":666,"__hash__":669},"posts/tech/custom-single-child-layout.md","CustomSingleChildLayout と仲良くなって自由にウィジェットを配置しよう",{"type":7,"value":8,"toc":641},"minimal",[9,13,17,21,24,32,39,42,48,61,67,71,74,82,86,100,122,125,131,134,137,149,155,159,185,188,195,203,207,224,235,593,607,610,613,634,637],[10,11,12],"h2",{"id":12},"サンプルコード",[14,15,16],"p",{},"本記事で使用するコードは DartPad で動作確認できる。",[18,19],"link-card",{"link":20},"https://dartpad.dev/?id=cce89b54af3a2b8f4d1524c886d81402",[10,22,23],{"id":23},"はじめに",[14,25,26,27,31],{},"Flutter でツールチップを実装したことがあるだろうか。「対象ウィジェットから相対的に配置するだけだから、",[28,29,30],"code",{},"Stack + Positioned"," で問題ないよね。さくっと実装できそう」——そう思った矢先に詰まった経験はないだろうか。",[14,33,34,35,38],{},"本記事では、そのフラグをしっかり回収しながら、",[28,36,37],{},"CustomSingleChildLayout"," というウィジェットがなぜ有用なのか、そしてどう使えばいいのかを解説する。",[10,40,41],{"id":41},"直面した課題",[14,43,44,45,47],{},"きっかけは、Web（主要サービス）と同じ UI のツールチップを Flutter で実装することになったことだ。「対象ウィジェットから相対的に配置するだけだから ",[28,46,30],{}," で問題ないよね」と実装を始めたが、すぐに壁にぶつかった。",[49,50,52,53,56,57,60],"h3",{"id":51},"問題1-positioned-の-top-に渡す値がツールチップのコンテンツ量に依存してしまい静的な値を渡せない","問題（1） ",[28,54,55],{},"Positioned"," の ",[28,58,59],{},"top"," に渡す値がツールチップのコンテンツ量に依存してしまい、静的な値を渡せない",[14,62,63,64,66],{},"ツールチップの高さはコンテンツによって変わる。なのに ",[28,65,55],{}," は静的な値しか受け取れないため、動的な高さに応じた位置調整ができない。",[49,68,70],{"id":69},"問題2-対象ウィジェットの位置によってツールチップの最低幅を担保できないためx-の-offset-値を均一にできない","問題（2） 対象ウィジェットの位置によってツールチップの最低幅を担保できないため、x の offset 値を均一にできない。**",[14,72,73],{},"対象ウィジェットが画面端に近い場合、ツールチップが画面外にはみ出してしまう。x 方向の offset を一律に設定しても、画面端での折り返し処理ができないのだ。",[14,75,76,77,81],{},"つまり本題は、",[78,79,80],"strong",{},"「対象ウィジェットの位置やサイズによって、動的に配置するにはどうしたらいいか」"," ということになる。",[10,83,85],{"id":84},"flutter-が-ui-を構築するまで","Flutter が UI を構築するまで",[14,87,88,99],{},[89,90,97],"a",{"href":91,"rel":92,"target":96},"https://api.flutter.dev/flutter/widgets/CustomSingleChildLayout-class.html",[93,94,95],"nofollow","noopener","noreferrer","_blank",[28,98,37],{}," を理解するには、Flutter が UI を描画するまでの流れを知っておく必要がある。Flutter の UI 構築は以下の 3 フェーズで行われる。",[101,102,103,110,116],"ul",{},[104,105,106,109],"li",{},[78,107,108],{},"1. Build Phase"," — ウィジェット ツリーの構築",[104,111,112,115],{},[78,113,114],{},"2. Layout Phase"," — サイズ・位置の確定",[104,117,118,121],{},[78,119,120],{},"3. Paint Phase"," — 画面の描画。",[14,123,124],{},"Flutter のレイアウトの原則として、こんな言葉がある。",[126,127,128],"blockquote",{},[14,129,130],{},"Constraints go down. Sizes go up. Parent sets position.",[14,132,133],{},"訳すと、「制約は親から子へ渡り、サイズは子から親へ返り、位置は親が決める」ということだ。",[14,135,136],{},"Layout Phase をさらに詳しく見ると、次の 3 ステップで構成されている。",[138,139,140,143,146],"ol",{},[104,141,142],{},"Constraints を親から子へ流す（Top-down）",[104,144,145],{},"子が自分のサイズを決めて親に返す（Bottom-up）",[104,147,148],{},"親が子の位置（Offset）を決める（Top-down）",[14,150,151,152,154],{},"ここが ",[28,153,37],{}," のポイントになる。",[10,156,158],{"id":157},"customsinglechildlayout-とは何か","CustomSingleChildLayout とは何か",[14,160,161,166,167,170,171,178,179,184],{},[89,162,164],{"href":91,"rel":163,"target":96},[93,94,95],[28,165,37],{}," は、",[78,168,169],{},"子ウィジェットのレイアウト（制約・位置）を、子のサイズが確定した後に動的に制御できる ウィジェット"," だ。Flutter 公式の ",[89,172,175],{"href":173,"rel":174,"target":96},"https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/raw_tooltip.dart#L823",[93,94,95],[28,176,177],{},"Tooltip"," や OSS ライブラリで公開されている",[89,180,183],{"href":181,"rel":182,"target":96},"https://github.com/bensonarafat/super_tooltip/blob/master/lib/src/super_tooltip.dart#L473",[93,94,95],"カスタム Tooltip"," でも使われている。",[14,186,187],{},"主なユースケースは、ツールチップやドロップダウンの位置決めだ。",[49,189,191,192,194],{"id":190},"なぜ-stack-positioned-だとだめなのか","なぜ ",[28,193,30],{}," だとだめなのか",[14,196,197,56,200,202],{},[28,198,199],{},"Stack",[28,201,55],{}," はビルド時に決められた値を参照するため、Layout Phase の Step 2 で確定する子ウィジェットのサイズを参照できない。つまり、コンテンツ量によって変わる動的なサイズをレイアウトに反映できないのだ。",[49,204,206],{"id":205},"customsinglechildlayout-が解決する理由","CustomSingleChildLayout が解決する理由",[14,208,209,211,212,219,220,223],{},[28,210,37],{}," では、",[89,213,216],{"href":214,"rel":215,"target":96},"https://api.flutter.dev/flutter/rendering/SingleChildLayoutDelegate-class.html",[93,94,95],[28,217,218],{},"SingleChildLayoutDelegate"," を継承したクラスを用意する。そのクラスに ",[28,221,222],{},"getPositionForChild(Size parentSize, Size childSize)"," を実装する。",[14,225,226,227,230,231,234],{},"このメソッドは Layout Phase の ",[78,228,229],{},"Step 3（親が子の位置を決めるタイミング）"," で呼ばれる。そのため、Step 2 で確定した ",[28,232,233],{},"childSize","（子ウィジェットの実際のサイズ）を使って位置を動的に計算できる。",[236,237,243],"pre",{"className":238,"code":239,"filename":240,"language":241,"meta":242,"style":242},"language-dart shiki shiki-themes material-theme","class _TooltipPositionDelegate extends SingleChildLayoutDelegate {\n  _TooltipPositionDelegate({\n    required this.targetOffset,\n    required this.context,\n  });\n\n  final Offset targetOffset;\n  final BuildContext context;\n\n  final double verticalSpacing = 8.0;\n\n  @override\n  Offset getPositionForChild(Size size, Size childSize) {\n    final fullWidth = MediaQuery.sizeOf(context).width;\n    // 対象ウィジェットが右に寄っていて、ツールチップが画面外にはみ出すかどうかを判定する\n    final overflow = (fullWidth - targetOffset.dx + 200) \u003C 0;\n\n    return Offset(\n      // はみ出す場合はツールチップを左に寄せ、画面端から 16px のマージンを確保する\n      overflow ? -targetOffset.dx + 16 : 0,\n      // ツールチップを対象ウィジェットの上に配置し、verticalSpacing 分のスペースを開ける\n      -verticalSpacing - childSize.height,\n    );\n  }\n}\n","custom_single_child_layout.dart","dart","",[28,244,245,268,277,296,310,319,326,340,353,358,378,383,389,417,446,453,493,498,510,516,546,552,573,581,587],{"__ignoreMap":242},[246,247,250,254,258,261,264],"span",{"class":248,"line":249},"line",1,[246,251,253],{"class":252},"sAklC","class",[246,255,257],{"class":256},"s5Dmg"," _TooltipPositionDelegate",[246,259,260],{"class":252}," extends",[246,262,263],{"class":256}," SingleChildLayoutDelegate",[246,265,267],{"class":266},"svy0-"," {\n",[246,269,271,274],{"class":248,"line":270},2,[246,272,273],{"class":256},"  _TooltipPositionDelegate",[246,275,276],{"class":266},"({\n",[246,278,280,284,287,290,293],{"class":248,"line":279},3,[246,281,283],{"class":282},"sJ14y","    required",[246,285,286],{"class":266}," this",[246,288,289],{"class":252},".",[246,291,292],{"class":266},"targetOffset",[246,294,295],{"class":252},",\n",[246,297,299,301,303,305,308],{"class":248,"line":298},4,[246,300,283],{"class":282},[246,302,286],{"class":266},[246,304,289],{"class":252},[246,306,307],{"class":266},"context",[246,309,295],{"class":252},[246,311,313,316],{"class":248,"line":312},5,[246,314,315],{"class":266},"  })",[246,317,318],{"class":252},";\n",[246,320,322],{"class":248,"line":321},6,[246,323,325],{"emptyLinePlaceholder":324},true,"\n",[246,327,329,332,335,338],{"class":248,"line":328},7,[246,330,331],{"class":282},"  final",[246,333,334],{"class":256}," Offset",[246,336,337],{"class":266}," targetOffset",[246,339,318],{"class":252},[246,341,343,345,348,351],{"class":248,"line":342},8,[246,344,331],{"class":282},[246,346,347],{"class":256}," BuildContext",[246,349,350],{"class":266}," context",[246,352,318],{"class":252},[246,354,356],{"class":248,"line":355},9,[246,357,325],{"emptyLinePlaceholder":324},[246,359,361,363,366,369,372,376],{"class":248,"line":360},10,[246,362,331],{"class":282},[246,364,365],{"class":256}," double",[246,367,368],{"class":266}," verticalSpacing ",[246,370,371],{"class":252},"=",[246,373,375],{"class":374},"sx098"," 8.0",[246,377,318],{"class":252},[246,379,381],{"class":248,"line":380},11,[246,382,325],{"emptyLinePlaceholder":324},[246,384,386],{"class":248,"line":385},12,[246,387,388],{"class":282},"  @override\n",[246,390,392,395,399,402,405,408,411,414],{"class":248,"line":391},13,[246,393,394],{"class":256},"  Offset",[246,396,398],{"class":397},"sdLwU"," getPositionForChild",[246,400,401],{"class":266},"(",[246,403,404],{"class":256},"Size",[246,406,407],{"class":266}," size",[246,409,410],{"class":252},",",[246,412,413],{"class":256}," Size",[246,415,416],{"class":266}," childSize) {\n",[246,418,420,423,426,428,431,433,436,439,441,444],{"class":248,"line":419},14,[246,421,422],{"class":282},"    final",[246,424,425],{"class":266}," fullWidth ",[246,427,371],{"class":252},[246,429,430],{"class":256}," MediaQuery",[246,432,289],{"class":252},[246,434,435],{"class":397},"sizeOf",[246,437,438],{"class":266},"(context)",[246,440,289],{"class":252},[246,442,443],{"class":266},"width",[246,445,318],{"class":252},[246,447,449],{"class":248,"line":448},15,[246,450,452],{"class":451},"s0_hs","    // 対象ウィジェットが右に寄っていて、ツールチップが画面外にはみ出すかどうかを判定する\n",[246,454,456,458,461,463,466,469,471,473,476,479,482,485,488,491],{"class":248,"line":455},16,[246,457,422],{"class":282},[246,459,460],{"class":266}," overflow ",[246,462,371],{"class":252},[246,464,465],{"class":266}," (fullWidth ",[246,467,468],{"class":252},"-",[246,470,337],{"class":266},[246,472,289],{"class":252},[246,474,475],{"class":266},"dx ",[246,477,478],{"class":252},"+",[246,480,481],{"class":374}," 200",[246,483,484],{"class":266},") ",[246,486,487],{"class":252},"\u003C",[246,489,490],{"class":374}," 0",[246,492,318],{"class":252},[246,494,496],{"class":248,"line":495},17,[246,497,325],{"emptyLinePlaceholder":324},[246,499,501,505,507],{"class":248,"line":500},18,[246,502,504],{"class":503},"s6cf3","    return",[246,506,334],{"class":256},[246,508,509],{"class":266},"(\n",[246,511,513],{"class":248,"line":512},19,[246,514,515],{"class":451},"      // はみ出す場合はツールチップを左に寄せ、画面端から 16px のマージンを確保する\n",[246,517,519,522,525,528,530,532,534,536,539,542,544],{"class":248,"line":518},20,[246,520,521],{"class":266},"      overflow ",[246,523,524],{"class":252},"?",[246,526,527],{"class":252}," -",[246,529,292],{"class":266},[246,531,289],{"class":252},[246,533,475],{"class":266},[246,535,478],{"class":252},[246,537,538],{"class":374}," 16",[246,540,541],{"class":252}," :",[246,543,490],{"class":374},[246,545,295],{"class":252},[246,547,549],{"class":248,"line":548},21,[246,550,551],{"class":451},"      // ツールチップを対象ウィジェットの上に配置し、verticalSpacing 分のスペースを開ける\n",[246,553,555,558,561,563,566,568,571],{"class":248,"line":554},22,[246,556,557],{"class":252},"      -",[246,559,560],{"class":266},"verticalSpacing ",[246,562,468],{"class":252},[246,564,565],{"class":266}," childSize",[246,567,289],{"class":252},[246,569,570],{"class":266},"height",[246,572,295],{"class":252},[246,574,576,579],{"class":248,"line":575},23,[246,577,578],{"class":266},"    )",[246,580,318],{"class":252},[246,582,584],{"class":248,"line":583},24,[246,585,586],{"class":266},"  }\n",[246,588,590],{"class":248,"line":589},25,[246,591,592],{"class":266},"}\n",[14,594,595,596,599,600,603,604,606],{},"上記のコードでは、",[28,597,598],{},"childSize.height"," を使ってツールチップを対象ウィジェットの上に正確に配置している。また ",[28,601,602],{},"overflow"," の判定で画面端を超える場合は x 方向の offset を調整している。これが ",[28,605,30],{}," では実現できなかった処理だ。",[608,609],"hr",{},[10,611,612],{"id":612},"まとめ",[101,614,615,618,621,626,631],{},[104,616,617],{},"Flutter の UI 構築は Build → Layout → Paint の 3 フェーズで行われる",[104,619,620],{},"Layout Phase では「制約が下へ流れ、サイズが上へ返り、親が位置を決める」",[104,622,623,625],{},[28,624,30],{}," は子のサイズが確定する前に位置を決めてしまうため、動的な配置が難しい",[104,627,628,630],{},[28,629,37],{}," は子のサイズが確定した後に位置を動的に計算できるため、ツールチップやドロップダウンのような柔軟な配置に向いている",[104,632,633],{},"周りがどう対処しているかをオープンソースで調べるなど、泥臭く調査することも解決への近道だ",[14,635,636],{},"「さくっと実装できそう」と思った瞬間こそ、深い理解への入口だったりする。",[638,639,640],"style",{},"html pre.shiki code .sAklC, html code.shiki .sAklC{--shiki-default:#89DDFF}html pre.shiki code .s5Dmg, html code.shiki .s5Dmg{--shiki-default:#FFCB6B}html pre.shiki code .svy0-, html code.shiki .svy0-{--shiki-default:#EEFFFF}html pre.shiki code .sJ14y, html code.shiki .sJ14y{--shiki-default:#C792EA}html pre.shiki code .sx098, html code.shiki .sx098{--shiki-default:#F78C6C}html pre.shiki code .sdLwU, html code.shiki .sdLwU{--shiki-default:#82AAFF}html pre.shiki code .s0_hs, html code.shiki .s0_hs{--shiki-default:#546E7A;--shiki-default-font-style:italic}html pre.shiki code .s6cf3, html code.shiki .s6cf3{--shiki-default:#89DDFF;--shiki-default-font-style:italic}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":242,"searchDepth":270,"depth":270,"links":642},[643,644,645,650,651,656],{"id":12,"depth":270,"text":12},{"id":23,"depth":270,"text":23},{"id":41,"depth":270,"text":41,"children":646},[647,649],{"id":51,"depth":279,"text":648},"問題（1） Positioned の top に渡す値がツールチップのコンテンツ量に依存してしまい、静的な値を渡せない",{"id":69,"depth":279,"text":70},{"id":84,"depth":270,"text":85},{"id":157,"depth":270,"text":158,"children":652},[653,655],{"id":190,"depth":279,"text":654},"なぜ Stack + Positioned だとだめなのか",{"id":205,"depth":279,"text":206},{"id":612,"depth":270,"text":612},[658,659],"Tech","Flutter","Stack + Positioned では対応できない動的なウィジェット配置を CustomSingleChildLayout で解決する方法を、Flutter の UI 構築フェーズの仕組みとともに解説する",false,"md",{},"/tech/custom-single-child-layout",null,"2025-12-04",{"title":5,"description":660},"tech/custom-single-child-layout","5gb3tIsSWynhALClUc15Cp2iwr8q7RYYvG7NyN9hew8",[671,676],{"title":672,"path":673,"stem":674,"description":675,"children":-1},"pnpm monorepo でビルドいらずのパッケージ参照を実現する — Custom Condition という選択肢","/tech/pnpm-monorepo-custom-condition","tech/pnpm-monorepo-custom-condition","pnpm monorepo でコンポーネントライブラリを変更するたびにビルドが必要な問題を、package.json の Custom Condition を使ってビルドなしで TypeScript ソースを直接参照できるように解決する方法を紹介します。",{"title":677,"path":678,"stem":679,"description":680,"children":-1},"リストの要素が少なくてもプルリフレッシュできるようにする","/tech/spared-height-refresh-indicator","tech/spared-height-refresh-indicator","要素が少ない ListView でプルリフレッシュが効かない問題を、LayoutBuilder と ConstrainedBox を組み合わせたシンプルなウィジェットで解決する方法を紹介します。",{"metadata":682},{"url":20,"title":665,"description":665,"imageUrl":665},1778491803908]