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年9月1日日曜日

Tategakiのダウンロード数

ふとNugetのTategakiのダウンロード数を確認したところ、次の画像のようになっていました。

過去6週間のダウンロード数ということで、最新のver.3.2.2が出てからすでに6週間以上たっていますが、いまだにver.2.1.1のダウンロード数が最も多いようです。

確かにver.2.1.1からver.3.0.0ではメジャーバージョンアップに相応しいほどガラリと変えていますので、敢えて古いバージョンを使いたいという人もいるのかもしれませんが、実際はどうなんでしょうね。何か知っている人いたら教えてください。