WPFのDataGridで大量データを扱っていると、ユーザーからほぼ必ず「ブラウザみたいに Ctrl+F と F3 で検索したい」と言われます。この記事では、コードビハインドにべったり依存せず、MVVM+ICollectionView+Attached Behavior で、DataGridにブラウザ風のインクリメンタル検索&F3移動を実装する手順を具体的なコード付きで解説します。
WPF DataGridでブラウザ風「Ctrl+F/F3」検索を実現する全体像
まずゴールを整理します。目指す挙動は、一般的なブラウザの検索バーとほぼ同じです。
| 操作 | 挙動 |
|---|---|
| Ctrl + F | DataGridヘッダー(または上部)の検索TextBoxにフォーカスし、文字列入力を開始できる |
| 入力中 | 入力するたびに検索語が更新され、一致する行のリストを再計算する |
| F3 | 次の一致へ移動(該当が末尾なら先頭へループ) |
| Shift + F3 | 前の一致へ移動(先頭なら末尾へループ) |
| 該当なし | 検索TextBoxを赤枠にしてユーザーに「0件」を明示 |
これを MVVM で実現するにあたり、責務を以下のように分けます。
| レイヤー | 役割 |
|---|---|
| Model | 従業員など、1行分のデータ(プロパティ+INotifyPropertyChanged) |
| ViewModel | ObservableCollectionとICollectionView、SearchText、Matches、Next/Prevロジック、件数・現在位置の管理 |
| View (XAML) | DataGrid本体、検索TextBox、赤枠スタイル、件数表示、IsSynchronizedWithCurrentItem |
| Attached Behavior | Ctrl+F / F3 / Shift+F3 のキー入力を捕捉し、ViewModelのメソッドや検索TextBoxフォーカスへ橋渡し。ScrollIntoViewを呼ぶ |
ポイントは以下の2つです。
- 検索ロジックと件数カウントはすべてViewModel側(データ側)で行う
- ビュー側は「選択の移動」と「スクロール」と「見た目のハイライト」に専念する
これにより、DataGridの仮想化とMVVMの両方を崩さずに、軽快な検索UIを実装できます。
データ側実装:ICollectionViewと検索結果リスト
まずは ViewModel の設計からです。ここでは従業員リストを例にします。
モデルクラスの例
public class Employee : INotifyPropertyChanged
{
private int _id;
private string _name;
private string _title;
private bool _isMatch;
public int Id
{
get => _id;
set { _id = value; OnPropertyChanged(nameof(Id)); }
}
public string Name
{
get => _name;
set { _name = value; OnPropertyChanged(nameof(Name)); }
}
public string Title
{
get => _title;
set { _title = value; OnPropertyChanged(nameof(Title)); }
}
/// <summary>
/// 行単位ハイライト用。「検索にヒットしているかどうか」。
/// </summary>
public bool IsMatch
{
get => _isMatch;
set { _isMatch = value; OnPropertyChanged(nameof(IsMatch)); }
}
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
IsMatch プロパティは行ハイライト用のオプションですが、入れておくと後で便利です(なくても基本検索は動きます)。
ICollectionViewと検索プロパティ
一覧データは ObservableCollection<Employee> をベースにし、ビュー側の選択・移動を楽にするために ICollectionView にラップします。
public class EmployeeListViewModel : INotifyPropertyChanged
{
private readonly ObservableCollection<Employee> _source;
private readonly ICollectionView _view;
private string _searchText;
private List<Employee> _matches = new List<Employee>();
private int _currentIndex;
public EmployeeListViewModel()
{
_source = new ObservableCollection<Employee>();
_view = CollectionViewSource.GetDefaultView(_source);
_view.CurrentChanged += (s, e) => OnPropertyChanged(nameof(CurrentItem));
// サンプルデータ
_source.Add(new Employee { Id = 1, Name = "Tanaka", Title = "Manager" });
_source.Add(new Employee { Id = 2, Name = "Suzuki", Title = "Developer" });
_source.Add(new Employee { Id = 3, Name = "Yamada", Title = "Sales" });
}
public ICollectionView View => _view;
/// <summary>
/// 検索文字列。TextBoxから双方向バインドする。
/// </summary>
public string SearchText
{
get => _searchText;
set
{
if (_searchText == value) return;
_searchText = value;
OnPropertyChanged(nameof(SearchText));
UpdateMatches(); // 入力のたびに一致リストを更新
}
}
public int CurrentIndex
{
get => _currentIndex;
private set
{
if (_currentIndex == value) return;
_currentIndex = value;
OnPropertyChanged(nameof(CurrentIndex));
OnPropertyChanged(nameof(CurrentPositionText));
}
}
/// <summary>
/// 検索結果件数。0なら該当なし。
/// </summary>
public int TotalMatches => _matches?.Count ?? 0;
/// <summary>
/// 「3 / 12」のようなUI用表示文字列。
/// </summary>
public string CurrentPositionText
=> TotalMatches == 0
? "0 / 0"
: $"{CurrentIndex + 1} / {TotalMatches}";
/// <summary>
/// Viewから見た現在の選択アイテム。
/// </summary>
public Employee CurrentItem => _view.CurrentItem as Employee;
/// <summary>
/// ViewからScrollIntoViewを呼んでもらうためのイベント。
/// </summary>
public event Action<object> RequestScrollIntoView;
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
検索ロジック:MatchesリストとMoveCurrentTo
SearchText が変更されたら、 LINQ で一致行のリストを作ります。
private void UpdateMatches()
{
var text = SearchText ?? string.Empty;
// 全件を一旦クリア
foreach (var e in _source)
{
e.IsMatch = false;
}
if (string.IsNullOrWhiteSpace(text))
{
_matches = new List<Employee>();
CurrentIndex = 0;
OnPropertyChanged(nameof(TotalMatches));
return;
}
// 大小無視+全角半角を吸収したい場合はNormalizeなどを挟む
string normalized = text.Trim();
_matches = _source
.Where(e => ContainsIgnoreCase(e, normalized))
.ToList();
// 行単位ハイライト用フラグを立てる
foreach (var e in _matches)
{
e.IsMatch = true;
}
CurrentIndex = 0;
GoTo(CurrentIndex);
OnPropertyChanged(nameof(TotalMatches));
OnPropertyChanged(nameof(CurrentPositionText));
}
/// <summary>
/// 複数列検索の例。Id/Name/Titleのどれかに含まれていたらヒットとみなす。
/// </summary>
private static bool ContainsIgnoreCase(Employee e, string text)
{
if (string.IsNullOrEmpty(text)) return false;
var candidates = new[]
{
e.Id.ToString(),
e.Name,
e.Title
};
return candidates.Any(v =>
!string.IsNullOrEmpty(v) &&
v.IndexOf(text, StringComparison.OrdinalIgnoreCase) >= 0);
}
現在位置の移動と dataGrid.ScrollIntoView(…) をお願いする部分です。
public void Next()
{
if (TotalMatches == 0) return;
CurrentIndex = (CurrentIndex + 1) % TotalMatches;
GoTo(CurrentIndex);
}
public void Prev()
{
if (TotalMatches == 0) return;
CurrentIndex = (CurrentIndex - 1 + TotalMatches) % TotalMatches;
GoTo(CurrentIndex);
}
private void GoTo(int index)
{
if (TotalMatches == 0) return;
if (index < 0 || index >= TotalMatches) return;
var target = _matches[index];
// ICollectionViewのカレントを移動
_view.MoveCurrentTo(target);
// Viewに行スクロールしてもらう
RequestScrollIntoView?.Invoke(target);
OnPropertyChanged(nameof(CurrentItem));
OnPropertyChanged(nameof(CurrentPositionText));
}
ここまでで、ViewModel側の中核である「検索語の変更 → Matches再計算 → CurrentIndexの移動 → MoveCurrentTo+ScrollIntoView要求」が揃いました。
入力のデバウンスを入れる場合
文字を一文字打つたびに LINQ を回すのが重い場合、SearchText の setter で直接 UpdateMatches を呼ばず、DispatcherTimer でデバウンスするのが実務的です。
private readonly DispatcherTimer _searchTimer;
public EmployeeListViewModel()
{
_source = new ObservableCollection<Employee>();
_view = CollectionViewSource.GetDefaultView(_source);
_searchTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(200)
};
_searchTimer.Tick += (s, e) =>
{
_searchTimer.Stop();
UpdateMatches();
};
}
public string SearchText
{
get => _searchText;
set
{
if (_searchText == value) return;
_searchText = value;
OnPropertyChanged(nameof(SearchText));
// タイピングから200ms経過したら検索実行
_searchTimer.Stop();
_searchTimer.Start();
}
}
これで長い文字列をタイプしても無駄な再計算が減り、体感速度がかなり改善されます。
ビュー側実装:DataGridと検索TextBox
次に XAML 側です。ここでは「DataGridのすぐ上に検索ボックスを置く」パターンで説明します。ヘッダーに埋め込む場合も、基本的な Binding の考え方は同じです。
画面レイアウトの例
<Window x:Class="SampleApp.Views.EmployeeListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:SampleApp"
mc:Ignorable="d"
Title="Employee List" Height="450" Width="800">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="検索" VerticalAlignment="Center" Margin="0,0,4,0" />
<TextBox x:Name="SearchBox"
Width="200"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="BorderBrush" Value="Gray" />
<Setter Property="BorderThickness" Value="1" />
<Style.Triggers>
<DataTrigger Binding="{Binding TotalMatches}" Value="0">
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="BorderThickness" Value="2" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<TextBlock Margin="8,0,0,0"
VerticalAlignment="Center"
Text="{Binding CurrentPositionText}" />
</StackPanel>
<DataGrid x:Name="EmployeesGrid"
Grid.Row="1"
ItemsSource="{Binding View}"
AutoGenerateColumns="False"
IsReadOnly="True"
IsSynchronizedWithCurrentItem="True">
<DataGrid.Columns>
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="80" />
<DataGridTextColumn Header="氏名" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="役職" Binding="{Binding Title}" Width="200" />
</DataGrid.Columns>
<i:Interaction.Behaviors>
<local:DataGridFindBehavior
SearchBox="{Binding ElementName=SearchBox}" />
</i:Interaction.Behaviors>
</DataGrid>
</Grid>
</Window>
ポイントは以下の通りです。
- TextBox の Style で
TotalMatches == 0のときだけ赤枠にする CurrentPositionTextをすぐ右に置き、「3 / 12」のような表示にする- DataGrid の
IsSynchronizedWithCurrentItem="True"により、ICollectionView の CurrentItem と選択行を同期する - Attached Behavior に TextBox を渡すため、Behavior の
SearchBoxプロパティにElementNameでバインドする
行ハイライト用スタイル(オプション)
検索にヒットした行全体をうっすら色付けしたい場合は、RowStyle を使います。先ほどの Employee.IsMatch を利用します。
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Style.Triggers>
<DataTrigger Binding="{Binding IsMatch}" Value="True">
<Setter Property="FontWeight" Value="Bold" />
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
最初は「検索結果のうち1件だけ(現在位置)を強調」、慣れてきたら「ヒットした行を全部薄い色に」「現在位置だけ濃い色に」といった形で段階的に強化すると実装しやすいです。
Attached BehaviorでCtrl+F/F3を処理する
MVVMを崩さずにキーボードショートカットを扱うには、コードビハインドより Attached Behavior を使う方が責務を分離しやすくなります。
Behaviorクラスの実装
public class DataGridFindBehavior : Behavior<DataGrid>
{
public static readonly DependencyProperty SearchBoxProperty =
DependencyProperty.Register(
nameof(SearchBox),
typeof(TextBox),
typeof(DataGridFindBehavior),
new PropertyMetadata(null));
/// <summary>
/// Ctrl+Fでフォーカスを当てる検索ボックス。
/// </summary>
public TextBox SearchBox
{
get => (TextBox)GetValue(SearchBoxProperty);
set => SetValue(SearchBoxProperty, value);
}
private EmployeeListViewModel ViewModel =>
AssociatedObject?.DataContext as EmployeeListViewModel;
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Loaded += OnLoaded;
AssociatedObject.PreviewKeyDown += OnPreviewKeyDown;
if (ViewModel != null)
{
ViewModel.RequestScrollIntoView += OnRequestScrollIntoView;
}
}
protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.Loaded -= OnLoaded;
AssociatedObject.PreviewKeyDown -= OnPreviewKeyDown;
if (ViewModel != null)
{
ViewModel.RequestScrollIntoView -= OnRequestScrollIntoView;
}
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
// 画面表示直後にDataGridにフォーカスを当てておくと、
// 「セルをクリックしていないとCtrl+Fが効かない」問題を避けやすい
AssociatedObject.Focus();
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
// Ctrl+F で検索ボックスにフォーカス
if (Keyboard.Modifiers == ModifierKeys.Control && e.Key == Key.F)
{
FocusSearchBox();
e.Handled = true;
return;
}
// F3 / Shift+F3 で前後の一致に移動
if (e.Key == Key.F3)
{
if (ViewModel == null) return;
if (Keyboard.Modifiers == ModifierKeys.Shift)
{
ViewModel.Prev();
}
else
{
ViewModel.Next();
}
e.Handled = true;
}
}
private void FocusSearchBox()
{
if (SearchBox == null) return;
SearchBox.Focus();
SearchBox.SelectAll();
}
private void OnRequestScrollIntoView(object item)
{
if (item == null) return;
AssociatedObject.ScrollIntoView(item);
}
}
Behavior のポイントは次の通りです。
Behavior<DataGrid>を継承し、DataGridにだけ貼り付けられるようにする- SearchBox を DependencyProperty にして、XAMLから ElementName で渡せるようにする
- PreviewKeyDown で Ctrl+F / F3 / Shift+F3 を捕捉し、ViewModel のメソッドを呼び出す
- ViewModel の
RequestScrollIntoViewイベントを購読し、DataGrid.ScrollIntoView を呼ぶ - OnDetaching でイベントをきちんと解除し、メモリリークを防ぐ
XAMLでBehaviorをアタッチする
先ほどの XAML にすでに書いていますが、BehaviorをDataGridに付ける部分だけ抜き出します。
<DataGrid x:Name="EmployeesGrid"
Grid.Row="1"
ItemsSource="{Binding View}"
IsSynchronizedWithCurrentItem="True">
<i:Interaction.Behaviors>
<local:DataGridFindBehavior
SearchBox="{Binding ElementName=SearchBox}" />
</i:Interaction.Behaviors>
</DataGrid>
nuget で Microsoft.Xaml.Behaviors.Wpf を参照に追加し、名前空間 xmlns:i="http://schemas.microsoft.com/xaml/behaviors" を宣言しておくのを忘れないようにしてください(古い System.Windows.Interactivity は非推奨です)。
「セルをクリックしていないとCtrl+Fを拾えない」問題への対処
よくあるハマりポイントが、「画面を開いた直後、どこにもフォーカスが無くて Ctrl+F を押しても何も起きない」というものです。対処方法はいくつかあります。
DataGridに初期フォーカスを与える
Behaviorの OnLoaded で AssociatedObject.Focus() を呼ぶ方法はすでに紹介しました。XAMLだけで完結させたい場合は、Window側で FocusManager を使う手もあります。
<Window x:Class="SampleApp.Views.EmployeeListView"
...
FocusManager.FocusedElement="{Binding ElementName=EmployeesGrid}">
こうしておけば、画面表示直後から DataGrid にキーボードフォーカスがあり、Ctrl+F を押したときに Behavior の PreviewKeyDown が呼ばれるようになります。
WindowレベルのInputBindingsで拾う方法
フォーカス位置に依存させたくない場合は、Window.InputBindings でグローバルにキーを拾う方法もあります。この場合はコマンドと組み合わせるのが自然です。
<Window.InputBindings>
<KeyBinding Key="F"
Modifiers="Control"
Command="{Binding FocusSearchCommand}" />
<KeyBinding Key="F3"
Command="{Binding FindNextCommand}" />
<KeyBinding Key="F3"
Modifiers="Shift"
Command="{Binding FindPrevCommand}" />
</Window.InputBindings>
ViewModel側に RelayCommand などでコマンドを用意し、Behavior の代わりにコードビハインド最小限で ScrollIntoView だけを行う、という構成も有力です。この記事では Behaviorパターンにフォーカスしましたが、既存プロジェクトの方針に合わせて選択してください。
件数カウント/スクロール移動/仮想化の落とし穴
DataGridは VirtualizingStackPanel を使った仮想化が有効になっていることが多く、可視領域外の行やセルはそもそもビジュアルツリーに存在しません。そのため、次のようなアプローチは避けるべきです。
- VisualTreeHelper を使って DataGridRow や TextBox を総なめし、文字列検索を行う
- 「全TextBoxを列挙して、そのTextプロパティから件数をカウントする」といったUI側ベースの検索
これをやると、スクロールするタイミングによってヒット件数が変わる・パフォーマンスが悪化するなど、トラブルの元になります。
この記事のようにデータ側(ObservableCollection)に対して件数を計算し、Matches リストを持ち回ると、仮想化の有無に関係なく安定して動作します。
仮想化とScrollIntoViewの関係
DataGrid.ScrollIntoView(item) は、仮想化が効いていても必要な行を自動的に実体化してくれます。つまり、
- ViewModel で MoveCurrentTo(target) を呼ぶ
- Behavior が ScrollIntoView(target) を呼ぶ
という流れさえ守れば、ビジュアルツリーを直接触る必要はありません。どうしても「現在位置のセルにフォーカスしたい」などの要件がある場合だけ、ItemContainerGenerator から DataGridRow を取得してフォーカスを動かすような処理を検討してください。
文字単位のハイライトをしたい場合
ブラウザのように「セル内の該当部分だけ黄色に塗る」表現をしたい場合は、少し工夫が必要です。
- ModelやViewModelに
IsMatchやMatchTextを持たせる - TextBlock の Inlines を生成する IMultiValueConverter を用意し、検索語とセルの内容から Run を分割・色付けする
- パフォーマンスに注意しながら、仮想化を前提に「必要なときだけコンバータで Inlines を構築」する
検索語や件数の管理自体はこの記事の実装そのまま利用し、見た目だけを高度化する、というイメージです。
実務で役立つ細かい工夫
複数列検索と検索オプション
先ほどの ContainsIgnoreCase を少し拡張すると、柔軟な検索ができます。
- 対象列を Config や列挙型で管理し、「氏名だけ」「氏名と役職」「全列」など切り替え
- 完全一致/部分一致の切り替え
- 前方一致(StartsWith)や正規表現を使った高度な検索
あまり機能を盛り込みすぎるとUIが複雑になるので、実務では「部分一致・大小無視・複数列検索」くらいまでに抑えておくと、ユーザーにとっても開発者にとってもバランスが良いです。
全角半角とローマ字/かなの扱い
日本語圏では、「全角・半角」や「ひらがな・カタカナ」の揺れが現実問題としてよく発生します。簡易的には String.Normalize(NormalizationForm.FormKC) を使って全角・半角をならすだけでも、ヒット率がかなり改善します。
private static string NormalizeForSearch(string text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
return text
.Normalize(NormalizationForm.FormKC); // 全角半角・一部の互換文字を統一
}
private static bool ContainsIgnoreCase(Employee e, string text)
{
var normalized = NormalizeForSearch(text);
var candidates = new[]
{
NormalizeForSearch(e.Id.ToString()),
NormalizeForSearch(e.Name),
NormalizeForSearch(e.Title),
};
return candidates.Any(v =>
!string.IsNullOrEmpty(v) &&
v.IndexOf(normalized, StringComparison.OrdinalIgnoreCase) >= 0);
}
アクセシビリティとショートカットのガイド
Ctrl+F / F3 自体は便利ですが、ユーザーに伝わらなければ宝の持ち腐れです。次のような工夫をしておくと親切です。
- 検索ボックスの近くに「Ctrl+Fで検索、F3で次を検索」のようなヒントテキストを小さく出す
- Alt+N などの AccessKey を付けておき、マウスが使えない環境でも操作しやすくする
- 「N件中K件目」を見れば現在位置が分かるようにしておく(すでに CurrentPositionText で実現)
まとめ:検索はデータで、ビューは見た目と操作に集中させる
この記事で紹介した構成をざっと振り返ります。
- 一覧データは ObservableCollection を ICollectionView にラップし、CurrentItem と選択行を同期させる
- 検索語 SearchText を ViewModel に持ち、変更時に LINQ で Matches リストを作る
- Next / Prev メソッドで CurrentIndex をループ移動させ、MoveCurrentTo + ScrollIntoView で該当行へジャンプする
- 該当件数 TotalMatches を公開し、0件のときは TextBox スタイルで赤枠にする
- Ctrl+F / F3 / Shift+F3 は Attached Behavior または Window.InputBindings で捕捉し、フォーカス移動と ViewModel 呼び出しを行う
- 仮想化された DataGrid ではビジュアルツリーを直接さわらず、あくまでデータ側で一致判定や件数管理を行う
ブラウザ風の検索UIは、一度パターン化してしまえば他の画面にも簡単に横展開できます。この記事の ViewModel と Behavior を少し一般化して、「検索可能な DataGrid 用の共通コンポーネント」としてライブラリ化しておくと、後々の開発効率がぐっと上がります。
MVVM と仮想化のルールさえ守れば、WPFのDataGridでも快適な Ctrl+F/F3 検索を実現できます。ぜひ自分のプロジェクト用にカスタマイズしてみてください。

コメント