概要
WPF の ScrollViewer は、内部の要素がビューポートより大きいときにスクロールバーを表示し、スクロールを可能にするコントロールである。
しかし StackPanel の中に ScrollViewer を置くと、内容がどれだけ増えてもスクロールバーが現れず、要素が下方向に伸び続ける問題が起きる。
本記事では、この現象がレイアウトの測定処理に起因することを説明し、コンテナの選び方による解決方法と選択基準を整理する。
前提・対象環境
- フレームワーク: .NET 6 以降 / WPF
- 言語: C# / XAML
- 対象コントロール:
ScrollViewer、StackPanel、Grid、DockPanel - アーキテクチャ: MVVM・コードビハインドのいずれにも適用可能
- 検証環境: .NET 10 / Windows 11
本記事の表は、上記の環境で実際に計測したものである。
計測の条件は、それぞれの表の説明に書いた。
この環境で確認しているのは次の点である。
- 高さ 200 の
StackPanelの中では、ScrollableHeightが 0 のままでスクロールバーが出ない。 - 同じ高さの
GridとDockPanel(LastChildFill)ではScrollViewerに有限の高さが渡り、スクロールできる。コンテナーの種類ではなく、有限の高さが渡るかどうかで決まる。 StackPanelの中でも、ScrollViewer自体にHeightを設定すればスクロールできる。
問題
縦方向の StackPanel の中に ScrollViewer を配置し、その中へ多数の要素を並べても、スクロールバーが表示されずに画面外まで要素が伸びてしまうことがある。
<StackPanel>
<TextBlock Text="ヘッダー" />
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- 大量の項目 -->
</StackPanel>
</ScrollViewer>
</StackPanel>
VerticalScrollBarVisibility="Auto" を指定しているにもかかわらず、ScrollViewer は内容全体の高さまで広がり、スクロールバーは現れない。
ScrollViewer を StackPanel の中に置いた場合で、スクロールバーが出ず領域の下端で内容が切れている。右は Grid の * 行に置いた場合で、スクロールバーが現れて残りの項目まで到達できる。原因・背景
原因は、StackPanel が子要素を測定する際に渡す利用可能サイズにある。
StackPanel は積み重ねる方向(縦方向の場合は高さ)について、子要素へ無限の利用可能サイズを渡して測定する。
公式ドキュメントでも、StackPanel はスタックする方向で子要素を制約しないことが示されている。
ScrollViewer は、与えられた高さより内容が大きいときにだけスクロールバーを表示する。
ところが StackPanel から無限の高さを渡されると、ScrollViewer は内容全体が収まる高さを要求し、オーバーフローが発生しない。
このため、スクロールバーは表示されず、ScrollViewer 自体が内容と同じ高さまで伸びてしまう。
問題の本質は ScrollViewer 側ではなく、それを囲む StackPanel が高さを制約していない点にある。
この違いは、親のレイアウトだけを変えて ScrollViewer の高さを読めば確かめられる。
| 親のレイアウト | Extent | Viewport | Scrollable | スクロールバー |
|---|---|---|---|---|
| StackPanel の中 | 800 | 800 | 0 | Collapsed |
| Grid の中 | 800 | 200 | 600 | Visible |
| DockPanel の中(LastChildFill) | 800 | 200 | 600 | Visible |
| StackPanel + Height を明示 | 800 | 200 | 600 | Visible |
| StackPanel + MaxHeight | 800 | 200 | 600 | Visible |
.NET 10 / Windows 11 で、高さ 20px の行を 40 個(合計 800px)持つ ScrollViewer を、高さ 200px に制約した親の中へ置いて測った結果。親のレイアウト以外の条件は同一である。
StackPanel の中では ViewportHeight が ExtentHeight と同じ 800 になっている。 200px に制約されたはずの親の中にいながら、内容全体が収まる高さを確保している。
その結果 ScrollableHeight は 0 となり、スクロールバーは Collapsed のまま現れない。
Grid と DockPanel では ViewportHeight が 200 に収まり、差の 600 が ScrollableHeight になる。
最終行のとおり、StackPanel のままでも ScrollViewer に高さを明示すれば同じ結果になる。制約が伝わりさえすればよい。
解決方法
ScrollViewer を、高さが制約されるコンテナに配置する。
具体的には、Grid の *(Star)指定の行に置くか、DockPanel で残り領域に収めるか、明示的な Height・MaxHeight を与える。
これにより ScrollViewer へ有限の高さが渡され、内容がその高さを超えたときにスクロールバーが表示される。
実装例
Grid の Star 行に配置する
Grid の行を「固定サイズの行」と「残り領域を埋める * 行」に分け、スクロールさせたい ScrollViewer を * 行へ置く。
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="ヘッダー" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- 大量の項目 -->
</StackPanel>
</ScrollViewer>
</Grid>
* 行は親の残り高さを受け取るため、ScrollViewer には有限の高さが渡される。
内容がその高さを超えると、スクロールバーが自動的に表示される。
DockPanel で残り領域に収める
DockPanel は最後の子要素を残り領域へ広げる LastChildFill(既定で有効)を持つ。
ヘッダーを上端にドッキングし、ScrollViewer を最後の子として残り領域に収める。
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="ヘッダー" />
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- 大量の項目 -->
</StackPanel>
</ScrollViewer>
</DockPanel>
DockPanel.Dock を指定しない最後の子は残り領域に収まるため、ScrollViewer の高さが制約される。
注意点
VerticalScrollBarVisibility="Disabled"はスクロール自体を無効化する:Disabledを指定すると、その方向のスクロールがユーザー操作で行えなくなる。
スクロールバーを隠しつつスクロールは残したい場合はHiddenを使う。- 内側にスクロール対応コントロールを二重に置かない:
ScrollViewerの中へ独自のスクロールを持つListBoxなどをそのまま入れると、マウスホイールの操作が競合し、意図した側がスクロールしないことがある。
ListBoxはそれ自体がスクロールするため、外側のScrollViewerで囲むよりも、高さが制約されるレイアウト(例:Gridの*行)に置くほうがよい。 - 物理スクロールと論理スクロールの違い:
ScrollViewer.CanContentScrollの既定値はfalseで、ピクセル単位の物理スクロールとなる。
trueのときは項目単位の論理スクロールとなる。
ただしListBoxの既定スタイルがtrueを設定するため(値の出どころはDefaultStyle)、データバインドしたListBoxは論理スクロールで動作し、VirtualizingStackPanelが項目を仮想化する。
仮想化が失われるのはCanContentScrollをfalseにした場合だけではない。2,000 件のListBoxで実体化されたListBoxItemを数えると、VirtualizingPanel.IsVirtualizingをfalseにした場合と、ListBoxを外側のScrollViewerやStackPanelの中に置いた場合も、2,000 個すべてが実体化された。外側がスクロールを引き受けたり高さを制約しなかったりすると、ListBoxにはすべての項目を並べる高さが渡るためである。CollectionViewにGroupDescriptionsを付けた場合は、GroupStyleを付けなければ仮想化されたままだった。GroupStyleを付けると、2,000 個すべてが実体化され、CanContentScrollもテンプレートのトリガーでfalseになった。
| 構成 | 実体化した ListBoxItem | CanContentScroll(値の出どころ) |
|---|---|---|
| ListBox Height=180 | 10 | True (DefaultStyle) |
| ScrollViewer.CanContentScroll=False | 2,000 | False (Local) |
| VirtualizingPanel.IsVirtualizing=False | 2,000 | True (DefaultStyle) |
| CollectionView.GroupDescriptions だけ(GroupStyle なし) | 10 | True (DefaultStyle) |
| CollectionView.GroupDescriptions + GroupStyle | 2,000 | False (TemplateTrigger) |
| 外側の ScrollViewer の中(Height=180) | 2,000 | True (DefaultStyle) |
| StackPanel の中(Height=180) | 2,000 | True (DefaultStyle) |
.NET 10 / Windows 11 で、2,000 件を持つ ListBox を条件ごとに表示し、実体化された ListBoxItem を数えた結果。外側の ScrollViewer と StackPanel には高さ 180 を与え、ListBox 自体には高さを与えていない。
代替案・比較
| コンテナ | スクロールの挙動 | 適するケース |
|---|---|---|
StackPanel(縦) |
高さを制約しないため、明示的な Height・MaxHeight を与えない限り ScrollViewer はスクロールしない |
内容が短く、スクロールが不要な単純な積み重ね |
Grid の * 行 |
残り高さが渡り、内容超過時にスクロールする | ヘッダー・フッターと可変領域を明確に分けたい画面 |
DockPanel(LastChildFill) |
残り領域に収まり、内容超過時にスクロールする | 端固定要素と本体領域を組み合わせる画面 |
明示的な Height・MaxHeight |
指定した高さを超えるとスクロールする | 高さの上限を固定したい部分的なリスト |
まとめ
StackPanel はスタック方向で子要素に無限の高さを渡すため、内部の ScrollViewer はオーバーフローを検知できずスクロールしない。
問題の本質は ScrollViewer ではなく、それを囲むコンテナが高さを制約していない点にある。
選択の基準は次のとおりである。
- ヘッダーやフッターと可変領域を分けたい場合:
Gridの*行にScrollViewerを置く。
役割ごとに行を分けられ、レイアウトの意図が明確になる。 - 端に固定する要素と本体領域を組み合わせる場合:
DockPanelを使い、ScrollViewerを最後の子として残り領域に収める。 - 部分的なリストの高さ上限だけを決めたい場合:
MaxHeightを指定し、その高さを超えたときにスクロールさせる。
いずれの場合も、ScrollViewer へ有限の高さが渡る構成にすることが解決の要点である。
- WPF ListBox 仮想化環境での SelectedItems が消えたように見える問題とその解決法
- ScrollViewer(WPF 標準コントロールデモアプリ):4 つのスクロールバーの設定、
CanContentScroll、大きさが決まる時点を実測したページ