はじめに
WPFでListView
のGridView
表示はメジャーなコントロールですが、かゆいところに手が届かない残念コントロールでもあります。俺はただエクスプローラーの詳細表示並みの機能が欲しいだけなのに………
そんなかゆいところをどうにか実装してGridView
のポテンシャルの高さを啓蒙するとともに、実はよくできたコントロールだと気づく記事です。
できたこと
- 動的に列を追加・削除する
- ヘッダークリックやメニュー操作でソートする
- データごとに特化したフィルターをする
- データごとに特化したグループ化をする
- いい感じのUI
- All MVVM
全体的な設計
- ViewModelの
ObservableCollection
が変更されたときに、GridView
のColumns
を変更するようなBehaviorを作ります。これにより動的な列追加・削除を実現すると同時に、各列に対して子ViewModelをBindします。 -
GridView
のヘッダー部分であるGridViewColumnHeader
コントロールをうまく使います。このコントロールはButtonBase
を継承しているためクリック時に子ViewModelに定義されたCommandを呼び出すことができます。またContentControl
を継承しているので、1で列に割り当てた子ViewModelをTargetType
としたDataTemplate
を使い見た目をカスタマイズできます。 - GridViewColumnHeader上での操作を、列に割り当てた子ViewModelで受けとり、親ViewModelに伝搬します。ListView操作の要であるCollectionViewを保持しているのは、個々の列(子ViewModel)ではなくリスト全体(親ViewModel)だからです。親ViewModelでCollectionViewを操作してソート・フィルター・グループ化を実現します。また、列(子ViewModel)全体の調整も親コントロールで行います
実装編
実装はすべてhttps://github.com/miswil/WPFListViewEtc にあります。ちょっと量が多く全部をQiitaに貼って解説することはできないのでClone推奨です。
説明したいところにフォーカスしたくて、なるべくライブラリに依存しない作りにしています。でもReactivePropertyくらいは使ったほうが楽だったな……
動的に列を追加・削除する
Behaviorを使います。要の処理は
columnVms.CollectionChanged += (_, e) =>
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
var added = (ColumnViewModelBase)e.NewItems![0]!;
gridView.Columns.Add(ToGridViewColumn(added));
break;
以下略
の部分で、親ViewModelでObservableCollection
(columnVms
)が変更されたときにGridView
のColumns
に列を追加します。今回の処理ではシンプルに、ViewModelからViewへの一方向のみを実装していますが、View側で列の並び替えなどをした場合などにViewModelに反映させることも可能です。
ToGridViewColumn
メソッドではGridViewColumn
の生成を行っており、次のような処理になっています。
private static GridViewColumn ToGridViewColumn(ColumnViewModelBase columnVm)
{
return new GridViewColumn
{
Header = columnVm,
CellTemplate =
columnVm.CellTemplateResourceKey is not null ?
(DataTemplate)App.Current.Resources[columnVm.CellTemplateResourceKey] :
columnVm.DisplayMenber is not null ?
CreateContentControlTemplate(columnVm.DisplayMenber) :
null,
};
}
private static DataTemplate CreateContentControlTemplate(string contentName)
{
var dataTemplateString =
@$"<DataTemplate><ContentControl Content=""{{Binding {contentName}}}""/></DataTemplate>";
ParserContext parserContext = new ParserContext();
parserContext.XmlnsDictionary.Add("", "http://schemas.microsoft.com/winfx/2006/xaml/presentation");
DataTemplate template = (DataTemplate)XamlReader.Parse(dataTemplateString, parserContext);
return template;
}
まずHeader
に子ViewModelを設定します。これによりContentPresenter
におけるDataTemplate
を用いたUIの制御が可能になります。さらにそのUI上での操作を子ViewModelが受け取ることができます。
次に、各データ行を表示するためのCellTemplate
を設定しますが、DataTemplate
を指すリソースキーをViewModelに埋め込むか、バインドするプロパティ名を埋め込むかしてやります。ここが若干ずるいところですが、他にいい方法思いつかず……ViewとViewModelが結合気味ですが、文字列だけですし、Behaviorを間に挟んでいるし、自動単体テストにも影響はないのでよしとしたいです。
ヘッダークリックやメニュー操作でソートする
ヘッダがクリックされたときにコマンドを呼び出すようにします。
<Style TargetType="{x:Type GridViewColumnHeader}">
<Setter Property="Command" Value="{Binding Content.SortCommand, RelativeSource={RelativeSource Self}}"/>
<Setter Property="CommandParameter" Value="{x:Null}"/>
</Style>
また、メニューから昇順・降順を選択してもソートできるようにします。
<MenuItem Style="{StaticResource SortMenuStyle}"
DockPanel.Dock="Right">
<MenuItem Header="昇順"
Command="{Binding SortCommand}"
CommandParameter="{x:Static component:ListSortDirection.Ascending}"/>
<MenuItem Header="降順"
Command="{Binding SortCommand}"
CommandParameter="{x:Static component:ListSortDirection.Descending}"/>
</MenuItem>
SortCommand
は子ViewModelで定義されており、そのまま親に処理を委譲します。親の側ではListView
のお決まりのソート処理をしてやればソートが完了します。SortDescriptions
をClearしていますが、それをやらなければ第一ソート、第二ソート...も実現できます。
var view = CollectionViewSource.GetDefaultView(this.Persons);
view.SortDescriptions.Clear();
view.SortDescriptions.Add(column.Sort(nextDirection));
データごとに特化したフィルターをする
列に表示するデータごとにフィルターの種類が様々です。今回の実装ではあえて種類が多くなるようにはしましたが、次のようなフィルターを作っています。
データ | データの種類 | フィルター |
---|---|---|
名前・ふりがな | 文字列 | 絞り込み文字列を入力して部分一致したデータのみを表示させます。名前列・ふりがな列ではまったく同じ列定義を使っており、再利用性もテーマになっています。 |
年齢 | 数値 | 数値ではありますが、各年齢ごとにフィルターするのは非常に面倒です。10歳台、20歳台のようにまとまりでフィルターできるようにします。 |
最終ログイン | 日時 | これも今週、今月といったまとまりでフィルターできるようにします。また、日付の範囲指定もできるようにする点が年齢より複雑です。 |
血液型 | 選択肢 | 定義済みの選択肢のみ存在するタイプです。各選択肢についてフィルターできるようにします。 |
出身地 | 選択肢(無限) | 離散的で選択肢と同等の性質を持ちながらも、その数が実質無限大です。血液型のように定義済みの値を使えませんので、実際にデータ内に存在する値からフィルターを動的に作ります。 |
実装ですが、やってることの基本は変わらず、子ViewModelで受け取った操作を親ViewModelに移譲し、ListView
のお決まりのフィルター操作を呼び出します。フィルター内ではすべての列のフィルターを通過させて絞り込みを行えるようにしています。
var view = CollectionViewSource.GetDefaultView(this.Persons);
view.Filter = item =>
{
if (item is not PersonViewModel vm) { return false; }
var allFilterPassed = true;
foreach (var column in this.Columns)
{
var isPassed = column.Filter(vm);
allFilterPassed = allFilterPassed && isPassed;
}
return allFilterPassed;
};
また、年齢、最終ログイン、出身地列では実際のデータに合わせて表示するフィルターの種類を変えるような実装も入れています。
データごとに特化したグループ化をする
グループ化も列に表示するデータごとに種類が様々です。基本的に、フィルターと同じ項目でグループ化できるといいと思います。
そのために必要な実装がGroupDescription
を継承したクラスの実装です。組み込みではPropertyGroupDescription
しか用意されていませんが、これを使うと特に数値型データでひどいことになります。例えば今回の「年齢」列でPropertyGroupDescription
を使うと、0歳グループ、1歳グループ、2歳etc...といった具合にグループ数がとんでもないことになります。
10歳台、20歳台、...でグループ化を実現するためのGroupDescription
クラスの実装は次の通りです。GroupNameFromItem
メソッド内で年齢をカテゴリに分け、カテゴリを表す値(AgeCategory
)とカテゴリの表示名を返しています。AgeCategory
だけを返してもグループ化は実現できるのですが、画面側の表示がAgeCategory.ToString()
の値になっていけてないので表示名も一緒に返します。カテゴリと表示名の紐づけはコードでやらずにXAMLでもがんばればできますが、すごく面倒なので。
public class AgeGroupDescription : GroupDescription
{
public AgeGroupDescription()
{
this.CustomSort = new AgeComparer();
}
public override object GroupNameFromItem(object item, int level, CultureInfo culture)
{
if (item is not PersonViewModel pvm) { throw new ArgumentException(nameof(item)); }
var category = pvm.Age.CategorizeAge();
var title = category switch
{
AgeCategory.UnderTen => "10才未満",
AgeCategory.TeenAgers => "10歳台",
AgeCategory.Twenties => "20歳台",
AgeCategory.Thirties => "30歳台",
AgeCategory.Fourties => "40歳台",
AgeCategory.Fifties => "50歳台",
AgeCategory.Sixties => "60歳台",
AgeCategory.OverSeventies => "70才以上",
};
return new GroupHeaderViewModel(category, title);
}
private class AgeComparer : IComparer
{
public int Compare(object? x, object? y)
{
return (x, y) switch
{
(CollectionViewGroup gx, CollectionViewGroup gy) =>
((AgeCategory)((GroupHeaderViewModel)gx.Name).Value).CompareTo((AgeCategory)((GroupHeaderViewModel)gy.Name).Value),
_ => throw new ArgumentException(),
};
}
}
}
いい感じのUI
ソートしている列にはソート順矢印を表示する
子ViewModelに各列がソートされているか、昇順か降順かをデータとして持たせているので、そこにバインドしてUIを変えます。
<Style x:Key="SortOrderMarkStyle" TargetType="{x:Type TextBlock}">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSorting}" Value="False">
<Setter Property="Visibility" Value="Hidden"/>
</DataTrigger>
<DataTrigger Binding="{Binding SortDirection}" Value="{x:Static component:ListSortDirection.Ascending}">
<Setter Property="Text" Value=""/>
</DataTrigger>
<DataTrigger Binding="{Binding SortDirection}" Value="{x:Static component:ListSortDirection.Descending}">
<Setter Property="Text" Value=""/>
</DataTrigger>
</Style.Triggers>
</Style>
操作メニューボタンが常に表示されていると邪魔なので、ヘッダにマウスオーバーしたときだけ表示。クリックしなくてもマウスオーバーでメニューを開く。
全部XAML上でやります。ユーザービリティとしてはやりすぎかも。操作ボタンが非表示になっているって基本的にNGですよね。デスクトップならまだしもスマホ、タブレットではマウスオーバーの概念もないですし。
マウスオーバーでメニューボタン表示のXAML
<DataTrigger Binding="{Binding IsMouseOver, Mode=OneWay, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type GridViewColumnHeader}}}"
Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
マウスオーバーでメニューを開くXAML
<Popup x:Name="PART_Popup"
PlacementTarget="{Binding ElementName=PART_Header}"
Placement="Bottom"
HorizontalOffset="-5"
IsOpen="{TemplateBinding IsMouseOver}"
Visibility="Collapsed"
AllowsTransparency="True">
フィルター・グループ化をしている列のフィルター操作メニューボタンは、マウスオーバーしてなくても表示。フィルターボタンクリックでフィルター解除
どの列で絞り込みしているのかわからなくなると悲惨なので、わかるようにするUIは必要だと思います。後半はやりすぎ。このUIをクリックするって発想は普通ないしわかりづらい。
まずフィルター・グループ化時にメニューボタンを表示するXAML
<DataTrigger Binding="{Binding IsFiltering}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsGrouping}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
続いてクリック時にフィルターを解除する定義です。ResetFilterAndGroupCommand
の処理ではフィルター解除を実装しています。
<Setter Property="Command" Value="{Binding ResetFilterAndGroupCommand}"/>
文字列フィルターで絞り込み条件にヒットした部分をハイライト
- 子ViewModelで絞り込み文字列入力
- 親ViewModelに通知
- 親ViewModelで各行ごとに、各列ごとにフィルター。このときに絞り込み文字列がCellに渡る
- Cell内でデータが絞り込み文字列を含むかどうか確認。含む場合、絞り込み文字列前中後に文字列を3分割する。絞り込み結果を親ViewModelに返す
そして3分割された文字列を表示するXAMLが次の通りです。特に設定してないけど、ハイライト部分が左右と間隔が空いてしまいます。
<DataTemplate DataType="{x:Type local:StringViewModel}">
<TextBlock>
<Run Text="{Binding PreviousFilteredText}"/>
<Run Text="{Binding FilteredText}" Background="Yellow"/>
<Run Text="{Binding FollowingFilteredText}"/>
</TextBlock>
</DataTemplate>
グループ化した際にグループ内の件数を表示
これはまんまMicrosoft Learnのサンプルコードです。
性能
各機能の操作から画面表示までの時間を計りました。非同期にしてないので画面が固まります。
環境:
Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz 3.19 GHz
32.0 GB
操作\データ数 | 1,000 | 10,000 | 100,000 | 1,000,000 |
---|---|---|---|---|
列追加・削除 | 1秒未満 | 1秒未満 | 1秒未満 | 1秒未満 |
ソート | 1秒未満 | 1秒未満 | 1秒未満 | 約4.5秒 |
フィルター | 1秒未満 | 1秒未満 | 1秒未満 | 約3.5秒 |
グループ化 | 約5秒 | 約50秒 | 測定断念 | 測定断念 |
グループ化(仮想化あり) | 1秒未満 | 1秒未満 | 1秒未満 | 約10秒 |
既定ではグループ化した際にはListViewのUI仮想化が解除されます。それを防ぐにはVirtualizingPanel.IsVirtualizingWhenGrouping
添付プロパティをTrueに設定します(参考:グループ化された大きなデータ セットを表示する際のパフォーマンスが向上)
<ListView ItemsSource="{Binding Persons}"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
UIの仮想化が無効になった場合、大量のListViewItemを作る必要があり大変重いです。データ数10,000件ですら50秒かかり、グループ化前後で約1.8GBほどメモリ使用量も増えてしまいます。
VirtualizingPanel.IsVirtualizingWhenGrouping
添付プロパティの既定値Trueでもいいと思うんですが、Falseなんですよね…なんででしょう。
まとめ
最終的にエクスプローラーの詳細表示で提供される程度の機能はやっと実装できました。これだけのことにずいぶんとコードを書いたと思います。
ただ振り返ってみるに、書いたコードの内容はほとんどアプリケーション独自の仕様を含みます。ListViewの汎用操作は組み込みで提供されていて割と使いやすいです。そう考えれば、ListViewは残念コントロールでもなんでもなく、汎用的によくできたコントロールで、残念なのはそれを使いこなせない人間なのでは……
まあソートと動的列くらいはもうちょっと組み込みサポートの余地があるんじゃないかとも思いますが。
今回UIをやりすぎた気もするので、ベターなのはソートメニューはなくしてフィルターメニューは常時表示でしょうか。ソートはヘッダークリックでできますし、それはもはや誰もが知る操作なので暗黙的機能でもなんとかなりそうです。
謝辞
テストデータは下記サイトの生成結果を加工・整形して作成しました。