2015年12月17日木曜日

ListView Extensions ver.1.0.0-beta1

WPFのListViewって結構使うのに根性いりません?

ちょっとしたデータの可視化程度ならば適当な実装でもさほど困らないかもしれませんが、ListViewらしくヘッダーをクリックしてソートしたり、右クリックやダブルクリック、選択したアイテムの取得などの実装をしようとし始めるととたんに面倒になってくるのがWPFのListViewです。
そこで、ListViewでよくやることについて、手が届きそうで届かない痒い所を中心にView、ViewModel、Modelのすべてにおいてサポートをするライブラリを作ったら格段とListViewの使い勝手が上がるのではないか?という発想になりました。

というわけで、こんなクラスを実装し、ライブラリ化しました。下記の図の青色のクラスです。


順を追って説明していきます。

Model

Modelの中心的存在となるのはSortableObservableCollection<T>です。
ここで重要なのは「Sortable」です。Sortedじゃないんですね。ListViewに表示するデータはソートされていなくてもいいですし、ソートされていてもいいです。必要に応じて並び替えることができるということでSortableという名前にしました。
このコレクションは、ISortableObservableCollection<T>インターフェースを継承しています。
/// <summary>
/// ソート可能な変更通知コレクションのインターフェースです。
/// </summary>
/// <typeparam name="T">要素の型</typeparam>
public interface ISortableObservableCollection<T> : IList<T>, INotifyPropertyChanged, INotifyCollectionChanged
{
    /// <summary>
    /// 自身のソート状態からして適切な位置にアイテムを追加します。
    /// ソートされていなかったら末尾に追加されます。
    /// もしも追加するアイテムと同じ大きさのアイテムがあった場合、すでにあるものの最後に挿入されます。
    /// </summary>
    /// <param name="item">追加するアイテム</param>
    void AddAsSorted(T item);

    /// <summary>
    /// 指定したインデックスが示す位置にある項目を、コレクション内の新しい場所に移動します。
    /// </summary>
    /// <param name="oldIndex">移動する項目の場所を指定する、0から始まるインデックス。</param>
    /// <param name="newIndex">項目の新しい場所を指定する、0から始まるインデックス。</param>
    void Move(int oldIndex, int newIndex);

    /// <summary>
    /// 自身をソートします。
    /// </summary>
    /// <param name="propertyName">ソートするプロパティ名</param>
    /// <param name="direction">ソートする方向</param>
    void Sort(string propertyName, SortingDirection direction);

    /// <summary>
    /// 現在のソート条件
    /// </summary>
    SortingCondition SortingCondition { get; }
}
IList<T>とINotifyCollectionChangedを継承しているのは当然ですね。あとは、SortingConditionというプロパティを持つのでINotifyPropertyChangedも継承しています。

さて、このインターフェースが独自に実装を支持しているのは、見ての通り3つのメソッドと1つのプロパティです。
AddAsSortedは見ての通り、ソート状態を保ったままアイテムを追加するものです。もしもコレクションがソートされていなければ末尾に追加されます。
MoveはObservableCollectionなどと同じくアイテムを移動させるものです。
Sortはソートを実行するメソッドです。T型のプロパティ名をstringで指定して、SortingDirectionで指定した向きでソートを実行します。
public enum SortingDirection
{
    None,
    Ascending,
    Descending,
}
いたってシンプルです。Ascendingは昇順、Descendingは降順です。SortメソッドにNoneを渡すと例外を吐きます。

SortingConditionですが、これは現在のソート条件を示すプロパティです。
public class SortingCondition : IEquatable<SortingCondition>
{
    /// <summary>
    /// ソート条件なしとして初期化します
    /// </summary>
    public SortingCondition()
    {
        PropertyName = string.Empty;
        Direction = SortingDirection.None;
    }

    /// <summary>
    /// ソート条件なしとして初期化します
    /// </summary>
    public SortingCondition(string propertyName, SortingDirection direction)
    {
        if(direction == SortingDirection.None) {
            PropertyName = string.Empty;
            Direction = SortingDirection.None;
        } else {
            if(string.IsNullOrEmpty(propertyName))
                throw new ArgumentNullException(nameof(propertyName));

            PropertyName = propertyName;
            Direction = direction;
        }
    }

    /// <summary>
    /// プロパティ名
    /// </summary>
    public string PropertyName { get; }

    /// <summary>
    /// ソート方向
    /// </summary>
    public SortingDirection Direction { get; }

    /// <summary>
    /// ソート条件がなしかを取得します。
    /// </summary>
    public bool IsNone => Direction == SortingDirection.None;

    #region IEquatable

    public override bool Equals(object obj)
    {
        return base.Equals(obj as SortingCondition);
    }

    public override int GetHashCode()
    {
        return PropertyName.GetHashCode();
    }

    public bool Equals(SortingCondition other)
    {
        if(other != null)
            return this.PropertyName == other.PropertyName && this.Direction == other.Direction;
        else
            return false;
    }

    #endregion

    public override string ToString()
    {
        if(IsNone)
            return "None";
        else
            return $"{PropertyName}, {Direction}";
    }

    /// <summary>
    /// ソート条件なし
    /// </summary>
    public readonly static SortingCondition None = new SortingCondition();
}
平たく言えばプロパティ名とソート方向を保持するクラスです。等価評価やソート条件なしを示すstaticフィールドなどが用意されており、ちょっとコードがごちゃごちゃしていますが、基本的にはソート条件を示すだけです。Sortメソッドを呼び出すことでソート条件が変化し、SortingConditionプロパティが変化します。また、アイテムの追加、移動、変更等でソート条件を満たさなくなった時も同様にこのプロパティは変化します。

概ねこれがModelの中身です。
SortableObservableCollectionは中でObservableCollectionのフィールドを持っており、コレクションの変更通知自体はObservableCollectionに任せています。しかし、ObservableCollectionはスレッドセーフではないので、そのあたりのニーズがある場合は適当なスレッドセーフな変更通知コレクションを実装したライブラリなどと組み合わせて独自のISortableObservableCollectionを実装するとよいです。このライブラリではSortableCollection抽象クラスを提供しており、これはソートにかかわるメソッドやプロパティのみが実装されたものなので、これを上手く継承するとそういったものが比較的楽に作れるかと思います。

ViewModel

ViewModelは基本的にListViewViewModel<TViewModel,TModel>クラスを使うことになります。
コンストラクタを見れば使い方が想像つきやすいかと思います。
public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher)
まずはsourceです。これはModelで用意したSortableObservableCollectionを指定すれば良いです。そのコレクションの変更を監視し、それに追従して自身のコレクション(読み取り専用)を変更します。
ところで、ViewModelはViewの都合を吸収するものですから、Modelで使ってたコレクションの要素の型とは違うものになることが多いです。なので、その変換をするメソッドをconverterに渡します。そうすることで、ModelをViewModelに変換した状態でListViewViewModelに保持することができるようになります。
最後に、ListViewコントロールに対してコレクションの変更通知をするときはUIスレッドで行う必要があるため、この引数でUIのDispatcherを引き渡してあげる必要があります。これにより、ListViewViewModelではたとえUIスレッド外でSortableObservableCollectionが変更されたとしてもちゃんとUIスレッド上でCollectionChangedイベントを発生させてくれます。

ちなみに、コンストラクタはもう1つあり、そちらではそのDispatcherの実行優先度を指定することができます。指定しなかった場合(上記のコンストラクタを使った場合)はDispatcherPriority.Normalになります。

最後に、このコンストラクタを呼び出す例を示しましょう。
People = new ListViewViewModel<PersonViewModel, Person>(model.People, m => new PersonViewModel(m), DispatcherHelper.UIDispatcher);
modelのPeopleプロパティ(SortableObsevableCollection<Person>型)に対して、それに対応するListViewViewModelを作っています。変換はPersonViewModelのコンストラクタにPerson型を与えることで実現しています。逆に言えば、コレクションの要素のViewModelはこのような実装をすることをお勧めします。


また、ViewModelにはSortableObservableCollectionに対応したコレクション操作以外に、ListViewの他の機能に対応するプロパティもいくつか持っています。

コレクションの操作

例えば、ListViewを使う場面として、何か適当なデータを複数セットする場合などがあると思います。その場合、データの追加、削除、移動などといった作業が付きまといます。


この画像を見ると、上から3つの項目が選択されています。選択されている項目があるのでRemoveボタンは有効です。また、一番上の項目が選択されているためこれをさらに上へ移動することはできないのでUpボタンは無効化されていますが、下への移動はできるのでDownボタンは有効になっています。
このようなデータの削除、移動は選択状態さえわかれば実行の可否がわかりますし、各要素のデータの内容知っている必要もありません。項目を消し去ったりそのまま動かせばいいだけです。なので、ListViewViewModelでは、その動作を実現するコマンドを用意してあります。
/// <summary>
/// 選択中の項目を削除するコマンド
/// </summary>
public ICommand RemoveSelectedItemCommand { get; }

/// <summary>
/// 選択中の項目を上へ移動するコマンド
/// </summary>
public ICommand MoveUpSelectedItemCommand { get; }

/// <summary>
/// 選択中の項目を上へ移動するコマンド
/// </summary>
public ICommand MoveDownSelectedItemCommand { get; }
ちなみにですが、見ての通り追加はコマンドでは用意されていません。追加されるデータの内容がわかりませんからね。また、追加は基本的にいつでも可能なものです。もしでないときがあるのならば、それはListViewの状態から決まるものではないと考えられます。なのでこのライブラリではそれに対応するコマンドはサポート外です。適当にコマンドを作るなりメソッドをバインディングするなりしてSortableObservableCollectionに直接Addしてください。

ソート

ソートは流れが面倒なので詳しくはまとめて後述します。
ViewModelとしては、ソート指示を受け取るSortByPropertyCommandと、現在のソート条件を示すSortingConditionプロパティを持っています。

選択

ListViewで複数選択する場合はSelectedItemsプロパティをバインディングする必要があります。
実はこのプロパティは読み取り専用依存関係プロパティなので普通にバインディングできないのですが、まあとりあえずそれについては後述します。
SelectedItemsをバインディングする先として、ListViewViewModelではSelectedItemsSetterプロパティを用意しています。ですが、これは非ジェネリックスのIList型なのでとても使いにくいです。なので、この内容をミラーリングしたジェネリックのコレクション、しかも変更通知までできるようにしたものとして、SelectedItemsプロパティを用意してあります。もしも選択状態を知りたければこれを読み込めば大丈夫です。

ちなみに、選択をViewModel側から変更したいときのためにSelectItem、 UnselectItem、ToggleItemSelectionなどのメソッドも用意されています。また選択されているか同化を判別するためにIsSelectedItemなどのメソッドもあります。また、選択を反転するコマンドとしてToggleSelectionCommandも用意されています。

View

Viewのサポートは上記のようなクラスの提供とは少し異なります。なんといっても使うのはListViewですから、例えばListViewViewModelをバインディングするだけですべてが実現できるようなコントロールは用意していません。必要に応じて、必要なものをバインディングしてください。

まずは下準備として、XAMLに名前空間の定義をします。
xmlns:lv="http://schemas.eh500-kintarou.com/ListViewExtensions"
名前空間はURLでまとめてあります。
XAMLの書き方は機能別に記述します。

ソート


ListViewのヘッダーをクリックしたらソートがされるべきです。ですが、残念ながらListViewには勝手にソートしてくれる機能はありません。なので、そのような機能をこのライブラリがサポートすることで解決しています。

全体の流れてとしては下記の図のようになります。



View:ヘッダーがクリックされる
↓コマンド呼び出し
ViewModel
↓Sortメソッド呼び出し
Model::自身をソートし、ソート条件を更新する
↓コレクション・プロパティ監視
ViewModel:自身のコレクションとソート条件をModelと同期
↓バインディング
View:コレクションの反映、ソート済みのヘッダーの▲▼印の表示

てな具合ですかね。ViewからModelまでを往復します。
ViewModel→Model→ViewModelの流れはListViewViewModelクラスとSortableObservableCollectionクラスがいい感じに勝手に処理をしてくれますが、ViewとViewModelのつながりはXAMLに記述しなければなりません。例えば、こんな感じになります。
<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name" >
                    <lv:SortedHeader Content="Name" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation" >
                    <lv:SortedHeader Content="Pronunciation" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age" >
                    <lv:SortedHeader Content="Age" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday" >
                    <lv:SortedHeader Content="Birthday" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height_cm" >
                    <lv:SortedHeader Content="Height" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height_cm'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
        </GridView>
    </ListView.View>
</ListView>
まずはGridViewColumnでバインディングするプロパティ名を指定します。この辺まではListViewの一般的な使い方かと思います。ですが、これはあくまでも"列"の設定ですので、列に対応したヘッダーの設定を別口でする必要があります。ということで、GridViewColumnHeaderを指定してあげます。これが列のヘッダーに対応する要素ですので、クリック時のコマンドをListViewViewModelのSortByPropertyCommandにバインディングしてあげることで、ヘッダーのクリックを伝えることができるようになります。CommandParameterにはソートするときに使うプロパティ名を指定する必要があるので注意してください。

また、GridViewColumnHeaderはそのContentが表示内容になるわけですが(すなわち任意のコントロールを表示内容として配置できます)、その表示内容にはこのライブラリが提供している SortedHeaderコントロールを使うといいです。これは、Contentに加えてソートを示す▲や▼などの図形を同時に表示することができるコントロールです。SortingDirectionプロパティにNoneを指定すれば非表示、Ascending/Descendingで▲/▼になります。
この状態をListViewから反映させるには、SortingConditionをバインディングするといいです。ただし、SortingConditionはソートに使われたプロパティ名とその方向を保持しているクラスですので、適当なValueConverterを介してそれを各ヘッダーのSortingDirectionに変換してやる必要があります。それがSortingConditionConverterになります。パラメーターにプロパティ名を与えれば、そのプロパティ名に一致するSortingConditionの場合はそのSortingDirection、そうでない場合はSortingDirection.Noneを返すようになっています。

これで、ヘッダーを押すとソートされ▲▼が付くというListViewが実現できるようになりました。

選択のバインディング

さて、上でListViewのSelectedItemsプロパティは読み取り専用なので直接バインディングはできないということを紹介しました。
じゃあどうするか。トリガーアクションを使います。

このライブラリではListViewSelectedItemsActionというアクションを持っており、このアクションが実行されると、SelectedItemsをSourceにコピーしてくれます。イベントトリガーを使って、SelectionChangedイベントが発生するたびにこのアクションを実行しても良いですが、選択項目が変更されるとどうもSelectedItemsにアイテムが追加されたり削除されたりするだけで、SelectedItems自体の参照は変わらないようです。むしろ、ViewModel側から選択操作をする場合は、このSelectedItemsの参照が無ければできませんから(これのAddメソッドを呼び出すことで実現している)、インスタンスが出来上がったらいち早く呼び出してやる必要があります。

というわけで、あなたが普段使っているMVVMライブラリのメッセンジャー機能を使ってこのようにListViewViewModelをインスタンス化した直後にSelectedItemsをミラーリングしてあげてください。私が普段使っているLivetではこうなります。
People = new ListViewViewModel<PersonViewModel, Person>(model.People, m => new PersonViewModel(m), DispatcherHelper.UIDispatcher);
Messenger.Raise(new InteractionMessage("SelectedItemsMirroring"));
XAMLではこのようにします。
<i:Interaction.Triggers>
    <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="SelectedItemsMirroring" >
        <lv:ListViewSelectedItemsAction Source="{Binding People.SelectedItemsSetter}" />
    </l:InteractionMessageTrigger>
</i:Interaction.Triggers>
こうすることで、ListViewViewModelが作られた直後にSelectedItemsがミラーリングされ、選択項目の取得や設定が可能になります。図にするとこんな感じになります。



ちなみにですが、 ContentRenderedとかのイベントを拾ってあげればわざわざViewModelからメッセージ送らなくていいんじゃないの?との考えもあるかもしれませんが、タイミングによってはContentRendered発生後にListViewViewModelをインスタンス化することがありますので、上手く拾えない可能性が結構あります。変なところで悩まないためにもこの方法が今のところベストかなーと思っています。何かいい対策があればいいのですが…。

ListViewの項目のダブルクリック

さて、ファイルビュアーみたいなものを想定した場合、項目がダブルクリックされたときにその要素のViewModelに対してコマンドを発火させるなりメソッドを呼び出すなりしてやりたくなることがあるかと思います。
これが案外めんどくさいんですね。詳細については以前の記事で紹介しましたが、この記事の機能もこのライブラリに組み込まれています。さらに、前回の記事ではコマンドをバインディングするだけでしたが、メソッドも直接バインディングできるようになっています。
<ListView ItemsSource="{Binding People}" >
    <!-- 中略 -->
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <!--<Setter Property="lvap:DoubleClickBehavior.Command" Value="{Binding DoubleClickCommand}" />-->
            <Setter Property="lv:DoubleClickBehavior.MethodTarget" Value="{Binding}" />
            <Setter Property="lv:DoubleClickBehavior.MethodName" Value="DoubleClicked" />
        </Style>
    </ListView.ItemContainerStyle>
</ListView>
こんな感じですね。コメントアウトしてあるのはコマンドをバインディングする方法で、コメントアウトしていないほうがメソッドを直接バインディングするほうです。

ライセンス

以下の各項目をお守りください
  • このライブラリを利用する方は自己責任でお願いします。いかなる問題が起きても作者は責任を負いません。
  • このソフトを悪用しないでください。
  • このソフトウェアを無断で単体での転載、再配布しないでください。ただし、このライブラリを参照しているソフトウェアと一緒に配布する場合を除きます。
  • 作者は使用方法やバグに関するサポートをする義務を負いません。
  • 有償アプリケーションには使用してはならない。
  • 完成したソフトウェアのどこか(ヘルプ、バージョン情報など)と、ReadMeなどのドキュメンテーションに私のライブラリを使用したことを明記すること。ただし、作者(私)がこのライブラリを自分のソフトで使用するときはその限りではない。

公開

Nugetに初挑戦してみました。どうぞ。
https://www.nuget.org/packages/ListViewExtensions/
なお、 プレリリース版扱いですので、Nugetでは検索時にプレリリース版もヒットするように設定する必要があるので注意してください。

また、こちらにサンプルプログラムを用意しています。どうぞ。
ListViewExtensionsSample ver.1.0.0-beta1

0 件のコメント:

コメントを投稿