ラベル ListView の投稿を表示しています。 すべての投稿を表示
ラベル ListView の投稿を表示しています。 すべての投稿を表示

2024年9月29日日曜日

ListView Extensions ver.1.3.1

ListView Extensions ver.1.3.1をリリースしました。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • ソート対象のプロパティ名を入れ子(プロパティのプロパティの…のプロパティ)にできるようにした。
  • SortableGridViewColumnにSortingMemberPathを追加した。この項目に何も設定していない場合(nullの場合)は今まで通りDisplayMemberBindingsのパスをソートのキーとして扱うが、この項目を設定した場合はこれをキーとしてソートされる。
  • ListViewViewModel (ReadOnlyUIObservableCollectionを継承しているクラス)でIndexerとCountのPropertyChangedイベントが発生しなかった不具合を修正
  • ListViewの子要素にComboBoxなどがあると、その選択が変化したときに例外が発生する不具合を修正

下2つはバグ修正ですので、上2つについて説明していきます。

ソート対象のプロパティ名を入れ子(プロパティのプロパティの…のプロパティ)にできるようにした

今までは、ソートのキーにするプロパティ名は、SortableObservableCollectionの要素のプロパティしかできませんでした。ですので、少々わざとらしいですが、例えば以下のようなクラスがあったとしたら、person.Name.Spellなどのいわゆる「プロパティのプロパティ」はソートキーに指定することができませんでした(こちらのコードはgithubに公開しているサンプルコードの抜粋です)。

public class PersonViewModel : ViewModel
{
    // 中略
    
    public NameViewModel? Name
    {
        get => _Name;
        set => RaisePropertyChangedIfSet(ref _Name, value);
    }
    NameViewModel? _Name;

    public string Age => $"{model.Age}歳";

    public string Birthday => model.Birthday.ToShortDateString();

    public string Height => $"{model.Height_cm}cm";

    // 中略
}

public class NameViewModel : ViewModel
{
    // 中略
    
    public string? Spell
    {
        get => model.Spell;
        set => model.Spell = value;
    }

    public string? Pronunciation
    {
        get => model.Pronunciation;
        set => model.Pronunciation = value;
    }
}

それをできるようにしたという変更です。

これの真価を発揮するのは、ReactivePropertyを使ってViewModelを作ったときです。ReactivePropertyでは、person.Name.Valueなどの形でプロパティにアクセスする必要があるため、必ず2段以上入れ子になります。

実は今までもListVIewViewModelのコンストラクタでプロパティの読み替え用のディクショナリを、SortableObservableCollectionの引数でIComparerのディクショナリを渡すことで無理やり使うことはできなくはなかったのですが、回りくどい方法でソースコードの負荷が高まってしまうためあまりイケている方法ではありませんでした。ですが、今回のアップデートで、DisplayMemberBindingsに表現したとおりのパスを辿るようになったので、ViewModelやModelでは何も書かずに入れ子プロパティを参照できるようになりました。

SortableGridViewColumnにSortingMemberPathを追加した

ListViewのヘッダーはSortableGridViewColumnで作ることができます。ここで、それぞれのヘッダーに対応するソートのキーとするプロパティは、そのままDisplayMemberBindingsのパスを用いていましたが、それを任意のプロパティに設定できるようにしました。

通常は別の名前のプロパティにする必要は無いとは思いますが、例えば、ViewModelとViewでプロパティ名が異なる場合は、実際はソート操作自体はModel(SortableObservableCollection)にて行っているためプロパティ名の読み替えが必要になっていました。前述した通り、ViewModelにはプロパティ名読み替え用にディクショナリを受け取るコンストラクタがありますが、SortingMemberPathで直接設定できるようになりました。

<GridView>
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name.Spell}" Header="Name" />
    <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name.Pronunciation}" Header="Pronunciation" />
    <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" SortingMemberPath="Height_cm" Header="Height" />
    <GridView.ColumnHeaderContainerStyle>
        <Style TargetType="lv:SortableGridViewColumnHeader">
            <Setter Property="SortingArrowLocation" Value="Top" />
        </Style>
    </GridView.ColumnHeaderContainerStyle>
</GridView>

このコードでは、5つ目の「Height」のプロパティ名を「Height_cm」と指定しています。

ちなみにもう一点この機能が必須のところがあって、SortableGridViewColumnにてCellTemplateを指定する場合です。セルの中身をDataTemplateで表現したい場合に使うものですが、DisplayMemberBindingsを設定しているとそちらのほうが優先されてしまいCellTemplateが働きません。その際もこのSortingMemberPathを指定することで、DataTemplateを使いながらソート用のプロパティも指定できるようになりました。

ちなみに、DisplayMemberBindingsはBindingBase型のプロパティですが、SortingMemberPathはString型です。ですので、Visual StudioでIntelliSenseは働きませんのでご注意ください。

--------------

変更点の説明は以上です 。

実際自分でこのライブラリを使い込んでいくといろいろと見つかりますね。まあ、WPFの全貌は10年以上触っていてもよくわからないので、こうやってブラッシュアップしていくしかないですね…。

2024年9月23日月曜日

ListView Extensions ver.1.3.0

ListView Extensions ver.1.3.0をリリースしました。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • 選択項目の同期をListViewSelectedItemsActionからSelectedItemsSync.Source添付プロパティ経由で行うようにした。
  • Obsolete指定していたSortedHeaderを削除

後者は古い機能が削除されただけなので、前者について説明します。

選択項目の同期をListViewSelectedItemsActionからSelectedItemsSync.Source添付プロパティ経由で行うようにした。

ListViewにはSelectedItemsプロパティがあり、複数項目を選択したときはここから選択項目をすべて取得することができますし、このリストをプログラムから操作することで選択項目を変更することができます。しかし、これはViewModelをバインディングできないのです。

というのも、見ての通りこれはget専用プロパティであり、ViewModelのにIListのプロパティを作ってバインディングしようとしてもsetすることができないのです。Mode=OneWayToSourceにしてみてもやはり上手くいきません。BinadbleAttributeが付いているプロパティなのに一体どういうことなんでしょうね。

ということで、ver.1.2.0までのListViewExtensionsではListViewSelectedItemsActionというものを用意していました。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </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>

ただし、これを使うのには癖がありすぎました。先日これを使おうとしたところ自分でもめちゃめちゃハマりましたし、ハマった方も多かったのではないでしょうか。

まず、これは適当なタイミングでViewModelからこのアクションを発動させないと同期しません。

public void Initialize()
{
    model = MainWindowModel.GetInstance();

    People = new ListViewViewModel<PersonViewModel, PersonModel>(model.People, person => new PersonViewModel(person), new Dictionary<string, string>() { { nameof(PersonModel.Height_cm), nameof(PersonViewModel.Height) } }, DispatcherHelper.UIDispatcher);
    Messenger.Raise(new InteractionMessage("SelectedItemsMirroring"));
}

忘れずにそのコードを入れたとして、このアクションを発動させるタイミングもとても重要です。このアクションはListViewのSelectedItemsをListViewSelectedItemsAction.Sourceにコピーする操作をするので、ListViewがインスタンス化されているタイミングでなければなりません。Loadedイベントなどで発動するようにしてもその前なので上手くいかないようです。Window.ContentRenderedイベントに合わせて使えば上手くいきますが、例えばUserControl内での使用などではこのイベントが使えないので一苦労します。

こんな癖つよシステムは使っていられないとのことで、試行錯誤のすえ、今回のバージョンでは以下のような形になりました。

<ListView ItemsSource="{Binding People}" lv:SelectedItemsSync.Source="{Binding People.SelectedItemsSetter}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </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>
</ListView>

はい、超シンプルです。ListViewに添付プロパティでSelectedItemsSetterを登録するだけです。わかりやすいし、今までのようにViewModelで特別な処理を入れる必要もありませんし、タイミングを選ぶなどといったこともありません。

中身的にはWPFシステムのバインディングではなく、独自のバインディングシステムを使っています。すなわち、ListView.SelectedItemsとPeople.SelectedItemsSetterは別インスタンスで、裏で中身を同期する仕組みを作って動かしています。そのため、今までのSelectedItemsSetterとはプロパティの形態が変わり、以前バージョンとの互換性はなくなっています。

それ以外の使い方は今までのバージョンと合わせています。

2024年9月3日火曜日

ListView Extensions ver.1.2.0

ListView Extensions ver.1.2.0をリリースしました。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • IReadOnlySortableObservableCollectionインターフェースとReadOnlySortableObservableCollectionクラスを追加
    • ISortableObservableCollectionインターフェースはIReadOnlySortableObservableCollectionインターフェースを継承するようにした
    • ListViewViewModelのコンストラクタに与えるソースコレクションをIReadOnlySortableObservableCollectionにした
  • ListViewViewModelに単一の型引数を取るオーバーロードを追加

主な変更点を説明していきます。

IReadOnlySortableObservableCollectionインターフェースとReadOnlySortableObservableCollectionクラスを追加

もともとSortableObservableCollectionはMVVMのModelで使うことを想定していますが、今まではReadOnlyがありませんでした。MVVMパターンでは、Modelでは読み取り専用のコレクションを公開し、書き換えは別のメソッドなどを介してやることが多いので、今までのReadOnlyが無い環境では少し不便でした。

そこで、読み取り専用のソート可能ObservableCollectionとしてReadOnlySortableObservableCollectionを追加しました。ReadOnlyですがソートはできるので、SortやMoveなどのメソッドも動きます。そうした場合、ソースコレクションに対してSort / Moveを行うという形になり、結果的にソースコレクションに影響を与えることができます。ReadOnlyなのにそれはどうなのかと少し思いましたが、まあ、Sortableと言ってるから割り切ってくれということで。

これに伴って、ISortableObservableCollectionインターフェイスはIReadOnlySortableObservableCollectionを継承する形にしました。この辺で破壊的変更をしているのでバージョンの2桁目を上げています。 

また、ListViewViewModelもIReadOnlySortableObservableCollectionを受け取るようにしています。SortableObservableCollectionもIReadOnlySortableObservableCollectionを実装していますので、今までと使い勝手は変わりないでしょう。ただし、ListVIewViewModelのRemoveSelectedItemCommandはReadOnlyだと失敗します(InvalidOperationExceptionを吐きます)。

ListViewViewModelに単一の型引数を取るオーバーロードを追加

ListViewViewModelは、要素の型をModelとViewModelで変換できるよう2つの型引数を取るものしか今までありませんでしたが、変換が不要な際は記述が冗長だったので、1つの型引数を取るオーバーロードを追加しています。

ただし、ListViewViewModelは同じ参照の要素を2つ以上設定すると例外を吐きます。これは、同じ参照の要素が2つ以上あるとSelectedItemsでどちらが選択された項目か区別がつかないからです。そういうことになりうる場合は、少し面倒ですが、ラッピングするViewModel型を作ってnewするようにしてください。あくまでもお作法です。

***

以上です。最近投稿頻度が上がっている気がする…。

2024年8月31日土曜日

ListView Extensions ver.1.1.0

唐突ですがListView Extensions ver.1.1.0をリリースしました。実に前回の更新から6年ぶりです。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • Githubでソースコードを公開
  • ライセンスをMITライセンスに変更
  • ターゲットを.NET Framework 4.5.2 / .NET Core 3.1 / .NET 6に変更
  • ListViewのヘッダーサポートを強化
    • SortableGridViewColumnHeader、SortableGridViewColumnを追加
    • SortedHeaderをObsolete指定にした
  • ISortableObservableCollectionのSortメソッドの引数を変更、古いメソッドはObsolete指定にした
  • コードのリファクタリング、Nullableの有効化、単体テストの追加など

主な変更点を説明していきます。

Githubで公開 / MITライセンス化

最近久しぶりにこのListView Extensionsをいじろうとしたとき、そういえばはコードを公開していなかったなと思ってGithubで公開することにしました。併せてライセンスもMITに変更しています(今までは明記なし)。すっかり私もオープンソースの人(?)になってきました。

ターゲットを.NET Framework 4.5.2 / .NET Core 3.1 / .NET 6に変更

直前のver.1.0.1 では.NET Framework 4.5のみでした。これが.NET系統にも対応するようにしました。実はこれが今回のアップデートの大きなモチベーションだったりします。

ListViewのヘッダーサポートを強化

ListViewのヘッダーを簡単に作成

今までのListView Extensionsではヘッダーの記述がとても冗長でした。

<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>

これはサンプルコードの抜粋ですが、ListViewで1列作るのに5行もコードが必要でした。そして似たような記述も繰り返し行われとても冗長です。XAMLの構造、というよりもどういう仕組みになっているかはこの状態でわかりやすいんですがね。

これを、今回のバージョンでは以下のように書き換えることができるようになりました。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

だいぶすっきりしました。

このSortableGridViewColumnはGridViewColumnを継承して作られたものですが、少しトリッキーです。上のコードは下のコードとほぼ同等です。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <lv:SortableGridViewColumnHeader Content="Name" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name"/>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <lv:SortableGridViewColumnHeader Content="Pronunciation" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation"/>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <lv:SortableGridViewColumnHeader Content="Age" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age"/>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <lv:SortableGridViewColumnHeader Content="Birthday" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday"/>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <lv:SortableGridViewColumnHeader Content="Height" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height"/>
            </GridViewColumn>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

SortableGridViewColumnHeaderはGridViewColumnHeaderを継承して作られたもので、GridViewColumnHeaderに並び替えのアイコン「▲」「▼」を表示する機能を追加したものです。SortingDirectionプロパティに並び替えの向きを指定することでアイコンが表示されます。

SortableGridViewColumnは、HeaderプロパティがSortableGridViewColumnHeaderではない場合に自動的にSortableGridViewColumnHeaderを作ってそのインスタンスにHeaderプロパティに入っていたものを渡しますます。そして、そのSortableGridViewColumnHeaderのSortingDirectionプロパティ、Command / CommandParameterプロパティにSortableSourceプロパティにバインディングされたオブジェクトのプロパティを自動的にバインディングします。ソートのキーにはDisplayMemberBindingの値を使うので、万が一DisplayMemberBindingの値と異なるプロパティをソートのキーにする必要がある場合はGridViewColumnHeaderを直接触る必要があります。

これらの機能が追加されたことによって、SortedHeaderはObsolete扱いとなりました。

ListViewのヘッダーをカスタマイズ① - 並び替え矢印の位置を変更

SortableGridViewColumnはSortingArrowLocationプロパティを持っています。これはDock列挙型になっているので、上下左右好きな場所に配置することができます。直接プロパティを触っても良いのですが、GridView.ColumnHeaderContainerStyleプロパティによる一括スタイル指定をするのが良いでしょう。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

↓Top

Top

↓Right

Right

画像ではTopとRightの例を出していますが、LeftとBottomを指定することもできます。あまり馴染みのないデザインかとは思いますが。

あとはSortingArrowMarginプロパティを使えばArrowの周囲のMarginを設定することができるので、「Name」などのヘッダーテキストの間隔を調整したいときに使えます。

ListViewのヘッダーをカスタマイズ② - 並び替え矢印をカスタマイズ

ListViewのヘッダーの矢印は専用のクラス「AscendingArrow」「DescendingArrow」で表現されています。ですので、このクラスのTemplateを丸々置き換えてしまうことで矢印の見た目をカスタマイズすることができます。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <Style TargetType="lv:AscendingArrow">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Polyline Points="0,5 5,0,10,5" StrokeThickness="1" Stroke="Black" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style TargetType="lv:DescendingArrow">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Polyline Points="0,0 5,5,10,0" StrokeThickness="1" Stroke="Black" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

このように全く違う見た目になりました。これで、仮に私のデザインした▲が気に入らない人がいたとしても、クレームを受けることなく「自分で好きに変えてください」と言えるようになりました。こういった表現力の豊かさがさすがWPFという感じですね。

ISortableObservableCollectionのSortメソッドの引数を変更

ISortableObservableCollectionのSortメソッドの形が少し変わっています。破壊的変更です。

public interface ISortableObservableCollection<T> : IList<T>, INotifyPropertyChanged, INotifyCollectionChanged
{
    // 中略

    [Obsolete]
    void Sort(string propertyName, SortingDirection direction);

    /// <summary>
    /// 自身をソートするメソッド
    /// </summary>
    /// <param name="direction">ソート方向。Noneの場合は何もしない。</param>
    /// <param name="propertyName">ソートに使用するプロパティ名。Nullの場合は要素自身をキーとしてソート。</param>
    void Sort(SortingDirection direction, string? propertyName = null);
}

まあ、直接SortableObservableCollectionからソートを手動でかけていない限り影響は無いとは思いますが、注意してください。Obsoleteの警告が出てもパラメーターの順序を入れ替えるだけでOKです。

この変更は、propertyNameをnullでも受け取れるようにしたところにあります。nullにすると要素自身をキーにして並び替えることができます。SortableObservableCollection<int>などの場合を想定しています。

***

以上で今回の変更分の説明は終わりです。あまり使用面で影響のない修正は割愛させてもらいます。

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で不便さを感じている皆さん、ぜひご活用ください!

2017年11月13日月曜日

ListView Extensions ver.1.0.0-beta4

beta2に引き続いてbeta4をリリースしました。
えっ?beta3はどこに行ったって?beta2リリース直後にバグを見つけてこっそり差し替えていました。テヘペロッ(๑´ڡ`๑)

ListView Extensions - Nuget

バグフィックスのbeta2,3に比べたら今回はかなり大きなアプデです。バージョン変えようかと思いましたが、1.0.0-beta*という名前である以上、1.0.0をリリースしない限りバージョン変えられないなと思い、結局このままになっています…。
これで安定していたら今度こそ正式リリースするぞ!

今回の変更点

さて、今回のアプデ内容はただ一つにして大きい内容です。コレクションのスレッドセーフ化です。
シングルスレッドが許されるのは小学生までだよねー。

名前は「SyncedSortableObsevableCollection」にしようかと思いましたが、いまどきスレッドセーフじゃないCollectionなんてWPFで使う上では用途が無いと判断し、名前は「SortableObservableCollection」のままとしました。インターフェースは互換性を維持していますので、そのままコンパイルは通るはずです。
一応今までの実装は残していて、同じ名前空間にUnsyncedSortableObservableCollectionとしてObsolate属性を付けています。問題がある方はこっちを使ってください。

なお、スレッドセーフ化する過程で継承構造をより素直な形にしました。以前のバージョンは「SortableCollection」というクラスを継承していましたが、こちらもObsolateになっており、基底クラスは「SyncedObservableCollection」にしております。

SyncedObservableCollection

これは私がフルで実装したクラスで、スレッドセーフな変更通知可能なコレクションです。ReaderWriterLockSlimを使って効率的なロックを行っております。要素技術としては詳しくは前回の記事を見て下さい。

なお、このクラスは非ジェネリックのICollectionインターフェースを継承しているため、IsSynchronizedプロパティSyncRootプロパティを実装しています。コレクションを外部から操作するときに複数回のメソッド呼び出しにわたってlockが必要な場合はこのSyncRootを使ってlockを行ってください。

例えば次のようなケースです。

SortableObservableCollection<Person> People = new SortableObservableCollection<Person>();

//中略

lock(People.SyncRoot)
    People.RemoveAt(People.Count - 1);    //Remove last element

最後の要素を削除する処理ですが、これは
  1. コレクションの長さを取得
  2. コレクションの長さ-1の要素を削除
の2回のコレクションへのアクセスを行っています。
この1.と2.の間に他のスレッドからの書き込みアクセスがあって要素数が増減した場合、「コレクションの長さ-1」が最後の要素のインデックスではなくなってしまいます。これは問題です。

そこで、対策としてSyncRootを使ったlockを行っております。これによって他のスレッドからの書き込みアクセスを阻止しています。
これこそ「ReaderWriterLockSlimを使うべきじゃないの?」と思うかもしれませんが、このようなケースは書き込みアクセスを伴うことが圧倒的に多く、書き込みアクセスをする場合はReaderWriterLockもlock構文も大差がありません。ですので、既存の枠組みで簡単に実現できるこの方法を使うことにしました。
もちろん、コレクションの外でlock構文を使って書き込みをしている間も、中でReaderWriterLockSlimを併用していますので、書き込みのタイミング以外(上記で言えばPeople.Count読み取り時など)は他のスレッドからの読み込みアクセスは許可されます。

ちなみに、本家のObservableCollectionはAddRange/InsertRange/RemoveRangeといった複数の項目を一気に操作する機能は付いていません。しかし、INotifyCollectionChangedインターフェースの機能としてはNotifyCollectionChangedEventArgsクラスを見てもわかりますが複数項目のコレクション操作機能にも対応しています。
なので、このSyncedObservableCollectionではそれらの機能にも対応しています。ループでAddメソッドを複数回呼び出すとその呼び出しごとにイベントが発生しますが、AddRangeだと1回で済むので効率が格段と高まります。

ListViewViewModel

ListViewViewModelは今までもModelをUI以外のスレッドから操作した場合に発生した変更通知をUIのDispatcher経由でViewに反映させる機能を持っていました。ただし、肝心のスレッドセーフ設計は(面倒なので)やっていませんでした。

今回は、このSyncedObservableCollectionを活用してスレッドセーフに設計しています。今までの実装はSortableObservableCollectionと同じくUnsyncedListViewViewModeに移したうえでObsolate属性を付けています。

デッドロックに注意

ここで、ListViewViewModelを使う上での注意を紹介します(私もハマってしばらく原因探しに苦戦していました)。

ListViewViewModelはViewに設置されたボタンなどの入力を受け付けてModelに渡すためのコマンドをいくつか持っています。
例えばRemoveCommandはListViewのうち選択されている項目を削除するコマンドです。Viewのボタン押下を受けてCommandが発火し、Model(SortableObservableCollection)のRemoveメソッドを呼び出します。

気を付けないとここでデッドロックが発生しうるのです。




UIスレッドからのSortableObservableCollectionへのアクセスの直前にその他のスレッドから同じくSortableObservableCollectionに書き込みアクセスがあったとします。
そうすると、先に書き込みアクセスを始めたほうがLockを保持するのでUIスレッドはそのLockが解放されるまで待機します。

しかし、その他のスレッドは変更後に変更通知イベントを発生させ、UIのスレッドでUIの更新作業をしようとします。このときは、すでにUIスレッドはSortableObservableCollectionのLockが解放されるのを待っているのでUIスレッドでのInvokeが行えずデッドロックが発生してしまいます。

AとBの排他制御機構があって、AをLockしたうえでBをLockしようとするスレッドと、BをLockしたうえでAをLockしようとするスレッドがあるとデッドロックが発生する、というのはマルチスレッドプログラミングの基本中の基本です。
今回はUIスレッドとSortableObservableCollectionがそれぞれの排他制御機構となっています。


さてさて、これに対抗する手段もListViewViewModelには追加しております。


UIスレッドが直接SortableObservableCollectionへアクセスしなければよいのです。
UIスレッドからSortableObservableCollectionへのアクセスを行いたいときは別のスレッドに書き込みアクセスを依頼したうえですぐに処理をUIスレッドに戻すようにします。
そうすることで、先にその他のスレッドがSortableObservableCollectionのLockを保持していたとしてもUIスレッドはアイドル状態になっており、その他のスレッドからのUI更新作業もブロックされることはありません。この機構によってデッドロックが回避されるわけです。

ただ、重い作業を並列実行するわけでもないのに別スレッドを起動するので単純に処理が重い上、UI起点の処理をやっているのにその処理が終わる前にUIがいじれるようになるので不都合が起きることもあります。

そこで、これはオプションとし、デフォルトではOFFにしています。
別スレッドからの書き込みとUIスレッドからの書き込みが衝突する可能性がある場合は、ListViewViewModelにてUseNonUIThreadWhenCallingModelプロパティをtrueにしてください。
また、DisableCommandWhenCommandTaskRunningを使うことによってCommand起点のタスクが別スレッドで実行されているときにCommandを無効化することができます。Command起点のタスクが実行中かどうかはCommandTaskRunningプロパティでわかります。


なお、他の注意点として、変更通知イベントを受けたUI更新作業のときに、UIスレッドから直接SortableObservableCollectionに書き込みアクセスをしようとするとやはりデッドロックが起こりますので注意してください。この場合は確実に起きます。
ただ、UI更新作業中にSortableObservableCollectionを書き込むなんておかしな話です。なんでUIの更新作業の名目でデータの変更作業までしてんねんって話です。そんなデッドロックを起こすようなコードを書く人のことは知りません。

ListViewViewModelは同一インスタンスの要素を複数持ってはいけない

これは実は前からある制約です。
ですが、自分自身ですら忘れていたので書いておきます。

SortableObservableCollectionは問題ないのですが、ListViewViewModelは同一インスタンスの要素を複数持てません。
もしも同一インスタンスを追加しようとすると例外(ArgumentException)が発生します。ListViewViewModelのTViewModelが構造体やプリミティブ型の場合は、同値だった場合に例外が発生してしまいます。

なぜこんな仕様になっているかというと、ひとえにSelectedItemsのせいです。

WPFのListViewは複数のアイテムを選択している場合はSelectedItemsでしかその選択されたアイテムをすべて取得することができません。インデックスではなく選択されているアイテムのViewModelインスタンスのコレクションとなります。
この際、複数の同じインスタンスがあった場合、どちらのアイテムが選択されているのか求めることができません。ですので、このような不便な制約があるわけです。


しかし、MVVMモデルをなぞって実装しているのならば、ListViewの各行のアイテムとしてバインディングするのは、通常はSortableObservableCollectionの要素の型に対応するViewModelになるはずです。 なので、SortableObservableCollectionが複数の同一インスタンスを持っていたとしてもViewModelの要素としては別個のインスタンスを作ればいいだけなのです。

これはどのように実現するかというと、ListViewViewModelのコンストラクタで指定します。

public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher);
public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher, DispatcherPriority priority);

ListViewViewModelは2つのコンストラクタを持っています。ですが、Dispatcherの優先度を指定するかデフォルト値(Normal)を採用するかの違いだけで、それ以外は同じです。

sourceはModelのコレクションとなります。SortableObservableCollection<T>を指定する場合が圧倒的に多いでしょう。

問題は2番目の引数です。
このconverterがSortableObservableCollectionで要素が追加されたときに、ViewModelの要素を追加するために呼ばれるデリゲートになります。
ModelをViewModelに変換(ラッピング)するわけですから、ここで

model => new ElementViewModel(model)

のようなラムダ式を渡してやれば十分でしょう。
これでどのようなインスタンスが追加されたときも全自動で新しいインスタンスが生成されていくのでListViewViewModelに同一インスタンスが複数追加されることはありません。

めんどくさがって

model => model

にした場合は例外が飛ぶことがあるので注意してください。


ちなみに、要素がRemoveされたり、別の要素で書き換えられたときは、ElementViewModelがIDisposableを継承していればちゃんとDispose()を呼びますのでご安心を。



さーて、次回こそ正式リリースをするぞ!!!

2017年10月24日火曜日

ListView Extensions ver.1.0.0-beta2

最近久しぶりにWPFを触ったのですが、その中でListViewを使う機会がありました。

身近なListViewと言えばWindowsのエクスプローラーですが、例えばこれはヘッダーをクリックすることで思い思いの要素でファイルのソートができるのに対して、WPFのListViewにはそのような基本機能が備えられていません。
とは言っても、備えるのもかなりの根気がいる作業ですし、そもそも元のコレクションに介入する作業はPresentation Foundationと名の付くフレームワークがやるべきことではない気がします。

そこで、ListView Extensionsです。

MVVMスタイルの形をしながら、ListViewでの基本機能が多数取り揃えられているライブラリです。
例えば、ListViewのソート、選択項目に対する移動、削除などです。
このような機能を実現するにあたって、View⇔ViewModel⇔Modelのすべての領域にわたって素晴らしいクラスが提供されています。

完全に自画自賛です。


ところで、バグがありました。

ListViewViewModelでClear()を呼び出したとき、より正確にはRemoveなどで最後の1項目を削除したとき、InvalidOperationExceptionが発生することがあるバグがありました。

MoveUpSelectedItemCommand/MoveDownSelectedItemCommandの実装にて、このコマンドが有効になる条件として「選択されているアイテムの個数が1以上、かつリストの先頭/最後尾のアイテムが選択されていない」というロジックを組んでいました。
しかし、 最後の1つの項目を削除したときにSelectedItemsの反映がリストのコレクションの反映より遅くなることがあるようで、その場合、「選択されているアイテムが1個あるがリストのコレクションのアイテムは0個」という状況が起こってしまうようです。その時に、1つ目の条件をすり抜けて2つ目の条件の判定に入った際、リストの先頭または最後尾のアイテムを取得しようとしてInvalidOperationExceptionがスローされているようでした。

このバグは、「リストのコレクションの数が1個以上」という条件を追加することによって回避しました。


あと、自分で作っておいて少しはまったところですが、このライブラリ、UI以外のスレッドからアイテムの操作をしてもUIに正常に反映される仕組み(Dispatcher経由でのアクセス)に対応しているにもかかわらず、スレッドセーフに作られていません。
気が向いたらSorableSynchronizedObservableCollectionなどを作るかもしれませんが、それまでは皆さん自分でlockなどをして使ってくださいね…。

とりあえず、例の最後の1個を消したら例外を吐くバグを直したものをbeta2としてうpしておきました。
ListViewExtensions 1.0.0-beta2
細かな使い方はbeta1をリリースしたときの記事を確認してください。

いつになったらプレリリース外そうかな…。 

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

2015年12月8日火曜日

ListViewItemのイベントをMVVMスタイルのプログラムで受信する - 添付プロパティ編

さて、以前にReactive Propertyを使って苦し紛れにListViewItemのイベントを受信する方法を紹介しました。しかし、その方法は本当にかなり苦し紛れで、それらしいスタイルは曲がりなりにも美しいとは言えませんでした。

しかし、今回は別の方法でついにそれっぽい書き方をすることに成功しました。 その方法はずばり、添付プロパティを使う方法です。
WPFには添付プロパティという機構があり、本来そのコントロールが持っているプロパティではない任意のプロパティを作成し、コントロールに添付することができます。Grid.Rowなどのプロパティがその一つで、例えばTextBlockは当然Grid以外にも配置されますからGridの何行目何列目に位置するなんていうプロパティは持っていませんが、添付プロパティを使うことでそういう情報を付加できるようになるものです。

というわけで、添付プロパティで適当なコマンドをListViewItemに添付してしまえば、そこで上手くやることでそのコマンドを発火させることができるのではないかというアプローチになります。

public static class DoubleClickBehavior
{
    public static readonly DependencyProperty CommandProperty = DependencyProperty.RegisterAttached(
        Regex.Replace(nameof(CommandProperty), "Property$", ""),    //末尾のPropertyを消す
        typeof(ICommand),
        typeof(DoubleClickBehavior),
        new FrameworkPropertyMetadata(null, (sender, e)=> {
            Control ctrl = sender as Control;

            if(ctrl != null) {
                ICommand oldCommand = (ICommand)e.OldValue;
                ICommand newCommand = (ICommand)e.NewValue;

                if((oldCommand != null) && (newCommand == null))    //購読を停止
                    ctrl.MouseDoubleClick -= Control_MouseDoubleClick_ForCommand;
                if((oldCommand == null) && (newCommand != null))    //購読を開始
                    ctrl.MouseDoubleClick += Control_MouseDoubleClick_ForCommand; ;
            }
        }));

    public static void SetCommand(DependencyObject obj, ICommand value)
    {
        obj.SetValue(CommandProperty, value);
    }

    public static ICommand GetCommand(DependencyObject obj)
    {
        return (ICommand)obj.GetValue(CommandProperty);
    }

    private static void Control_MouseDoubleClick_ForCommand(object sender, MouseButtonEventArgs e)
    {
        ICommand com = GetCommand((Control)sender);

        if(com.CanExecute(e))
            com.Execute(e);
    }
}

こんな感じです。添付プロパティはDependencyProperty.RegisterAttached()メソッドを使って作った****Propertyという名前のプロパティで、セッターやゲッターはGet****()、Set****()という名前で作るというお作法になっています。そしていずれもstaticです。

ここでミソなのは、添付プロパティのコンストラクタに与えるFrameworkPropertyMetadataクラスは、コンストラクタに値の変更時にその通知を受け取るハンドラを渡すオーバーロードがあるということです。もちろんその通知のsenderは添付プロパティが取り付けられたコントロールになるので、それに対してイベントの購読を行えば、イベントを拾って添付プロパティに投げることが可能になります。

XAMLではこんな感じになります。

<ListView ItemsSource="{Binding Items}" >
    <ListView.View>
        <GridView>
            <!-- 中略 -->
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="b:DoubleClickBehavior.Command" Value="{Binding DoubleClickCommand}" />
        </Style>
    </ListView.ItemContainerStyle>
</ListView>

いたって普通にItemsContainerStyleでプロパティを設定しています。しかしこれが添付プロパティで、上記の通りこれに値をバインディングすることでイベントを購読できるようになります。

それにしてもこのアイディアを知ったときは目から鱗だったなあ。

2015年11月18日水曜日

ListViewItemのイベントをMVVMスタイルのプログラムで受信する

WPFでListViewを使うときというのは、たいてい、複数のプロパティを持つようなデータが複数あるときですよね。それゆえに、たいていItemsSourceに各々のデータに対応するViewModelをバインディングして、ListView.ViewにDisplayMemberBindingなどを設定したGridViewColumnを設定したGridViewを設定して使うことになると思います。
内部的には、そのデータに対応するViewはListViewItemになっていて、ItemsSourceで指定したVMがそのDataContextに指定されています。そして、ListViewItemのプロパティならば、ListView.ItemContainerStyleを使ってスタイルを指定することでいじることができます。例えばこんな感じです。

<ListView ItemsSource="{Binding Data}" >
    <!-- 中略 -->
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem" >
            <Setter Property="ContextMenu" >
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Open" Command="{Binding OpenCommand}" />
                        <MenuItem Header="Close" Command="{Binding CloseCommand}" />
                    </ContextMenu>
                </Setter.Value>                            
            </Setter>
        </Style>
    </ListView.ItemContainerStyle>
</ListView>

このXAMLは、リストビューで表示している各項目に対してコンテキストメニューを表示させるようにしています。このスタイルはListViewItemに対して適用されているので、メニューにバインディングしたコマンドなど(上記の例ではOpenCommandやCloseCommand)は各アイテムのViewModelにバインドされます(上記の例ではData)。非常に素直な設計だと思います。データに対する操作を各データのVMが受け取るわけですからね。
このほかにもListView.ContextMenuのほうにコンテキストメニューを登録し、SelectedItemなどをうまいこと使ってデータ処理をすることもできますが、この場合はヘッダーを右クリックしてもメニューが出てきてしまう点に問題があります(ListViewコントロール全域に対してコンテストメニューが登録されているわけですからね)。その観点からも、やはり上記のような実装が素直だと言えます。

さて、ここまでは非常に王道なのですが、ListViewItemが発行するイベントを受信しようとすると、とたんに問題が難しくなります。
前述の通り、ListViewItemは直接いじれないので、スタイルを介してプロパティ等をいじることになります。そして、プロパティと同様にEventSetterというセッターがあり、これを通すことでイベントの受信も可能になります。
しかし、EventSetterは、そのイベントをコードビハインドに用意したイベントハンドラに飛ばします。コードビハインドで受信をしてしまうと、それをViewModelにきれいに飛ばすのがなかなか難しくなります。コードビハインドでDataContextを適当なViewModel型にキャストしメソッドを呼び出せば確かにViewからViewModelにイベントの発生を伝えることができますが、XAMLで表現したバインディング以外にデータの流れる経路ができてしまうことには非常に抵抗感があります。
もちろんですが、スタイルにはi:Interaction.TriggersにEventTriggerを入れて使う、なんてこともできません。それができれば何も苦労はしないんですがねえ。

というわけで、今回はいろいろ試行錯誤した結果、いかにListViewItemで発生したイベントを受信し、それをViewModelに伝えるかということを綴っていきたいと思います。

結論から言いますが、ReactivePropertyを使いました。別にReactivePropertyがこれ用の機能を持っているというわけでもないんですが、一番使いやすかったので使いました。まずは、XAMLのほうから紹介します。

<ListView ItemsSource="{Binding Data}" >
    <ListView.View>
        <GridView>
            <!-- 中略 -->
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem" >
            <!-- 中略 -->
        </Style>
    </ListView.ItemContainerStyle>
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="MouseDoubleClick">
            <rp:EventToReactiveProperty ReactiveProperty="{Binding ListViewDoubleClicked}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</ListView>

ListViewのほうにEventTriggerを指定し、ダブルクリックのイベントを受信しています。EventTriggerは多くのMVVMスタイルのXAMLで見かけるものなので特段説明はいらないかと思います。この時点では、たとえばヘッダー領域などを含んだ、ListView全体のダブルクリックを拾ってしまう点に注意する必要があります。
さて、EventTriggerで指定したEventToReactivePropertyですが、これがReactivePropertyでサポートしている機能の1つで、イベントを直接ViewModelのReactiveProperty(IObservable<T>とINotifyPropertyChangedを実装したプロパティ)に飛ばすことができます。飛んでくるデータソースはRoutedEventArgs(もちろんイベントによってはそれを継承したEventArgsだったりする)で、ViewModelにEventArgsの処理を書くのは行儀が悪いので、作者の方のブログではReactiveConverter<T, U>を継承したクラスを作り、イベントをより実用的な型に変換しておりました。
しかし、どうもReactiveConverter<T, U>を挟むと、イベントの送り主がAssosiateObject (この場合はListView)になってしまうようです。挟まなかった場合、ListViewItemのさらにいくつか子要素(TextBlockだったり、Borderだったりします)が送り主になるようです。ListViewItemの子要素ならば、DataContextはListViewItemのものになるはずですから、これをいいことに、DataContextに関連付けられているViewModelにダブルクリックのイベントを伝えることにしました。

なお、上記のXAMLではちゃんと{Binding ListViewDoubleClicked}という形でイベントの受け取り先のReactivePropertyがバインディングされており、コードビハインドとViewModelの間につながりを持たせるということは行わずに済んでいます。

public MainWindowViewModel()
{
    ListViewDoubleClicked
        .Select(p => new { EventArgs = p, ViewModel = (p?.Source as System.Windows.FrameworkElement)?.DataContext as DataElementViewModel })
        .Where(p => p.ViewModel != null)
        .Do(p => p.EventArgs.Handled = true)    //HandledをtrueにするためにDoを挟む
        .Select(p => p.ViewModel)
        .Subscribe(p => p.DoubleClicked());
}

public ReactiveProperty<System.Windows.Input.MouseButtonEventArgs> ListViewDoubleClicked { get; } = new ReactiveProperty<System.Windows.Input.MouseButtonEventArgs>();

つづいてViewModelの一部です。上記の通り、素のままではListViewが発行したすべてのダブルクリックイベントを受信してしまうので、Reactive Extensionsを使ってそれをフィルタリングしています。Reactive ExtensionsはLINQを使って時間的に流れてくるデータをフィルタリングや変換できる、とても強力なライブラリです。
まずはSelectで受信したRoutedEventArgsと、そのViewModelを合成した匿名クラスを作っています。null条件演算子をふんだんに使い、指定のViewModelの型にできなかったときはnullになるように作っています。そして、次にWhereでそのnullになったものを弾き、生き残ったものに対してDoを挟んでEventArgsのHandledをtrueにする作業をしています。最後にViewModelをSelectし、SubscribeでViewModelにダブルクリックが行われたときに呼ぶべきメソッドを呼ぶようにしています。

ViewModelでWPFのRoutedEventの処理をやっている時点で気持ち悪いって言えば気持ち悪いですが、うーん、コードビハインドからViewModelのメソッドを呼ぶのと比べてどっちが気持ち悪いんだろうな…。