[{"data":1,"prerenderedAt":701},["ShallowReactive",2],{"docs-/posts/tech/spared-height-refresh-indicator":3,"docs-/tech/spared-height-refresh-indicator-surround":690},{"id":4,"title":5,"body":6,"categories":677,"description":680,"draft":681,"extension":682,"meta":683,"navigation":65,"path":684,"priority":685,"publishedAt":686,"secret":681,"seo":687,"stem":688,"updatedAt":686,"__hash__":689},"posts/tech/spared-height-refresh-indicator.md","リストの要素が少なくてもプルリフレッシュできるようにする",{"type":7,"value":8,"toc":665},"minimal",[9,13,22,25,31,34,426,429,437,440,444,452,457,471,475,489,493,502,505,508,621,630,633,658,661],[10,11,12],"h2",{"id":12},"はじめに",[14,15,16,17,21],"p",{},"Flutter で ",[18,19,20],"code",{},"ListView"," を使った画面を実装していると、こんな場面に出くわすことがある。",[14,23,24],{},"「要素が少ないとき、プルリフレッシュが効かない」",[14,26,27,30],{},[18,28,29],{},"RefreshIndicator"," を使っているのに、リストの要素が少なくてスクロールが発生しない場合、引っ張っても何も起きない。ユーザーが更新しようとしても反応がなく、UX として不親切だ。本記事では、このよくある課題をシンプルなウィジェットで解決する方法を紹介する。",[10,32,33],{"id":33},"サンプルコード",[35,36,41],"pre",{"className":37,"code":38,"language":39,"meta":40,"style":40},"language-dart shiki shiki-themes material-theme","import 'package:flutter/material.dart';\n\nclass SparedHeightRefreshIndicator extends StatelessWidget {\n  const SparedHeightRefreshIndicator({\n    required this.child,\n    this.onRefresh,\n    super.key,\n  });\n\n  final Widget child;\n  final Future\u003Cvoid> Function()? onRefresh;\n\n  @override\n  Widget build(BuildContext context) {\n    return RefreshIndicator(\n      onRefresh: onRefresh ?? Future.value,\n      // onRefresh が null の場合はリフレッシュを無効にする\n      notificationPredicate: (_) => onRefresh != null,\n      child: LayoutBuilder(\n        builder: (context, constraints) {\n          return ConstrainedBox(\n            // 子ウィジェットの最小高さを画面いっぱいに広げる\n            constraints: BoxConstraints(minHeight: constraints.maxHeight),\n            child: child,\n          );\n        },\n      ),\n    );\n  }\n}\n","dart","",[18,42,43,60,67,87,99,117,130,143,151,156,170,201,206,212,231,244,268,275,297,310,327,338,344,370,382,390,398,406,414,420],{"__ignoreMap":40},[44,45,48,52,56],"span",{"class":46,"line":47},"line",1,[44,49,51],{"class":50},"sx098","import",[44,53,55],{"class":54},"sfyAc"," 'package:flutter/material.dart'",[44,57,59],{"class":58},"sAklC",";\n",[44,61,63],{"class":46,"line":62},2,[44,64,66],{"emptyLinePlaceholder":65},true,"\n",[44,68,70,73,77,80,83],{"class":46,"line":69},3,[44,71,72],{"class":58},"class",[44,74,76],{"class":75},"s5Dmg"," SparedHeightRefreshIndicator",[44,78,79],{"class":58}," extends",[44,81,82],{"class":75}," StatelessWidget",[44,84,86],{"class":85},"svy0-"," {\n",[44,88,90,94,96],{"class":46,"line":89},4,[44,91,93],{"class":92},"sJ14y","  const",[44,95,76],{"class":75},[44,97,98],{"class":85},"({\n",[44,100,102,105,108,111,114],{"class":46,"line":101},5,[44,103,104],{"class":92},"    required",[44,106,107],{"class":85}," this",[44,109,110],{"class":58},".",[44,112,113],{"class":85},"child",[44,115,116],{"class":58},",\n",[44,118,120,123,125,128],{"class":46,"line":119},6,[44,121,122],{"class":85},"    this",[44,124,110],{"class":58},[44,126,127],{"class":85},"onRefresh",[44,129,116],{"class":58},[44,131,133,136,138,141],{"class":46,"line":132},7,[44,134,135],{"class":85},"    super",[44,137,110],{"class":58},[44,139,140],{"class":85},"key",[44,142,116],{"class":58},[44,144,146,149],{"class":46,"line":145},8,[44,147,148],{"class":85},"  })",[44,150,59],{"class":58},[44,152,154],{"class":46,"line":153},9,[44,155,66],{"emptyLinePlaceholder":65},[44,157,159,162,165,168],{"class":46,"line":158},10,[44,160,161],{"class":92},"  final",[44,163,164],{"class":75}," Widget",[44,166,167],{"class":85}," child",[44,169,59],{"class":58},[44,171,173,175,178,181,184,187,190,193,196,199],{"class":46,"line":172},11,[44,174,161],{"class":92},[44,176,177],{"class":75}," Future",[44,179,180],{"class":85},"\u003C",[44,182,183],{"class":92},"void",[44,185,186],{"class":85},"> ",[44,188,189],{"class":75},"Function",[44,191,192],{"class":85},"()",[44,194,195],{"class":58},"?",[44,197,198],{"class":85}," onRefresh",[44,200,59],{"class":58},[44,202,204],{"class":46,"line":203},12,[44,205,66],{"emptyLinePlaceholder":65},[44,207,209],{"class":46,"line":208},13,[44,210,211],{"class":92},"  @override\n",[44,213,215,218,222,225,228],{"class":46,"line":214},14,[44,216,217],{"class":75},"  Widget",[44,219,221],{"class":220},"sdLwU"," build",[44,223,224],{"class":85},"(",[44,226,227],{"class":75},"BuildContext",[44,229,230],{"class":85}," context) {\n",[44,232,234,238,241],{"class":46,"line":233},15,[44,235,237],{"class":236},"s6cf3","    return",[44,239,240],{"class":75}," RefreshIndicator",[44,242,243],{"class":85},"(\n",[44,245,247,250,253,256,259,261,263,266],{"class":46,"line":246},16,[44,248,249],{"class":85},"      onRefresh",[44,251,252],{"class":58},":",[44,254,255],{"class":85}," onRefresh ",[44,257,258],{"class":58},"??",[44,260,177],{"class":75},[44,262,110],{"class":58},[44,264,265],{"class":85},"value",[44,267,116],{"class":58},[44,269,271],{"class":46,"line":270},17,[44,272,274],{"class":273},"s0_hs","      // onRefresh が null の場合はリフレッシュを無効にする\n",[44,276,278,281,283,286,289,291,294],{"class":46,"line":277},18,[44,279,280],{"class":85},"      notificationPredicate",[44,282,252],{"class":58},[44,284,285],{"class":85}," (_) ",[44,287,288],{"class":58},"=>",[44,290,255],{"class":85},[44,292,293],{"class":58},"!=",[44,295,296],{"class":58}," null,\n",[44,298,300,303,305,308],{"class":46,"line":299},19,[44,301,302],{"class":85},"      child",[44,304,252],{"class":58},[44,306,307],{"class":75}," LayoutBuilder",[44,309,243],{"class":85},[44,311,313,316,318,321,324],{"class":46,"line":312},20,[44,314,315],{"class":85},"        builder",[44,317,252],{"class":58},[44,319,320],{"class":85}," (context",[44,322,323],{"class":58},",",[44,325,326],{"class":85}," constraints) {\n",[44,328,330,333,336],{"class":46,"line":329},21,[44,331,332],{"class":236},"          return",[44,334,335],{"class":75}," ConstrainedBox",[44,337,243],{"class":85},[44,339,341],{"class":46,"line":340},22,[44,342,343],{"class":273},"            // 子ウィジェットの最小高さを画面いっぱいに広げる\n",[44,345,347,350,352,355,358,360,363,365,368],{"class":46,"line":346},23,[44,348,349],{"class":85},"            constraints",[44,351,252],{"class":58},[44,353,354],{"class":75}," BoxConstraints",[44,356,357],{"class":85},"(minHeight",[44,359,252],{"class":58},[44,361,362],{"class":85}," constraints",[44,364,110],{"class":58},[44,366,367],{"class":85},"maxHeight)",[44,369,116],{"class":58},[44,371,373,376,378,380],{"class":46,"line":372},24,[44,374,375],{"class":85},"            child",[44,377,252],{"class":58},[44,379,167],{"class":85},[44,381,116],{"class":58},[44,383,385,388],{"class":46,"line":384},25,[44,386,387],{"class":85},"          )",[44,389,59],{"class":58},[44,391,393,396],{"class":46,"line":392},26,[44,394,395],{"class":85},"        }",[44,397,116],{"class":58},[44,399,401,404],{"class":46,"line":400},27,[44,402,403],{"class":85},"      )",[44,405,116],{"class":58},[44,407,409,412],{"class":46,"line":408},28,[44,410,411],{"class":85},"    )",[44,413,59],{"class":58},[44,415,417],{"class":46,"line":416},29,[44,418,419],{"class":85},"  }\n",[44,421,423],{"class":46,"line":422},30,[44,424,425],{"class":85},"}\n",[10,427,428],{"id":428},"なぜリストの要素が少ないとプルリフレッシュできないのか",[14,430,431,433,434,436],{},[18,432,29],{}," はスクロール可能なウィジェット（",[18,435,20],{}," など）の一番上でさらに引っ張ったときに発火する仕組みだ。",[14,438,439],{},"しかし、リストの要素が少ない場合、コンテンツの合計高さが画面の高さに満たないため、そもそもスクロールが発生しない。スクロールが発生しないということは、引っ張る動作を検知できず、リフレッシュが一切効かなくなってしまう。これはバグではなく Flutter の仕様だが、ユーザー視点では「更新できない」という体験になってしまうため、対処が必要だ。",[10,441,443],{"id":442},"解決策最小高さを画面いっぱいに広げる","解決策：最小高さを画面いっぱいに広げる",[14,445,446,447,451],{},"解決のアイデアはシンプルだ。",[448,449,450],"strong",{},"コンテンツの高さが足りないなら、最小高さを画面いっぱいまで広げてしまえばいい。"," そうすることで、コンテンツが少ない場合でもスクロール可能な領域が確保され、プルリフレッシュが正常に動作する。",[453,454,456],"h3",{"id":455},"layoutbuilder-で親の高さを取得する","LayoutBuilder で親の高さを取得する",[14,458,459,462,463,466,467,470],{},[18,460,461],{},"LayoutBuilder"," を使うと、親ウィジェットが提供する制約（",[18,464,465],{},"constraints","）を取得できる。",[18,468,469],{},"constraints.maxHeight"," は画面の高さ（または親の最大高さ）を表す。",[453,472,474],{"id":473},"constrainedbox-で最小高さを指定する","ConstrainedBox で最小高さを指定する",[14,476,477,480,481,484,485,488],{},[18,478,479],{},"ConstrainedBox"," に ",[18,482,483],{},"BoxConstraints(minHeight: constraints.maxHeight)"," を渡すことで、子ウィジェットの高さを制約できる。",[18,486,487],{},"maxHeight"," を下回らないよう保証されるため、コンテンツが少なくてもスクロール領域が確保される。コンテンツが多い場合は自然にそれ以上の高さになるため、通常のスクロール動作も損なわれない。",[453,490,492],{"id":491},"onrefresh-が-null-のときはリフレッシュを無効にする","onRefresh が null のときはリフレッシュを無効にする",[14,494,495,498,499,501],{},[18,496,497],{},"notificationPredicate: (_) => onRefresh != null"," を指定することで、",[18,500,127],{}," が渡されていない場合はリフレッシュの発火自体を無効にしている。これにより、リフレッシュ不要な画面でこのウィジェットを使い回すことも可能だ。",[503,504],"hr",{},[10,506,507],{"id":507},"使い方",[35,509,511],{"className":37,"code":510,"language":39,"meta":40,"style":40},"SparedHeightRefreshIndicator(\n  onRefresh: () async {\n    await fetchData();\n  },\n  child: ListView(\n    children: items.map((item) => ListTile(title: Text(item))).toList(),\n  ),\n)\n",[18,512,513,520,535,547,554,566,609,616],{"__ignoreMap":40},[44,514,515,518],{"class":46,"line":47},[44,516,517],{"class":75},"SparedHeightRefreshIndicator",[44,519,243],{"class":85},[44,521,522,525,527,530,533],{"class":46,"line":62},[44,523,524],{"class":85},"  onRefresh",[44,526,252],{"class":58},[44,528,529],{"class":85}," () ",[44,531,532],{"class":236},"async",[44,534,86],{"class":85},[44,536,537,540,543,545],{"class":46,"line":69},[44,538,539],{"class":236},"    await",[44,541,542],{"class":220}," fetchData",[44,544,192],{"class":85},[44,546,59],{"class":58},[44,548,549,552],{"class":46,"line":89},[44,550,551],{"class":85},"  }",[44,553,116],{"class":58},[44,555,556,559,561,564],{"class":46,"line":101},[44,557,558],{"class":85},"  child",[44,560,252],{"class":58},[44,562,563],{"class":75}," ListView",[44,565,243],{"class":85},[44,567,568,571,573,576,578,581,584,586,589,592,594,597,600,602,605,607],{"class":46,"line":119},[44,569,570],{"class":85},"    children",[44,572,252],{"class":58},[44,574,575],{"class":85}," items",[44,577,110],{"class":58},[44,579,580],{"class":220},"map",[44,582,583],{"class":85},"((item) ",[44,585,288],{"class":58},[44,587,588],{"class":75}," ListTile",[44,590,591],{"class":85},"(title",[44,593,252],{"class":58},[44,595,596],{"class":75}," Text",[44,598,599],{"class":85},"(item)))",[44,601,110],{"class":58},[44,603,604],{"class":220},"toList",[44,606,192],{"class":85},[44,608,116],{"class":58},[44,610,611,614],{"class":46,"line":132},[44,612,613],{"class":85},"  )",[44,615,116],{"class":58},[44,617,618],{"class":46,"line":145},[44,619,620],{"class":85},")\n",[14,622,623,624,626,627,629],{},"既存の ",[18,625,29],{}," と ",[18,628,20],{}," の組み合わせを、このウィジェットに置き換えるだけで対応できる。",[10,631,632],{"id":632},"まとめ",[634,635,636,642,650,653],"ul",{},[637,638,639,641],"li",{},[18,640,29],{}," はスクロールが発生しないとプルリフレッシュが効かない",[637,643,644,646,647,649],{},[18,645,461],{}," + ",[18,648,479],{}," でコンテンツの最小高さを画面いっぱいに広げることで解決できる",[637,651,652],{},"コンテンツが多い場合は通常のスクロール動作のままで、影響はない",[637,654,655,657],{},[18,656,127],{}," の有無で制御できるため、汎用ウィジェットとして使い回しやすい",[14,659,660],{},"小さなウィジェットだが、地味に詰まりやすいポイントをスッキリ解決してくれる。同じ課題にぶつかった際はぜひ参考にしてほしい。",[662,663,664],"style",{},"html pre.shiki code .sx098, html code.shiki .sx098{--shiki-default:#F78C6C}html pre.shiki code .sfyAc, html code.shiki .sfyAc{--shiki-default:#C3E88D}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 .sdLwU, html code.shiki .sdLwU{--shiki-default:#82AAFF}html pre.shiki code .s6cf3, html code.shiki .s6cf3{--shiki-default:#89DDFF;--shiki-default-font-style:italic}html pre.shiki code .s0_hs, html code.shiki .s0_hs{--shiki-default:#546E7A;--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":40,"searchDepth":62,"depth":62,"links":666},[667,668,669,670,675,676],{"id":12,"depth":62,"text":12},{"id":33,"depth":62,"text":33},{"id":428,"depth":62,"text":428},{"id":442,"depth":62,"text":443,"children":671},[672,673,674],{"id":455,"depth":69,"text":456},{"id":473,"depth":69,"text":474},{"id":491,"depth":69,"text":492},{"id":507,"depth":62,"text":507},{"id":632,"depth":62,"text":632},[678,679],"Tech","Flutter","要素が少ない ListView でプルリフレッシュが効かない問題を、LayoutBuilder と ConstrainedBox を組み合わせたシンプルなウィジェットで解決する方法を紹介します。",false,"md",{},"/tech/spared-height-refresh-indicator",null,"2025-08-24",{"title":5,"description":680},"tech/spared-height-refresh-indicator","7XSDmFIlmYXcbWXCnzd9jQt62L3FRkYiExsN6Hqg4AU",[691,696],{"title":692,"path":693,"stem":694,"description":695,"children":-1},"CustomSingleChildLayout と仲良くなって自由にウィジェットを配置しよう","/tech/custom-single-child-layout","tech/custom-single-child-layout","Stack + Positioned では対応できない動的なウィジェット配置を CustomSingleChildLayout で解決する方法を、Flutter の UI 構築フェーズの仕組みとともに解説する",{"title":697,"path":698,"stem":699,"description":700,"children":-1},"Nuxt Content v3 マイグレーションガイド","/tech/nuxt-content-v3-migration","tech/nuxt-content-v3-migration","Nuxt Content 2 から Nuxt Content 3 への移行に関するガイド",1778491807267]