2017年11月26日日曜日

ListView Extensions ver.1.0.0リリース

さて、ついに正式リリースしましたListView Extensions ver.1.0.0。

ListView Extensions - Nuget

beta4からはほとんど変わっていないはずです。そのままだと使いにくいWPFのListViewを使いやすくすることを目的に、View、ViewModel、Modelの全領域で便利な機能を提供するライブラリです。

ListView Extensionsの使用例はこんな感じです。


見た目はただのWPFのListViewに見えますが、よくよく見ると「Name」ヘッダーの上部にソートしていることを表す「▲」が表示されています。ListViewと言えばこのように名前、読み、年齢など様々なプロパティを持つデータをソートするときに使い、さらに、ユーザーが好きなプロパティでソートする機能を提供するようなものをイメージするはずです。Windowsのエクスプローラーだってそうですよね。ファイル名でソートしたり、更新日時でソートしたり、ファイルの種類でソートしたりすることがあるはずです。
ただ、WPFは標準でそのような機能をサポートしていません。自分でそれを実装しようとするととてつもなく手間がかかりますので、それをやってくれるライブラリが欲しくなります。そこで生まれたのがListView Extensionsというわけです。

せっかく正式リリースにしたので使い方をなぞってみます。

1. 使い方

実はbeta-1をリリースしたときにもある程度書いてあります。


ViewにはWPF標準のListView、ViewModelにはListViewViewModel、ModelにはSortableObservableCollectionを使うのが基本になります。それ以外の使い方はこのライブラリは想定していません。
SortableObservableCollectionは名前の通りソート可能コレクションです。必ずしもソートされているとは限らず、Sortメソッドを呼び出した時点でその時に指定したプロパティ名でソートします。いかにもListViewのソートに沿った機能ですね。

さて、Viewにはあらゆる機能が凝縮されていますので、これを見るのが手っ取り早いです。
まずはXAMLにListViewViewModelの名前空間を追加しておきましょう。
xmlns:lv="http://schemas.eh500-kintarou.com/ListViewExtensions"
Viewに対応する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.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="ContextMenu">
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Increment the age" Command="{Binding IncrementAgeCommand}" />
                        <MenuItem Header="Decrement the age" Command="{Binding DecrementAgeCommand}" />
                    </ContextMenu>
                </Setter.Value>
            </Setter>
            <!--<Setter Property="lv:DoubleClickBehavior.Command" Value="{Binding DoubleClickCommand}" />-->
            <Setter Property="lv:DoubleClickBehavior.MethodTarget" Value="{Binding}" />
            <Setter Property="lv:DoubleClickBehavior.MethodName" Value="DoubleClicked" />
        </Style>
    </ListView.ItemContainerStyle>
    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="SelectedItemsMirroring" >
            <lv:ListViewSelectedItemsAction Source="{Binding People.SelectedItemsSetter}" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
</ListView>

ちなみに、「People」がViewModelでのListViewViewModelのインスタンスになります。

順に機能を見ていきます。

a. ソート

ListViewの機能のよく使う機能としてリストのソートがありますが、WPFでは標準でサポートされていません。それを行います。

ヘッダーをクリックして並び替える

ListViewのヘッダーをクリックするとCommandが発生します。ですので、そこでソートをする機能を呼び出します。
/// <summary>
/// プロパティ名をパラメーターに与えてソートするコマンド
/// </summary>
public ICommand SortByPropertyCommand { get; }
ListViewViewModelにはプロパティ名でソートするコマンドが備わっています。ですので、ヘッダーのクリックに合わせてこのコマンドを呼ぶようにしましょう。プロパティ名はパラメーターで渡してあげる必要があります。

ヘッダーにソートを示す▲▼を表示する

これはWPFの標準の機能がないので、ListView Extensionsが提供するSortedHeaderコントロールをGridViewColumnHeaderContentとして使います。GridViewColumnHeaderはContentControlを継承しているので、任意のコントロールをContent表示することができるのです。

SortedHeaderコントロールもContentControlクラスを継承しており、1個だけプロパティが追加されています。
/// <summary>
/// ソートの方向
/// </summary>
public SortingDirection SortingDirection
{
    get { return (SortingDirection)GetValue(SortingDirectionProperty); }
    set { SetValue(SortingDirectionProperty, value); }
}
SortingDirectionもListView Extensionsが提供するEnumで、Ascending、Descending、Noneの3つの状態があります。これに合わせて▲▼を表示してくれるようになります。

しかし、ListViewViewModelはソート状態をソート方向、プロパティ名の2つの値で保持しています。それらをひっくるめて、SortingConditionというプロパティがあります。
/// <summary>
/// 現在のソート条件
/// </summary>
public SortingCondition SortingCondition
{
    get { ... }
    private set
    {
        ...
    }
}
ですので、これから各ヘッダーに対応したSortingDirectionに変換するValueConverterが必要になります。

ご安心ください。そのConverterもListView Extensionsには装備されています。
SortingConditionConverterがそれで、パラメーターにプロパティ名を与えてやればそのヘッダーのSortingDirectionを返してくれます。

というわけでこのようなXAMLが出来上がります。
<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>
DisplayMemberBindingでプロパティ名、ヘッダーをクリックされたときのCommandParameterでプロパティ名、SortingConditionをSortingDirectionに変換するValueConverterのパラメーターでプロパティ名が必要で、計3回書く必要があります。SortedHeaderのContentもプロパティ名になる場合は4回出てきます。コピペミス等に気を付けてください。

b. アイテムのダブルクリック

これがなかなかやろうとすると難しいところです。
左クリックは通常選択程度の意味を成しませんからそんなに自分でコントロールしようと思うことはないですし、右クリックはたいていコンテキストメニューですからContextMenuプロパティにメニューを設定してやればいいです。しかし、ダブルクリックはアプリケーションを実行したり、編集画面を出したりと何かしらの処理を行う系になります。すなわち、ViewModelのメソッドやコマンドを呼び出したい何かになるわけです。

そこで、DoubleClickBehaviorです。この添付ビヘイビアはメソッド直接バインディングとコマンドのバインディングの両方を用意しています。Viewを見ればわかるかと思います。メソッドやコマンドは当然ですがアイテムのViewModelに書きます。

c. 選択

これもなかなか厄介です。ListViewのSelectedItemSelectedIndexは単一選択しか想定されていないうえに、SelectedItemsはgetのみでバインディングしようとするとうまくいきません。
ListViewItemはIsSelectedプロパティを持っているからこれを使えばいいのではと思うとそれも違います。ListViewItemは表示していない部分のインスタンスは消えてしまいますので、選択されているアイテムが表示範囲外に行くとIsSelectedは期待通りに動作してくれません。

実はListViewでの選択はこのSelectedItemsプロパティがすべて握っていて、このリストに対してAddやRemoveなどの編集操作をすると選択アイテムがそれに応じて増減します。さらに、このリストはICollectionChangedも実装していて、これで選択の変化を捉えることも可能です。
これはどう見てもコードビハインドで使うためのメソッドです。ですが、何とかそれをViewModelで使えるようにしました。手順としては次の通りになります。

SelectedItemsへの参照をViewModelに渡す

まずはこれを行う必要があります。これはListView Extensionsが提供しているListViewSelectedItemsActionを使います。ListViewViewModelはSelectedItemsSetterプロパティを持っており、ListViewSelectedItemsActionはこれにSelectedItemsの参照を渡してくれます。
<i:Interaction.Triggers>
    <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="SelectedItemsMirroring" >
        <lv:ListViewSelectedItemsAction Source="{Binding People.SelectedItemsSetter}" />
    </l:InteractionMessageTrigger>
</i:Interaction.Triggers>
今回はLivetを使っているので、LivetのInteractionMessageTriggerを使っています。別のMVVMフレームワークでも、何かしらViewModelからViewのアクションを動かす機構を持っているはずなのでそれを使ってください。
タイミングは、例えばContentRenderedなどのListViewインスタンス生成直後に呼ばれるイベントのタイミングでコピーをすべきでしょう。そのタイミングでViewModelからViewにListViewSelectedItemsActionを動かすように呼び出します。
Messenger.Raise(new InteractionMessage("SelectedItemsMirroring"));

選択の読み書き

SelectedItemsは非ジェネリックのコレクションで非常に使いにくいです。ですので、ListViewViewModelのSelectedItemsSetterはSet-Onlyプロパティにしており、読み取りはできないようにしています。

選択の状態を読み取るには、SelectedItemsSetterをミラーリングしているSelectedItemsプロパティを使ってください。
/// <summary>
/// 選択中の項目をIListじゃ使いにくいから使いやすくミラーリングしたクラス
/// </summary>
public ReadOnlyObservableCollection<TViewModel> SelectedItems
{
    get
    {
        ...
    }
}
逆に、選択をするにはViewModelに用意されたメソッドを使います。
/// <summary>
/// 指定したアイテムを選択します
/// </summary>
/// <param name="item">選択するアイテム</param>
public void SelectItem(TViewModel item);

/// <summary>
/// 指定したインデックスの要素を選択します。
/// </summary>
/// <param name="index">選択するインデックス</param>
public void SelectAt(int index);

/// <summary>
/// 指定した要素の選択を解除します
/// </summary>
/// <param name="item">選択を解除する要素</param>
public void UnselectItem(TViewModel item);

/// <summary>
/// 指定したインデックスの要素の選択を解除します
/// </summary>
/// <param name="index">インデックス</param>
public void UnselectAt(int index);

/// <summary>
/// 選択されているアイテムかを調べます
/// </summary>
/// <param name="item">選択されているかどうか調べたい要素</param>
/// <returns>選択されていればtrue</returns>
public bool IsSelectedItem(TViewModel item);

/// <summary>
/// 選択されているアイテムかを調べます
/// </summary>
/// <param name="item">選択されているかどうか調べたい要素</param>
/// <returns>選択されていればtrue</returns>
public bool IsSelectedAt(int index);

/// <summary>
/// 指定したアイテムの選択を反転します
/// </summary>
/// <param name="item">選択を反転するアイテム</param>
public void ToggleItemSelection(TViewModel item);

/// <summary>
/// 指定したインデックスの要素の選択を反転します。
/// </summary>
/// <param name="index">インデックス</param>
public void ToggleItemSelectionAt(int index);
まあ、メソッドの使い方は見ればわかるでしょう。

選択に対する操作

さらにもう一歩先の機能として、選択に対する操作も用意しています。
リスト系のソフトだと、選択しているアイテムを削除したり、選択しているアイテムを上や下へ動かすという用途もたくさん出てくるでしょう。ListViewViewModelにはそのような操作をするコマンドも用意されています。
/// <summary>
/// 選択中の項目を削除するコマンド
/// </summary>
public ICommand RemoveSelectedItemCommand { get; }

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

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

/// <summary>
/// 選択を反転するコマンド
/// </summary>
public ICommand ToggleSelectionCommand { get; }
適当なボタンなどを作って、必要に応じてこのコマンドを呼び出してあげれば良いでしょう。

選択に関する制約

ListView.SelectedItemsプロパティを見てもらっても分かりますが、ListViewは「選択されているアイテム」で選択を管理しており、例えばインデックスなどでは管理されていません。これは何を意味しているかというと、ListViewの要素として同じインスタンスを複数登録すると選択が正常に動作しないということです。

ですが、通常はListViewの要素はアイテムのViewModelになります。ViewModelのインスタンスをあらゆるアイテムに対して生成しておけば同じインスタンスが複数登録されることはないでしょう。

これは、ListViewViewModelのコンストラクタで制御します。
public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher) { ... }
第2引数のconverterは、Modelでのコレクションの要素をViewModelに変換するためのデリゲートです。ここでは、必ず
person => new PersonViewModel(person)

のように新しいインスタンスを作るように実装しておけばいいだけです。

d. スレッド安全性

ListView Extensionsはスレッドセーフに設計されています。
複数スレッドからの同時アクセスなどがあってもデータの整合性は維持できます。ただ、固有のデッドロックの問題等もありますので、十分注意して使ってください。
そのあたりは、beta4のときの記事に詳しく書いてあります。

2. サンプルプログラム

さて、機能の概略は説明しましたが、実際のプログラムは見てみないとわかりにくいところもあるかと思います。ですので、細かい部分はサンプルプログラムを参考にして使ってください。

ListViewExtensionsSample ver.1.0.0

 実はbeta1の時に公開したサンプルプログラムとほとんど変わっていません。ただ、Model側にてスレッド安全性を確かめるための激しいプログラムにしています。
void AddLoop()
{
    Task.Run(async () => {
        while(true) {
            await Task.Delay(TimeSpan.FromSeconds(1));
            
            Parallel.ForEach(Enumerable.Range(0, 200), p => {
                if(p % 2 == 0 && People.Count > 0) {
                    lock(People.SyncRoot)
                        People.RemoveAt(random.Next(People.Count));
                } else
                    Add();
            });                
        }
    });
}

1秒おきに複数スレッドから100回のデータの追加と100回のデータ削除を行っています。もしもスレッド安全性に問題があれば、このコードをしばらく実行していれば不具合が起きるでしょう。

3. 最後に

(少なくとも私としては)結構使いやすいライブラリになったのではないかなと自負しています。WPFのListViewで不便さを感じている皆さん、ぜひご活用ください!

0 件のコメント:

コメントを投稿