気まま研究所ブログ

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

コンパレータでソート可能なListViewを実装してみる

以前、ヘッダーでソート可能なListViewを実装してみるといったソート可能なListViewを紹介しましたが、より高度なソートとしてコンパレータを使ってソートする方法も紹介します。
ただし、前回のように汎用的な実装ではないので仕様に際しては工夫が必要です。

検証環境

項目 詳細
OS Windows 10 Pro x64 20H2
.Net .NET 6
Microsoft.Xaml.Behaviors.Wpf 1.1.39

完成目標とサンプル


今回の例ではコンパレータを使用しない項目と、使用する項目を混在させたものとなります。
ベースはヘッダーでソート可能なListViewを実装してみると同じなのでこちらも参考にしてください。
ただし、FirstSortOrderなるプロパティを追加していたりvirtualメソッドに変更していたりと若干変わるのでサンプルも同時に参考にしてください。

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

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

View

ベースはヘッダーでソート可能なListViewを実装してみると同じなので詳細な説明は割愛します。

一応項目としては、「ファイル名」「ファイルサイズ (KB, MB, GB)」を想定しています。

Views\MainWindow.xaml

<Window x:Class="SortableComparerListView.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:i="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:viewModels="clr-namespace:SortableComparerListView.ViewModels"
        xmlns:behaviors="clr-namespace:SortableComparerListView.Views.Behaviors"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance viewModels:MainWindowViewModel}"
        Title="MainWindow" Height="300" Width="400">

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

    <Grid>
        <ListView ItemsSource="{Binding FileList}">
            <i:Interaction.Behaviors>
                <behaviors:SortableListViewBehavior FirstSort="Name" FirstSortOrder="Desc" />
            </i:Interaction.Behaviors>

            <ListView.View>
                <GridView>
                    <GridViewColumn Width="150" DisplayMemberBinding="{Binding Name}">
                        <GridViewColumnHeader Tag="Name" Content="Name" />
                    </GridViewColumn>
                    <GridViewColumn Width="100" DisplayMemberBinding="{Binding FileSizeText}">
                        <GridViewColumnHeader Tag="FileSizeText" Content="FileSize" />
                    </GridViewColumn>
                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</Window>

ViewModel

お次のViewModelではViewで表示するデータを作成します。
こちらも大したことはしていないので詳細な説明は割愛します。

ViewModels\FileListInfo.cs

using System;

namespace SortableComparerListView.ViewModels;

public class FileListInfo
{
    public string Name { get; set; } = string.Empty;
    public long FileSize { get; set; } = 0L;

    public string FileSizeText
    {
        get
        {
            if (FileSize < 1024)
                return $"{FileSize} Bytes";
            else if (FileSize < 1024 * 1024)
                return $"{Math.Floor((double)FileSize / 1024)} KB";
            else if (FileSize < 1024 * 1024 * 1024)
                return $"{Math.Floor((double)FileSize / (1024 * 1024))} MB";
            else
                return $"{Math.Floor((double)FileSize / (1024 * 1024 * 1024))} GB";
        }
    }
}

ViewModels\MainWindowViewModel.cs

using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace SortableComparerListView.ViewModels
{
    public class MainWindowViewModel
    {
        public ICollection<FileListInfo> FileList { get; set; }

        public MainWindowViewModel()
        {
            FileList = new ObservableCollection<FileListInfo>()
            {
                new()
                {
                    Name = "File3.dat",
                    FileSize = 20801456
                },
                new()
                {
                    Name = "File4.dat",
                    FileSize = 41235
                },
                new ()
                {
                    Name = "File6.dat",
                    FileSize = 15098214000
                },
                new()
                {
                    Name = "File2.dat",
                    FileSize = 644
                },
                new()
                {
                    Name = "File5.dat",
                    FileSize = 1125
                },
                new()
                {
                    Name = "File1.dat",
                    FileSize = 25456
                },
            };
        }
    }
}

実行してみる


実行し、FileSizeをポチポチしてみるとこのようになります。
昇順にも関わらず最も小さいはずの644バイトが一番末尾に来ていますね。
これは内部のファイルサイズではなく、表示されている「644 Bytes」などの文字列を対象に比較をしているから起きてしまっています。

これでは困るので、今回はBehaviorを継承してファイルサイズのみコンパレータを使用するようにしてみましょう。

Behaviorを継承して実装する

SortableFileSizeListViewBehavior

ベースクラスのSortableListViewBehaviorはGithubのものを参考にしてください。
前回の記事のものは継承に対応していないのと、FirstSortのデフォルトオーダーに対応していないので上手く動かないです。

コンパレータ

まずはコンパレータから実装していきます。

Comparer\FileSizeAscComparer.cs

public class FileSizeAscComparer : IComparer
{
    public int Compare(object? x, object? y)
    {
        if (x is not FileListInfo logFileItemX || y is not FileListInfo logFileItemY)
            return 0;

        return logFileItemX.FileSize.CompareTo(logFileItemY.FileSize);
    }
}

コンパレータはファイルサイズに特化したもので、引数で渡されたオブジェクト、ここではFileListInfoオブジェクトのファイルサイズを比較して結果を返します。

Comparer\FileSizeDescComparer.cs

public class FileSizeDescComparer : IComparer
{
    public int Compare(object? x, object? y)
    {
        if (x is not FileListInfo logFileItemX || y is not FileListInfo logFileItemY)
            return 0;

        return logFileItemX.FileSize.CompareTo(logFileItemY.FileSize) * -1;
    }
}

AscはそのままFileSizeプロパティのCompareToの結果を返せば良いのですが、Descの場合は逆転させる必要があります。

また、コンパレータに渡される値はListViewのItemsSourceに依存し、表示されている文字列が来るわけではありません。
今回はFileListInfoオブジェクトですが、カスタムクラスであればそのクラスが渡されます。

CustomSortでソート

コンパレータができたら次はコンパレータをListViewに設定します。

Views\Behaviors\SortableFileSizeListViewBehavior.cs

private readonly IComparer _descFileSizeComparer = new FileSizeDescComparer();
private readonly IComparer _ascFileSizeComparer = new FileSizeAscComparer();

...

protected override void GridViewColumnHeaderSort(ListView listView, string headerName, Action<string> setContentSuffixAction)
{
    if (headerName == "FileSizeText")
    {
        var collectionView = (ListCollectionView)CollectionViewSource.GetDefaultView(listView.ItemsSource);

        collectionView.SortDescriptions.Clear();

        if (collectionView.CustomSort is FileSizeAscComparer)
        {
            collectionView.CustomSort = _descFileSizeComparer;
            setContentSuffixAction?.Invoke("↓");
        }
        else
        {
            collectionView.CustomSort = _ascFileSizeComparer;
            setContentSuffixAction?.Invoke("↑");
        }
        collectionView.Refresh();
    }
    else
    {
        base.GridViewColumnHeaderSort(listView, headerName, setContentSuffixAction);
    }
}

ヘッダーがFileSizeTextの場合のみコンパレータを使用するようにして、それ以外は基底クラスのメソッドを呼び出すことにします。

基本的にはListCollectionView型のCustomSortプロパティにコンパレータを設定するだけなのですが、CollectionViewSource.GetDefaultView(listView.ItemsSource)で取得できるのはICollectionViewインタフェースです。
こいつではCustomSortが設定できないので、ListCollectionView型にキャストする必要があります。

後はListCollectionViewオブジェクトのRefreshメソッドを呼び出せばソート完了です。

実行してみる


先程は644 Bytesが末尾だったのが正しく先頭にきています。

このようにしてコンパレータを設定することで高度なソートも可能となります。
また、今回は完全に特化したクラスになりましたが、アノテーションをうまく使えば汎用的なBehaviorの作成も可能です。
こちらもそのうち紹介すると思います。

ところで昇順と降順の矢印スゴイ分かりづらい・・・。

参考文献

CollectionViewSourceのSortDescriptionについてお伺いいたします