WPFのPopup表示でListViewやDataGridスクロールが遅くなる原因と対策

WPF で大量データを表示している ListView や DataGrid が、Popup を一度でも開いた途端にカクカクし始める――そんな現象は単なる「重い画面」ではなく、WPF と UI Automation の仕様が絡んだ既知の問題です。本記事では原因の正体と、実務レベルで取り得る回避策を整理します。

目次

WPF の Popup を開くとスクロールが重くなる現象

まずは問題の症状を整理します。典型的には次のような状況です。

  • ListView / DataGrid に数十万〜数百万件のデータをバインドしている。
  • 仮想化(VirtualizingStackPanel)を有効にしていれば、通常のスクロールはかなり滑らか。
  • しかし、Popup を一度表示した瞬間からスクロールや描画が明らかに重くなる。
  • 同じ UI を Window(別ウィンドウ)で表示した場合は遅くならない。

この現象は、Microsoft Q&A や GitHub の WPF リポジトリでも具体的な再現コード付きで報告されています。例えば、ListView に 200 万件をバインドし、Button から Popup と Window をそれぞれ開くサンプルでは「Popup を開いた後だけスクロールが重くなる」ことが確認されています。

つまり、単なる「データ量が多すぎるから遅い」のではなく、Popup を開く動作がトリガーになって描画パイプライン全体の負荷が跳ね上がっているというのがポイントです。

原因は UI Automation(アクセシビリティ)と AutomationPeer

GitHub で追跡されている WPF の既知問題

この種の問題は WPF チームの GitHub リポジトリで複数の Issue として報告されています。

  • #5807: Opening a Popup or ToolTip causes AutomationPeers to be created
  • #9881: When Popup (combobox, tooltip, menu item, etc) is shown within an app with a DataGrid, the DataGrid becomes slow and less responsive
  • #9181: ListView/GridView scroll performance is O(n) when automation peers are created

#5807 では、「最初の Popup / ToolTip を表示した後、UI Automation 用の AutomationPeer が大量に生成され、Automaton イベントが発火するようになり、特に DataGrid など複雑な画面でパフォーマンスが大きく劣化する」と報告されています。

さらに #9881 では、ComboBox(内部的には Popup)と DataGrid を組み合わせたアプリで、Popup を開いた直後から DataGrid のスクロールが著しく遅くなることが、プロファイラのスクリーンショット付きで示されています。問題は .NET 6 と .NET 8 で再現し、「既知のワークアラウンド無し」とされています。

Popup が UI Automation を強制的に有効にする

#5807 の説明によれば、問題の発端は Popup クラス内部にある ForceMsaaToUiaBridge というメソッドです。

  • 最初の Popup / ToolTip 表示時に、MSAA と UIA のブリッジ(Accessibility.dll)を強制的に初期化する。
  • それに伴い、アプリケーション内のコントロールに対して AutomationPeer の生成と UIA イベントの発火が始まる。
  • UI Automation クライアント(スクリーンリーダーなど)が接続していなくても行われる。

この「AutomationPeer の大量生成」と「Automation ツリーの走査」が、DataGrid や ListView のような巨大な ItemsControl に対しては極めて重い処理になります。

AutomationPeer と仮想化リストの相性の悪さ

大量アイテムを持つ ListView / DataGrid では、通常は仮想化によって、画面に見えている範囲のコンテナ(行・セル)だけが生成されます。

ところが、UI Automation のために AutomationPeer を構築する際には、

  • ItemsControl 全体を対象にインデックスを解決しようとしたり、
  • アイテム毎に Items.IndexOf のような線形検索を行ったり、
  • 視覚ツリーの子要素を再帰的に列挙したり、

といった処理が発生し、根本的に O(n) 以上のコストがかかります。#9181 では、GridViewItemAutomationPeer の GetChildrenCore が毎回 Items.IndexOf を行っており、アイテム数が増えるとスクロール性能が O(n) になってしまうことが指摘されています。

その結果、

  • Popup を一度でも表示する
  • → UI Automation が有効化される
  • → Automation ツリー構築のために ListView / DataGrid に対する走査が始まる
  • → レイアウト時間やフレーム生成時間が急増し、スクロールがカクつく

という流れになります。

なぜ別 Window では遅くならないのか

同じ XAML を別 Window(new MainWindow().Show() など)で表示した場合には、Popup を開かない限り UI Automation の強制初期化が行われないため、通常の仮想化リストとして動作し続けます。Microsoft Q&A のサンプルでも、Popup ボタンより Window ボタンの方はスクロールが滑らかなままであることが確認されています。

つまり、見た目は似ていても、Popup / ToolTip / ComboBox ドロップダウン / ContextMenu など「内部で Popup を使う UI」だけが、UIA の引き金を引いてしまうという構造になっているわけです。

実務で効く回避策の全体像

根本原因は WPF 自体の実装にありますが、アプリ側で取れるワークアラウンドはいくつか存在します。本記事では、現場で採用しやすい順に整理します。

対策効果難易度アクセシビリティへの影響
メインウィンドウの AutomationPeer を軽量化◎ 大きく改善低大(UIA ほぼ無効化)
Popup を使わず、Window で疑似ポップアップ◎〜○中小(Window 単位)
ListView / DataGrid 側の最適化○ 補助的効果中なし
条件付きで AutomationPeer を軽量化◎(設計次第)高中(制御可能)

メインウィンドウの AutomationPeer を軽量化する

OnCreateAutomationPeer のオーバーライド

Microsoft Q&A の回答でも紹介されているのが、メインウィンドウの AutomationPeer を差し替えて、UIA ツリーの子要素列挙を止めるという方法です。

コード例(C# 9 以降の簡潔な記法を少し書き換え、一般的な形にしています):

// MainWindow.cs
using System.Collections.Generic;
using System.Windows;
using System.Windows.Automation.Peers;

public partial class MainWindow : Window
{
    protected override AutomationPeer OnCreateAutomationPeer()
    {
        return new CustomWindowAutomationPeer(this);
    }

    private sealed class CustomWindowAutomationPeer : FrameworkElementAutomationPeer
    {
        public CustomWindowAutomationPeer(FrameworkElement owner)
            : base(owner)
        {
        }

        protected override string GetNameCore()
            => "CustomWindowAutomationPeer";

        protected override AutomationControlType GetAutomationControlTypeCore()
            => AutomationControlType.Window;

        // ここで空リストを返すことで
        // 子要素の AutomationPeer 列挙を抑止する
        protected override List<AutomationPeer> GetChildrenCore()
            => new List<AutomationPeer>();
    }
}

ポイントは GetChildrenCore を空リストで返すことで、Window 配下の膨大なコントロールを UI Automation に見せないようにしている点です。これにより、ListView / DataGrid など巨大な ItemsControl に対する AutomationPeer の生成や走査がほぼ発生しなくなり、Popup を開いてもスクロール性能が維持されます。

メリット・デメリット

項目内容
メリット実装が数十行で済み、既存画面の XAML をほぼ変更せずに導入できる。 Popup / ToolTip / ComboBox / ContextMenu など、Popup 派生の UI をまとめて軽くできる。 再現コードのレベルでも顕著にスクロールの滑らかさが改善することが確認されている。
デメリットスクリーンリーダーなどのアクセシビリティツールからは、Window 内のコンテンツがほとんど見えなくなる。 アクセシビリティ要件(WCAG 準拠など)があるプロダクトでは、そのまま適用できない可能性が高い。

アクセシビリティ要件がゆるい社内ツールや運用ツールであれば、「問題の出る画面だけ、この AutomationPeer を使う」という割り切りは実務的にかなり現実的です。

Popup をやめて、Window で「疑似ポップアップ」を作る

基本方針

そもそも Popup を使わなければ、今回の UIA 問題は発火しません。リストが重くなるのは Popup 表示がトリガーだからです。

そこで、以下のような「クロスフェード」的な設計が考えられます。

  • 本来 Popup で表示したかったコンテンツを、別の Window に移す。
  • Window の WindowStyle=None、ShowInTaskbar=False、ResizeMode=NoResize などで、装飾のない疑似ポップアップにする。
  • 必要なら Topmost=True や AllowsTransparency=True で「ドロップダウン風」「ダイアログ風」の見た目を演出する。

簡単な例:

<Window x:Class="SampleApp.PopupWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        WindowStyle="None"
        ResizeMode="NoResize"
        ShowInTaskbar="False"
        Topmost="True"
        SizeToContent="WidthAndHeight">
    <Border BorderBrush="Gray" BorderThickness="1" Padding="8" Background="White">
        <StackPanel>
            <TextBlock Text="疑似ポップアップ" Margin="0 0 0 8"/>
            <Button Content="閉じる" Click="Close_Click"/>
        </StackPanel>
    </Border>
</Window>

親コントロールの位置に合わせて表示する

Popup の代替として自然な UX にするには、親コントロールの位置にあわせて Window を動かす必要があります。

// 呼び出し元(メインウィンドウなど)
private void ShowPseudoPopup(Button anchor)
{
    var popupWindow = new PopupWindow
    {
        Owner = this      // オーナーを設定して Z オーダーとアクティブ制御を親と同期
    };

    // anchor の画面座標を取得
    var point = anchor.PointToScreen(new Point(0, anchor.ActualHeight));

    popupWindow.WindowStartupLocation = WindowStartupLocation.Manual;
    popupWindow.Left = point.X;
    popupWindow.Top  = point.Y;

    popupWindow.Show();
}

Popup のように「親と同じウィンドウの一部として扱われる」わけではありませんが、

  • タスクバーにアイコンが出ない
  • 親ウィンドウの前面に常に表示される(Owner 設定)
  • 位置を工夫すればドロップダウン風の UI になる

といった点で、実用上はほぼ同じ挙動を実現できます。

フォーカスとクローズの制御

Popup のように「フォーカスが外れたら閉じる」挙動を模倣する場合は、Window 側で Deactivated イベントなどをハンドルします。

// PopupWindow.xaml.cs
public partial class PopupWindow : Window
{
    public PopupWindow()
    {
        InitializeComponent();
        Deactivated += (_, __) => Close();
    }

    private void Close_Click(object sender, RoutedEventArgs e)
        => Close();
}

この方式は、アクセシビリティ情報は Window 単位で引き続き提供されるため、AutomationPeer を完全に切る方法よりは UIA への影響が小さい点もメリットです。

ListView / DataGrid 側の最適化(補助策)

根本原因は UI Automation ですが、画面全体の負荷を下げることで、Popup 後のカクつきを多少緩和することができます。Microsoft の公式ドキュメントでも、大量データ表示の基本テクニックが整理されています。

仮想化設定を明示する

ListView / DataGrid で仮想化が効いているかどうかを再確認しましょう。次のような条件で簡単に無効化されることがあります。

  • ScrollViewer.CanContentScroll="False" を設定している。
  • ItemsPanel を StackPanel に変更しているが、VirtualizingStackPanel にしていない。
  • アイテムコンテナ(ListViewItem など)をコードビハインドで直接追加している。

基本形の例:

<ListView ItemsSource="{Binding Items}"
          ScrollViewer.CanContentScroll="True">
    <ListView.ItemsPanel>
        <ItemsPanelTemplate>
            <VirtualizingStackPanel
                IsVirtualizing="True"
                VirtualizationMode="Recycling" />
        </ItemsPanelTemplate>
    </ListView.ItemsPanel>
</ListView>

DataGrid でも同様に、VirtualizingStackPanel を利用し、行高・列幅を固定することでレイアウト計算の負荷を下げられます。

テンプレートの簡素化

DataTemplate や CellTemplate に複雑なコントロールを入れすぎると、UI Automation の走査対象も増えます。

  • 画像の縮小・トリミングなどを、できるだけ事前に行う。
  • 不要な Border / Grid ネストを削る。
  • クリックやキー入力が不要なら IsHitTestVisible="False" でヒットテストを無効化する(若干の最適化)。

データのページング・遅延読み込み

UI Virtualization は「見えていない行の UI を作らない」だけで、データ自体は全件メモリに載っているケースがほとんどです。

数百万件を常にコレクションに持っておくと、UIA の走査時にもその規模が効いてしまうため、

  • ユーザー操作に応じたページング(1 ページ数千件程度に抑える)
  • サーバーからの遅延読み込み・データバーチャライゼーション

といったアプローチも検討の価値があります。

条件付きで AutomationPeer を軽量化する(高度な妥協案)

「常に UIA を無効化するのは困るが、Popup を開いている間だけは軽くしたい」という要件もありえます。この場合、若干トリッキーですが、以下のようなやり方も考えられます。

Popup の状態によって GetChildrenCore の挙動を変える

OnCreateAutomationPeer はウィンドウ生成時に一度だけ呼ばれますが、GetChildrenCore は UI Automation のタイミングで繰り返し呼ばれます。ここで、アプリケーション側のフラグに応じて挙動を切り替えることができます。

public static class PopupState
{
    public static bool IsPopupOpen { get; set; }
}

// Popup を開く側
private void Popup_Opened(object sender, EventArgs e)
{
    PopupState.IsPopupOpen = true;
}

private void Popup_Closed(object sender, EventArgs e)
{
    PopupState.IsPopupOpen = false;
}

// AutomationPeer 側
private sealed class ConditionalWindowAutomationPeer : FrameworkElementAutomationPeer
{
    public ConditionalWindowAutomationPeer(FrameworkElement owner)
        : base(owner)
    {
    }

    protected override List<AutomationPeer> GetChildrenCore()
    {
        if (PopupState.IsPopupOpen)
        {
            // Popup が開いている間は空を返す
            return new List<AutomationPeer>();
        }

        // 通常時は base 実装に委譲(必要に応じてカスタマイズ)
        return base.GetChildrenCore() ?? new List<AutomationPeer>();
    }
}

注意点として、

  • UIA クライアントから見ると、「Popup を開くと Window の子要素が一時的に消える」ような挙動になる。
  • スクリーンリーダーの挙動に影響が出る可能性があるので、実際に使うツールで必ず検証する必要がある。

それでも、常時 UIA を切るよりは影響を限定できるため、「アクセシビリティもできるだけ維持したいが、パフォーマンスも妥協できない」という現場では検討に値するアプローチです。

再現とプロファイリングのポイント

簡単な再現コード(概要)

Microsoft Q&A で紹介されているシンプルな再現例の構成は次の通りです。

  • ListView(内部は GridView)に 200 万件程度のデータをバインド。
  • 上部に「Popup」ボタンと「Window」ボタン。
  • 「Popup」ボタンは Popup を開くだけ、「Window」ボタンは新しいウィンドウを開くだけ。

この構成で、

  1. 起動直後に ListView をスクロール → とても滑らか。
  2. 「Popup」ボタンを押して Popup を開く(中身は何でもよい)。
  3. Popup を閉じた後、再度 ListView をスクロール → 明らかにカクつく。
  4. アプリを再起動して「Window」ボタンだけを押した場合は、スクロールは滑らかなまま。

という挙動が観察できます。

プロファイラで見るべき指標

Visual Studio の「パフォーマンスプロファイラ」(.NET Object Allocation、CPU 使用率、Timeline など)や WPF Performance Suite を使うと、Popup を開いた直後に次のような変化が見られます。

  • Layout(Measure/Arrange)の時間が顕著に増える。
  • フレームレート(FPS)が落ちる。
  • Accessibility 関連のモジュール(Accessibility.dll など)が読み込まれる。

特にスクロール中に CPU 使用率が 1 コアぶん以上張り付くようであれば、AutomationPeer の走査や UIA イベントの発火がボトルネックになっている可能性が高いと考えられます。

Popup を使う他のコントロールへの影響

今回の問題は、純粋な Popup コントロールだけでなく、内部的に Popup を使うコントロールにも波及します。

  • ComboBox のドロップダウン
  • ToolTip
  • ContextMenu / MenuItem
  • 一部のカスタムコントロール(ポップアップ式の候補表示など)

つまり、「何かをホバーしただけで Tooltip が出る」「行右クリックで ContextMenu が出る」といった UI も、初回表示を境に DataGrid のスクロールを重くする可能性があります。

大量データグリッドを扱う画面では、

  • Tooltip を極力使わず、行内に情報を表示する。
  • 行右クリックメニューを別の操作(画面上部のコマンドバーなど)で代替する。
  • どうしても Tooltip / ContextMenu を使う場合は、その画面専用の AutomationPeer 軽量化を検討する。

といった設計レベルの見直しも有効です。

まとめ:WPF の Popup とどう付き合うか

最後に、本記事の要点を整理します。

  • Popup(およびそれを内部的に使う ComboBox、ToolTip、ContextMenu など)を初めて表示すると、WPF が UI Automation のブリッジを強制的に有効化し、AutomationPeer の大量生成とツリー走査が始まる。
  • この処理は、特に大量データを表示する DataGrid / ListView / GridView などの仮想化リストと非常に相性が悪く、スクロールや描画が急激に重くなる。
  • メインウィンドウの OnCreateAutomationPeer をオーバーライドして GetChildrenCore を空にするカスタム AutomationPeer を返すことで、UIA 走査コストを大きく削減できる。
  • アクセシビリティ要件が強い場合は、Popup 自体をやめて装飾なし Window に置き換える「疑似ポップアップ」方式が現実的な落としどころになる。
  • あわせて、仮想化設定やテンプレートの簡素化、ページング・データバーチャライゼーションなど、ListView / DataGrid 側のパフォーマンスチューニングも行うと全体の UX を底上げできる。
  • より高度な妥協案として、Popup が開いている間だけ AutomationPeer を軽量化する、といった条件付き制御も設計可能だが、UIA クライアントとの相性検証が必須。

WPF 自体の修正が入るまでは、アプリケーション側での工夫がどうしても必要になります。ただし、本記事で紹介したテクニックを組み合わせれば、「Popup を使った瞬間に DataGrid が使い物にならなくなる」といった状況はかなりの程度まで緩和できます。

大量データを扱う業務アプリで WPF を使う場合は、「Popup を開くと UIA が動き出す」という前提を頭の片隅に置きつつ、設計段階からパフォーマンスとアクセシビリティのバランスを意識していくことが重要です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次