◆ WPF の ListView にソート機能をつけてみました

ListView という Windows でよくみる表のような表示方法があります
エクスプローラーの詳細表示みたいもの

listview-header-explorer

こういうのって基本 列のヘッダ部分をクリックしたら その列でソートしてくれますよね
それでもう一回クリックしたら逆順ソートになって

なので この機能は当たり前にあるものかと思ってました


でも WPF を使ってみると DataGrid というデータ編集用の表形式なコントロールではソート機能がデフォルトであるのですが ListView にはついてないんです

やっぱりこの機能欲しいよね と思うので作ってみます

ListView のソート

適当に 3 つカラムを準備しました

[Window1.xaml]
<Window
        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:local="clr-namespace:wpf_sample_project"
        x:Class="wpf_sample_project.Window1"
        mc:Ignorable="d"
        Height="480" Width="640">
    <Grid>
        <ListView ItemsSource="{Binding items}" GridViewColumnHeader.Click="listHeader_Click">
            <ListView.View>
                <GridView>
                    <GridViewColumn Header="ID" DisplayMemberBinding="{Binding id}" Width="80" />
                    <GridViewColumn Header="名前" DisplayMemberBinding="{Binding name}" Width="80" />
                    <GridViewColumn Header="説明" DisplayMemberBinding="{Binding description}" Width="140" />
                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</Window>

C# の部分
長いけどほとんど Binding の設定とかなので ちゃんとみるのは listHeader_Click メソッドだけでいいです

[Window1.xaml.cs]
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;

namespace wpf_sample_project
{
    /// <summary>
    /// Window1.xaml の相互作用ロジック
    /// </summary>
    public partial class Window1 : Window
    {
        private class BindingData : NotifyHelper
        {
            public List<Row> items
            {
                get { return this.getValue(nameof(items), new List<Row>()); }
                set { this.setValue(nameof(items), value); }
            }

            public BindingData()
            {
                this.items = new List<Row> {
                    new Row {id = 1, name="abc", description="お" },
                    new Row {id = 3, name="def", description="か" },
                    new Row {id = 2, name="acc", description="う" },
                    new Row {id = 4, name="bfe", description="さ" }
                };
            }
        }

        public class Row
        {
            public int id { get; set; }
            public string name { get; set; }
            public string description { get; set; }
        }

        public Window1()
        {
            this.DataContext = new BindingData();
        }

        private void listHeader_Click(object sender, RoutedEventArgs e)
        {
            var header = (GridViewColumnHeader)e.OriginalSource;

            // 列の外部分は無視
            if (header.Column == null)
            {
                return;
            }

            var binding = (Binding)header.Column.DisplayMemberBinding;
            var path = binding.Path.Path;

            var listview = (ListView)sender;
            var pre_sort_items = listview.ItemsSource.Cast<object>();
            var sorted_items = pre_sort_items.OrderBy(row =>
            {
                return row.GetType().GetProperty(path).GetValue(row);
            }).ToList();

            // すでに昇順なら降順にする
            if (sorted_items.SequenceEqual(pre_sort_items))
            {
                sorted_items.Reverse();
            }

            listview.ItemsSource = sorted_items;
        }
    }
}

ListView の GridViewColumnHeader.Click プロパティに設定しているので ListView の中の GridViewColumnHeader のクリックイベントを受け取ります

GridViewColumnHeader の Column プロパティが null なのは 右端の列じゃない あまり部分なので無視します

クリックされた列の binding 情報から列のプロパティ名を取得して ListView のデータ (ItemsSource) をソートします
すでにソート済みなら反転して逆順になるようにしています

これでヘッダクリックでソート機能ができました

でも 今どの列でソートされているのかがわからないので もうちょっと追加します

ソート列を表示

列名の上に ▲▼ をつけるのはちょっと面倒なので 簡単に背景に色を付けることにします


[Window1.xaml]
xaml に column header の style を追加します
<ListView ItemsSource="{Binding items}" GridViewColumnHeader.Click="listHeader_Click">
    <ListView.Resources>
        <Style TargetType="GridViewColumnHeader">
            <Style.Triggers>
                <Trigger Property="Tag" Value="ASC">
                    <Setter Property="Background" Value="#FFFF88A0"/>
                </Trigger>
                <Trigger Property="Tag" Value="DESC">
                    <Setter Property="Background" Value="#FF87ECEC"/>
                </Trigger>
            </Style.Triggers>
        </Style>
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Header="ID" DisplayMemberBinding="{Binding id}" Width="80" />
            <GridViewColumn Header="名前" DisplayMemberBinding="{Binding name}" Width="80" />
            <GridViewColumn Header="説明" DisplayMemberBinding="{Binding description}" Width="140" />
        </GridView>
    </ListView.View>
</ListView>


[Window1.xaml.cs]
listHeader_Click で GridViewColumnHeader のタグを編集します
private void listHeader_Click(object sender, RoutedEventArgs e)
{
    var header = (GridViewColumnHeader)e.OriginalSource;

    // 列の外部分は無視
    if (header.Column == null)
    {
        return;
    }

    var binding = (Binding)header.Column.DisplayMemberBinding;
    var path = binding.Path.Path;

    var listview = (ListView)sender;
    var pre_sort_items = listview.ItemsSource.Cast<object>();
    var sorted_items = pre_sort_items.OrderBy(row =>
    {
        return row.GetType().GetProperty(path).GetValue(row);
    }).ToList();

    var pflag = BindingFlags.Instance | BindingFlags.NonPublic;
    var row_presenter = typeof(GridView).GetProperty("HeaderRowPresenter", pflag).GetValue(listview.View);
    var col_headers = (List<GridViewColumnHeader>)typeof(GridViewHeaderRowPresenter).GetProperty("ActualColumnHeaders", pflag).GetValue(row_presenter);

    foreach(var col_header in col_headers)
    {
        col_header.Tag = null;
    }

    var order_type = "ASC";

    // すでに昇順なら降順にする
    if (sorted_items.SequenceEqual(pre_sort_items))
    {
        sorted_items.Reverse();
        order_type = "DESC";
    }

    listview.ItemsSource = sorted_items;
    header.Tag = order_type;
}

ソートの条件になっている列のヘッダに ASC か DESC のタグをつけます
新しくソート条件が変わったら前のヘッダから ASC/DESC を外すのですが 通常の方法で ListView のインスタンスから GridViewColumnHeader にはアクセスできないようでちょっと複雑な方法になってます
var pflag = BindingFlags.Instance | BindingFlags.NonPublic;
var row_presenter = typeof(GridView).GetProperty("HeaderRowPresenter", pflag).GetValue(listview.View);
var col_headers = (List<GridViewColumnHeader>)typeof(GridViewHeaderRowPresenter).GetProperty("ActualColumnHeaders", pflag).GetValue(row_presenter);

列のヘッダにアクセスするには
var gridview = (GridView)listview.View;
var header = gridview.Columns[0].Header;
で できるのですが 今回のような XAML 定義では header のところには列名が入っていて GridViewColumnHeader のインスタンスではありません

ヘッダを表示するためのテンプレートがデフォルトで設定されていて 表示する時に列名の文字をテンプレートに当てはめて表示しているようです

テンプレートを使った実際の要素は公開されてるプロパティからは取得できないので VisualTree を探索するか 今回みたいなプライベートなプロパティにアクセスする必要がでてきます

プライベートなプロパティでも場所がわかっているなら VisualTree を辿るより取得するのは楽だし実行速度も速そうなのでこっちにしました


Window1 のプロパティに前回の GridViewColumnHeader いれておけばもっと楽なのですけど出来る限り ListView だけで完結したくて Window1 のプロパティを増やしたくなかったのでこうなりました


あとは XAML の Style を好きにいじればいい感じのソート機能付き ListView が完成です

追記:注意点

GridViewColumnHeader.Click にリスナ設定をしていますが これは ButtonBase.Click に定義されているものです
そのせいで GridViewColumnHeader のクリックだけでなく Button のクリックでも反応してしまいます


例えばこんなボタン列がある ListView があったとします
<ListView ItemsSource="{Binding items}" GridViewColumnHeader.Click="listHeader_Click">
    <ListView.View>
        <GridView>
            <GridViewColumn Header="ID" DisplayMemberBinding="{Binding id}" Width="80" />
            <GridViewColumn Header="名前" DisplayMemberBinding="{Binding name}" Width="80" />
            <GridViewColumn Header="ボタン">
                <GridViewColumn.CellTemplate>
                    <DataTemplate>
                        <Button>ボタン</Button>
                    </DataTemplate>
                </GridViewColumn.CellTemplate>
            </GridViewColumn>
        </GridView>
    </ListView.View>
</ListView>
private void listHeader_Click(object sender, RoutedEventArgs e)
{
    Console.WriteLine("Called.");
}

削除ボタンや詳細ボタンなどでけっこうあるケースだと思います

このときに Button を押した場合でも Called とコンソールに出力されます

本当に GridViewColumnHeader だけのクリックだけを受け取るのは無理そうなので ボタンを使うときはリスナの中で
var header = (GridViewColumnHeader)e.OriginalSource;
のかわりに
var header = e.OriginalSource as GridViewColumnHeader;
if (header == null) return;
を使います



……ボタン使わないときでもこれでよさそう