[WPF]MVVMでDataGridのソートを扱うためのクラス設計

MVVM的な設計の中でDataGridのソート機能をどのように扱うかという話。

WPFのDataGridには標準で列ヘッダクリックによるソートの機能があるんだけども
これをコードでも制御したいときに、ItemsSourceの中身はViewModel側にあるんだけど
Viewでやるの?どうするの?って話だ。

要件を整理すると以下のとおり。
・ソートは、ユーザによる列ヘッダクリック、および任意の状態変化をトリガとして実行される
・ソート状態を列ヘッダにアイコンで表示する
・表示データが更新された場合にソート状態を引き継ぐことができる


ちょっと検索すると、ListCollectionViewをViewModel側に持って、
これをDataGridにバインドすればViewModel側だけで処理が完結するよ
っていうサンプルが出てくるんだけど、
これは列ヘッダのアイコン表示との同期までは考えられていなかったので却下した。

そもそもListCollectionView自体がDispatcherObjectなので、
これをViewModelで持つというのはあまりよろしくない。
実際、これを非UIスレッドからいじってしまうと露骨に不具合が出るのでね。

それで結局どうしたかというと、あまりスマートな設計にできなかったのだけど、
要点を書くと、以下のような感じにしました。
・ListCollectionViewのソート機能は使わずに自分でOrderBy()してバインド
・常に、ViewModelで中身をソート→Viewのヘッダアイコンに反映 の順に処理する
 

以下、実際のコードを載せていきます。

まず、Viewのxaml。
Personの一覧を表示するDataGridがひとつと、
動作確認のためのボタンが2つあるだけ。

<Window x:Class="DataGridSortOnMvvmSample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <StackPanel Orientation="Vertical">
        <DataGrid Height="200"
                  AutoGenerateColumns="False"
                  Name="dataGrid"
                  Sorting="dataGrid_Sorting"
                  ItemsSource="{Binding Persons}">
            <DataGrid.Columns>
                <DataGridTextColumn Header="No"  Binding="{Binding No}" Width="100"/>
                <DataGridTextColumn Header="Age" Binding="{Binding Age}" Width="100"/>
                <DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*"/>
            </DataGrid.Columns>
        </DataGrid>
        <Button Content="Ageでソート" Command="{Binding SortByAgeCommand}"/>
        <Button Content="ItemsSourceを再構成" Command="{Binding ResetItemsSourceCommand}"/>
    </StackPanel>
</Window>

こんな画面。
datagrid-sort
 

次にViewのコードビハインド。
メッセンジャー登録してる中身はあとで説明します。
とりあえずコンストラクタでDataContextを設定してるのと、
あとはDataGridの列ヘッダをクリックされた際のハンドラで、
そのまま標準のソート処理を走らせないでViewModelのメソッドを呼び出している。

ここではViewModelのメソッドをベタに呼び出しちゃってるけど、
本当はインタフェース経由で参照するなり、コマンド作るなりしたほうが設計的には良い。

public partial class MainWindow : Window
{
    MainWindowViewModel viewModel;

    //コンストラクタ
    public MainWindow()
    {
        InitializeComponent();

        this.DataContext = (this.viewModel = new MainWindowViewModel());

        //メッセンジャーへの登録
        Messenger.Instance.Register<SortMessage>(this, OnSortMessageReceived);
    }

    //メッセージ受信時処理
    private void OnSortMessageReceived(SortMessage msg) { ... }

    //ソートイベントハンドラ
    private void dataGrid_Sorting(object sender, DataGridSortingEventArgs e)
    {
        e.Handled = true;   //処理済みにしておく

        var preDir = e.Column.SortDirection;
        var newDir = preDir == ListSortDirection.Ascending ? ListSortDirection.Descending : ListSortDirection.Ascending;

        //ViewModelのメソッドを呼び出し
        viewModel.ExecuteSort(e.Column.SortMemberPath, newDir);
    }
}

 

そしてViewModel。まずは全体をドン。

class MainWindowViewModel : INotifyPropertyChanged
{
    #region INotifyPropertyChanged実装

    public event PropertyChangedEventHandler PropertyChanged;

    private void RaisePropertyChanged(string propertyName)
    {
        var h = PropertyChanged;
        if (h != null) h(this, new PropertyChangedEventArgs(propertyName));
    }

    #endregion

    SortDescription? currentSort;

    public ICommand SortByAgeCommand { get; private set; }
    public ICommand ResetItemsSourceCommand { get; private set; }

    //※.NET4.0ではICollection<T>
    private IReadOnlyCollection<Person> _persons;

    public IReadOnlyCollection<Person> Persons
    {
        get
        {
            return _persons;
        }
        private set
        {
            _persons = value;
            RaisePropertyChanged("Persons");
        }
    }


    //コンストラクタ
    public MainWindowViewModel()
    {
        this.Persons = NewPersons();

        //Ageでソートするコマンド
        SortByAgeCommand = new DelegateCommand(_ =>
        {
            ListSortDirection dir;
            if (currentSort.HasValue
                && currentSort.Value.PropertyName == "Age"
                && currentSort.Value.Direction == ListSortDirection.Ascending)
            {
                dir = ListSortDirection.Descending;
            }
            else
            {
                dir = ListSortDirection.Ascending;
            }

            //別スレッドから
            Task.Factory.StartNew(() =>
            {
                ExecuteSort("Age", dir);
            });
        });

        //ItemsSource再構成コマンド
        ResetItemsSourceCommand = new DelegateCommand(_ =>
        {
            this.Persons = NewPersons();

            //ソート再現
            if (currentSort.HasValue)
            {
                ExecuteSort(currentSort.Value.PropertyName, currentSort.Value.Direction);
            }
        });
    }

    public void ExecuteSort(string propertyName, ListSortDirection direction)
    {

        var isAsc = direction == ListSortDirection.Ascending;

        IEnumerable<Person> sortedQuery;

        switch (propertyName)
        {
            case "No":
                sortedQuery = isAsc ? Persons.OrderBy(p => p.No) : Persons.OrderByDescending(p => p.No);
                break;
            case "Age":
                sortedQuery = isAsc ? Persons.OrderBy(p => p.Age) : Persons.OrderByDescending(p => p.Age);
                break;
            case "Name":
                sortedQuery = isAsc ? Persons.OrderBy(p => p.Name) : Persons.OrderByDescending(p => p.Name);
                break;
            default:
                throw new ArgumentException();
        }

        Persons = new ReadOnlyCollection<Person>(sortedQuery.ToList());

        //ソートアイコン表示をViewに依頼
        Messenger.Instance.Send<SortMessage, MainWindow>(new SortMessage(propertyName, direction));

        //ソート情報保持
        currentSort = new SortDescription(propertyName, direction);
    }


    private static IReadOnlyCollection<Person> NewPersons()
    {
        return new ReadOnlyCollection<Person>(new[] 
        {
            new Person(1, 25, "たなか"),
            new Person(2, 30, "おだ"),
            new Person(3, 15, "さとう"),
            new Person(4, 20, "にかいどう"),
        });
    }
}

DataGridのItemsSourceにバインドしてるのはPersonsコレクション。
んで、適当なレコードを作ってるのがNewPersonsメソッド。

一応、↓こんなPersonクラス。

class Person
{
    public int No { get; private set; }

    public int Age { get; private set; }

    public string Name { get; private set; }

    public Person(int no, int age, string name)
    {
        this.No = no;
        this.Age = age;
        this.Name = name;
    }
}

ソートの実処理をやってるのは、さっきViewから呼んでた
ExecuteSortメソッド。

    public void ExecuteSort(string propertyName, ListSortDirection direction)
    {

        var isAsc = direction == ListSortDirection.Ascending;

        IEnumerable<Person> sortedQuery;

        switch (propertyName)
        {
            case "No":
                sortedQuery = isAsc ? Persons.OrderBy(p => p.No) : Persons.OrderByDescending(p => p.No);
                break;
            case "Age":
                sortedQuery = isAsc ? Persons.OrderBy(p => p.Age) : Persons.OrderByDescending(p => p.Age);
                break;
            case "Name":
                sortedQuery = isAsc ? Persons.OrderBy(p => p.Name) : Persons.OrderByDescending(p => p.Name);
                break;
            default:
                throw new ArgumentException();
        }

        Persons = new ReadOnlyCollection<Person>(sortedQuery.ToList());

        //ソートアイコン表示をViewに依頼
        Messenger.Instance.Send<SortMessage, MainWindow>(new SortMessage(propertyName, direction));

        //ソート情報保持
        currentSort = new SortDescription(propertyName, direction);
    }

プロパティ名とプロパティの結び付けをswitch文でやってるのがダサいけど、
要はコレクションを自分でソートして再設定してやってるだけだ。
 

で、そのあとでView側にアイコン表示反映を依頼してるのが↓これ。

Messenger.Instance.Send<SortMessage, MainWindow>(new SortMessage(propertyName, direction));

僕はViewModelからViewへの連絡に↑のような形のMessengerを使ってるのだけど、
まあViewに処理を依頼できれば何でもいい。
Messengerクラスの中身はここに載せると長くなるので
気になる人は「Messengerパターン」とかでググってください。

で、View側でそのメッセージを受け取ってるのがさっき省略してたやつ。

    private void OnSortMessageReceived(SortMessage msg)
    {
        //※ItemsSourceへのバインドを先に強制評価する
        dataGrid.GetBindingExpression(DataGrid.ItemsSourceProperty).UpdateTarget();

        //ソートアイコン表示
        var clm = dataGrid.Columns.First(c => c.SortMemberPath == msg.PropertyName);
        clm.SortDirection = msg.Direction;
    }

僕はMessengerが常にViewのDispatcher経由でメッセージを届けるようにしているが、
そうなっていない場合は、Dispatcherを通すようにしておいたほうが安全だろう。
後述するように別スレッドでの状態変化をトリガにする場合は必須。

    private void OnSortMessageReceived(SortMessage msg)
    {
        //UIスレッドから実行されていないときは再帰呼び出しして終了
        if (!this.Dispatcher.CheckAccess())
        {
            this.Dispatcher.Invoke(new Action(() => OnSortMessageReceived(msg)));
            return;
        }

        //※ItemsSourceへのバインドを先に強制評価する
        dataGrid.GetBindingExpression(DataGrid.ItemsSourceProperty).UpdateTarget();

        //ソートアイコン表示
        var clm = dataGrid.Columns.First(c => c.SortMemberPath == msg.PropertyName);
        clm.SortDirection = msg.Direction;
    }

 

ここでめんどくさいポイントが、ItemsSourceのバインディングを強制評価してるところ。
特にItemsSourceへのバインドソース(Persons)が別スレッドで更新された場合なんだけど、
ItemsSourceへのバインディング反映が非同期に処理されるので、
先にアイコン表示変更が処理されてしまうと、その直後にItemsSourceが更新されたタイミングで
せっかく表示したアイコンがクリアされてしまう。
なので、先に強制的にItemsSourceへの反映を処理してしまってから
アイコン表示を更新するようにしています。

↓のように、アイコン表示処理を遅延実行させる手もある。

    private void OnSortMessageReceived(SortMessage msg)
    {
        this.Dispatcher.BeginInvoke(new Action(() =>
        {
            var clm = dataGrid.Columns.First(c => c.SortMemberPath == msg.PropertyName);
            clm.SortDirection = msg.Direction;

        }), DispatcherPriority.ContextIdle);
    }

 

その他のViewModelのコードはサンプル動作確認用のもの。

↓はコードからソートを制御する例。
ここでは別スレッドで監視してる状態変化をトリガとする想定でTaskにした。

        //Ageでソートするコマンド
        SortByAgeCommand = new DelegateCommand(_ =>
        {
            ListSortDirection dir;
            if (currentSort.HasValue
                && currentSort.Value.PropertyName == "Age"
                && currentSort.Value.Direction == ListSortDirection.Ascending)
            {
                dir = ListSortDirection.Descending;
            }
            else
            {
                dir = ListSortDirection.Ascending;
            }

            //別スレッドから
            Task.Factory.StartNew(() =>
            {
                ExecuteSort("Age", dir);
            });
        });

あ、DelegateCommandは標準的なICommandの実装で、
DelegateCommandとかRelayCommandとかでググるとでてくるようなやつです。
今回の本題とは関係ないので、なんならボタンクリックイベントを使ってもよろしい。

↓はItemsSourceをまるごと入れ替えるときにソートを引き継ぐ例。
DataGridはItemsSourceに対するDefaultViewでソートを管理してるので
ItemsSourceが入れ替わるとソートアイコン表示も勝手に消えてしまう。
なので再設定してやってます。

        //ItemsSource再構成コマンド
        ResetItemsSourceCommand = new DelegateCommand(_ =>
        {
            this.Persons = NewPersons();

            //ソート再現
            if (currentSort.HasValue)
            {
                ExecuteSort(currentSort.Value.PropertyName, currentSort.Value.Direction);
            }
        });

 

以上。
いっそDataGridのソートアイコンも自前で描いたほうが楽かもしれんな。
そんでViewModel側で持ってるソート情報をVisibilityとかにコンバートしてバインドしてやれば
メッセージ飛ばす必要もないし、もう少しスマートになるかも。

あと、今回のサンプルではソートの第二キーを特に意識していないけど、
そこも厳密にやろうとする場合は、例えば初期状態リストとソート後のリストを別にもっておいて
毎回初期状態に対して第一キーのみでソートかけるとか考えれば良いでしょう。