気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

ヘッダーでソート可能なListViewを実装してみる

お久しぶりです。
最近多忙とブログネタが無かったことが重なって更新がかなり空いてしまいましたが、中の人は普通に生きてます。
今回は久しぶりにC# WPFネタで、GridViewColumnを装備したListViewで各GridViewColumnをクリックするとそのカラムに応じてソートするプログラムを実装してみます。

検証環境

項目 詳細
OS Windows 10 Pro x64 20H2
.Net .Net Core 3.1
Microsoft.Xaml.Behaviors.Wpf 1.1.31

.Net Framework 4.8でも動作します。

完成目標とサンプル

今回の例では完成すると画像のように各カラムをクリックするとその要素に合わせて昇順・降順が切り替わるようになります。
ちょっと分かりづらいですが、標準はIDの昇順ソートで、真ん中の値でソートされているのがわかるかと思います。

今回のサンプルプロジェクトは以下より閲覧・ダウンロード可能です。 SortableListView - Github

下準備 (表示だけしてみる)

View

とりあえず単純にバインディングして表示だけしてみます。

これは説明不要かと思いますが、2点だけ特殊な部分があって、まずはGridViewColumnHeader要素のTag属性です。
これはTextBlock.Textのバインディングと同じパス名を入れておいてください。
もう一つはGridViewColumn.CellTemplate使わない場合で、GridViewColumnオブジェクトに必ずGridViewColumnHeaderを内包してください。
実際にBehaviorで実装した後にしてもいいんですが面倒なのでこの時点で定義しちゃいます。
詳細は後述。

Views/MainWindow.xaml

<Window x:Class="SortableListView.Views.MainWindow"
        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:viewModels="clr-namespace:SortableListView.ViewModels"
        mc:Ignorable="d"
        Title="MainWindow" Height="300" Width="400">

    <Window.DataContext>
        <viewModels:MainWindowViewModel />
    </Window.DataContext>

    <Grid>
        <ListView ItemsSource="{Binding Accounts}">
            <ListView.View>
                <GridView>
                    <GridViewColumn Width="40" DisplayMemberBinding="{Binding Id}">
                        <GridViewColumnHeader Tag="Id" Content="ID" />
                    </GridViewColumn>

                    <GridViewColumn Width="100">
                        <GridViewColumnHeader Tag="Value1" Content="Value 1" />
                        <GridViewColumn.CellTemplate>
                            <DataTemplate DataType="viewModels:ListViewValue">
                                <TextBlock Text="{Binding Value1}" />
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>

                    <GridViewColumn Width="100">
                        <GridViewColumnHeader Tag="Value2" Content="Value 2" />
                        <GridViewColumn.CellTemplate>
                            <DataTemplate DataType="viewModels:ListViewValue">
                                <TextBlock Text="{Binding Value2}" />
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</Window>

ViewModels

お次はViewModelです。
こちらもそんな説明はいらないと思いますが、一応さらっとだけ。
ListViewValueはListViewのItemsSourceにバインディングするコレクションの各要素となる構造体です。

ViewModels/ListViewValue.cs

namespace SortableListView.ViewModels
{
    public struct ListViewValue
    {
        public int Id { get; set; }

        public string Value1 { get; set; }

        public string Value2 { get; set; }
    }
}

次にMainWindowのViewModelです。
ListViewのItemsSourceとバインディングするAccountsプロパティをコンストラクタで初期化してるだけ。
各要素は順不同であるものとします。
なお、Value 2がランダム値使ってるのに特に深い意味はないです。

ViewModels/MainWindowViewModel.cs

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;

namespace SortableListView.ViewModels
{
    public class MainWindowViewModel
    {
        public ICollection<ListViewValue> Accounts { get; set; }

        public MainWindowViewModel()
        {
            var random = new Random(Environment.TickCount);
            Accounts = new ObservableCollection<ListViewValue>
            {
                new ListViewValue { Id = 5, Value1 = "e", Value2 = random.Next().ToString() },
                new ListViewValue { Id = 4, Value1 = "d", Value2 = "" },
                new ListViewValue { Id = 101, Value1 = "i", Value2 = random.Next().ToString() },
                new ListViewValue { Id = 1, Value1 = "a", Value2 = random.Next().ToString() },
                new ListViewValue { Id = 8, Value1 = "h", Value2 = random.Next().ToString() },
                new ListViewValue { Id = 3, Value1 = "c", Value2 = random.Next().ToString() },
                new ListViewValue { Id = 2, Value1 = "b", Value2 = random.Next().ToString() },
                new ListViewValue { Id = 6, Value1 = "f", Value2 = random.Next().ToString() },
                new ListViewValue { Id = 15, Value1 = "j", Value2 = "" },
                new ListViewValue { Id = 7, Value1 = "g", Value2 = random.Next().ToString() }
            };
        }
    }
}

実行する

とりあえずこれで実行すると順不同のListViewValueが表示されているはずです。

カラムヘッダをクリックしても何も反応しないと思いますが、とりあえずこれでOK。

Behaviorでソート可能にする

ここからが本題です。
一見難しそうなListViewのソートですが、意外に意外やそんなことはなく、ItemsSourceからCollectionViewを取得し、SortDescriptionオブジェクトを追加してあげるだけで勝手にソートしてくれます。
その機能を利用してヘッダークリックに応じてSortDescriptionオブジェクトを調整してあげるだけでソートができちゃいます。

SortableListViewBehavior

ちょっと長いので全文はSortableListViewBehavior.csを見てもらうとして、各メソッドごとにやってることを軽く説明していきます。

AssociatedObjectOnLoadedメソッド

ListViewがロードされた時に呼び出されるメソッドですが、Viewにて定義したGridViewColumnHeaderのClickイベントにメソッドを登録しています。
また、ヘッダーの初期状態を記録しておいて後々もとに戻せるようにしておきます。
あとはFirstSort属性に初期表示時のソートプロパティが設定されている場合だけソート処理を行います。

◎追記 _headersからFirstSortの項目を取得するところを修正しました。

private readonly Dictionary<GridViewColumnHeader, string> _headers = new Dictionary<GridViewColumnHeader, string>();

...

private void AssociatedObjectOnLoaded(object sender, RoutedEventArgs e)
{
    if (!(AssociatedObject.View is GridView gridView))
        return;

    // GridViewColumnHeaderのClickイベントにメソッドを登録
    // 同時にヘッダーの初期表示を記録
    foreach (var gridViewColumn in gridView.Columns)
    {
        if (!(gridViewColumn.Header is GridViewColumnHeader header) || _headers.ContainsKey(header))
            continue;

        _headers.Add(header, header.Content.ToString());
        header.Click += (_, args) => HeaderOnClick(AssociatedObject, args);
    }

    // 初期表示時にソートするカラムがなければソートしない
    if (string.IsNullOrEmpty(FirstSort))
        return;

    var firstHeader = _headers.Keys.First(x => x.Tag.ToString() == FirstSort);
    var content = firstHeader.Content.ToString();
    GridViewColumnHeaderSort(AssociatedObject, FirstSort,
        arg => firstHeader.Content = $"{content} {arg}");
}

HeaderOnClickメソッド

ソートメソッドは後回しにして、次にGridViewColumnHeaderのクリックイベントを記述していきます。
基本的にはソートメソッドを呼ぶだけなんですが、どのヘッダーが昇順なのか降順なのかを末尾に追加するため、それをもとに戻す処理をクリックのタイミングで行っています。
あとはソートメソッドを呼び出すだけです。

private void HeaderOnClick(object sender, RoutedEventArgs e)
{
    if (!(e.OriginalSource is GridViewColumnHeader header))
        return;

    // 全てのヘッダーを初期表示に戻す
    foreach (var h in _headers)
    {
        h.Key.Content = h.Value;
    }

    var content = header.Content.ToString();

    GridViewColumnHeaderSort(AssociatedObject, header.Tag.ToString(),
        arg => header.Content = $"{content} {arg}");
}

GridViewColumnHeaderSort

ソートを行う本体メソッドです。
やってることは単純で、ListView#ItemsSourceからCollectionViewを取得し、そのCollectionViewにSortDescriptionオブジェクトを追加してあげるだけです。
SortDescriptionオブジェクトではソートするプロパティ名(Bindingのパス名)とListSortDirection型で昇順か降順を指定して初期化します。

あとはヘッダの末尾に文字を追加してあげたり、昇順・降順を切り替える処理があるくらいでめちゃくちゃ簡単にソートできます。

private static void GridViewColumnHeaderSort(ListView listView, string headerName, Action<string> setContentSuffixAction)
{
    // ListView#ItemsSourceからCollectionViewを取得
    var collectionView = CollectionViewSource.GetDefaultView(listView.ItemsSource);

    // CollectionViewからSortDescriptionsの最初の要素を取得
    var sortDescription = collectionView.SortDescriptions.FirstOrDefault();
    // ソート方法をクリア
    collectionView.SortDescriptions.Clear();
    if (sortDescription.PropertyName == headerName)
    {
        // SortDescriptionsのヘッダ名とクリックしたヘッダ名が同一なら降順か昇順かをチェンジする
        if (sortDescription.Direction == ListSortDirection.Ascending)
        {
            collectionView.SortDescriptions.Add(new SortDescription(headerName, ListSortDirection.Descending));
            setContentSuffixAction?.Invoke("↓");
        }
        else
        {
            collectionView.SortDescriptions.Add(new SortDescription(headerName, ListSortDirection.Ascending));
            setContentSuffixAction?.Invoke("↑");
        }
    }
    else
    {
        // 昇順でソートする
        collectionView.SortDescriptions.Add(new SortDescription(headerName, ListSortDirection.Ascending));
        setContentSuffixAction?.Invoke("↑");
    }
}

MainWindow

あとはMainWindowのListViewにBehaviorを追加するだけでソートが機能するようになります。
注意点は二度目ですが、GridViewColumnHeader要素のTag属性とGridViewColumnだけで済む場合でもGridViewColumnHeader要素を内包することです。

GridViewColumnHeader要素のTag属性ですが、Content属性とオブジェクトのプロパティ名はローカライズなどで必ず一致するとは限らないため、何らかの方法でプロパティ名(Bindingのパス名)を渡す必要があります。
Bindingのパスを取得する方法もありますが、複雑なのとちょくちょくnullが返ってくるので確実なのはXAMLで定義することでしょう。
そこでTag属性を利用してパスを確実にBehaviorへ渡します

もう一つはGridViewColumnHeader要素の内包ですが、GridViewColumn.CellTemplate使わない場合だとGridViewColumn.DisplayMemberBindingで値をバインディングしてGridViewColumn.Headerにカラム名を書くことが多いかと思います。
しかしながら、GridViewColumnオブジェクトにはClickイベントが無いので動的にClickイベントを割り当てることができません
なので、Clickイベントを割り当てるために必ずGridViewColumnHeaderを内包する必要があります。

(補足)
XAMLのほうだとListViewにGridViewColumnHeader.Clickがインテリセンスでは出ないですが指定できて、コードビハインドにイベントが書けるのですが、こちらなら内包しなくてもいけるかもしれません。
まぁコードビハインド使いたくないからBehavior使ってるわけでわざわざ使う意味はないですが。

Behavior内でFirstSort属性を定義してますが、これは初期状態でソートさせるかどうかなので別になくてもOK。
また、ソートされてるヘッダは末尾に「↑/↓」が出るようにしていますが、必要に応じてFirstSort属性みたいに定義してもいいかも。

Views/MainWindow.xaml

<Window ...
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:behaviors="clr-namespace:SortableListView.Behaviors"
        ...>

    <Window.DataContext>
        <viewModels:MainWindowViewModel />
    </Window.DataContext>

    <Grid>
        <ListView ItemsSource="{Binding Accounts}">
            <i:Interaction.Behaviors>
                <behaviors:SortableListViewBehavior FirstSort="Id" />
            </i:Interaction.Behaviors>

            <ListView.View>
                <GridView>
                    <GridViewColumn Width="40" DisplayMemberBinding="{Binding Id}">
                        <GridViewColumnHeader Tag="Id" Content="ID" />
                    </GridViewColumn>

                    <GridViewColumn Width="100">
                        <GridViewColumnHeader Tag="Value1" Content="Value 1" />
                        <GridViewColumn.CellTemplate>
                            <DataTemplate DataType="viewModels:ListViewValue">
                                <TextBlock Text="{Binding Value1}" />
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>

                    <GridViewColumn Width="100">
                        <GridViewColumnHeader Tag="Value2" Content="Value 2" />
                        <GridViewColumn.CellTemplate>
                            <DataTemplate DataType="viewModels:ListViewValue">
                                <TextBlock Text="{Binding Value2}" />
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</Window>

すると目標と同じようにヘッダカラムでソートができるようになります。

実際の値はどうなってるのか

今回行ったソートで気になるのは実際のコレクションはどうなっているか。 嬉しいことに、CollectionViewでは本体のコレクションは変更しないようになっています。
ListCollectionView Class - Microsoft Docs

実際にヘッダークリックイベント内のソート後にViewModel内の本体とCollectionViewの中身を表示させると異なる結果であることがわかります。
なお、CollectionViewはValue1を昇順ソートしています。

MainWindowViewModel#Accounts
  5 e 1837974566
  4 d 
  101 i 421150764
  1 a 595945452
  8 h 1616981068
  3 c 1495149107
  2 b 490640367
  6 f 1049469499
  15 j 
  7 g 1913761367

CollectionView
  1 a 595945452
  2 b 490640367
  3 c 1495149107
  4 d 
  5 e 1837974566
  6 f 1049469499
  7 g 1913761367
  8 h 1616981068
  101 i 421150764
  15 j 

なので、実際に扱う際は大元のデータの順序は気にしなくても問題ありません。

また、ソートした状態で要素を追加するとちゃんとソートされた状態で表示されます。
元のObservableCollectionへはただ単にAddしてあげればいいだけなので、ソートを気にする必要はありません。