ReactiveCollection 世代の ListBox 達【step: 9 .NET 5 WPF MVVM ReactiveCollection 入門 2020】

前回記事「Model のインタフェースの上に ReactiveCommand は立っている【step: 8 .NET Core WPF MVVM ReactiveCommand 入門 2020】」

 

step: 8 から大分間が空いてしまいましたが、前回は ReactiveProperty に含まれる ReactiveCommand と AsyncReactiveCommand の基本的な使用方法、Command から呼び出す Model のインタフェースについて紹介したので、今回はリスト系コントロールを ReactiveCollection と MVVM パターンでバインドしてデータを一覧形式で表示する方法を紹介します。

尚、ReactiveProperty については step: 7【ReactiveProperty を編む】で詳しく紹介しています。

ReactiveCommand については step: 8【Model のインタフェースの上に ReactiveCommand は立っている】で詳しく紹介しています。

尚、この記事は Visual Studio 2019 Community Edition で .NET5 以降 と C# + Prism + ReactiveProperty + MahApps.Metro + Material Design In XAML Toolkit + AutoMapper を使用して、WPF アプリケーションを MVVM パターンで作成するのが目的なので、C# の文法や基本的なコーディング知識を持っている人が対象です。

リスト系コントロール項目の仮想化

前回までは TextBox のような単一値を表示するコントロールしか紹介していませんでしたが、今回は複数のデータを一覧で表示するリスト系コントロールについて紹介します

リスト系コントロールとは以下のようなコントロールを指します。

  • ListBox
  • ComboBox
  • TreeView
  • ListView
  • DataGrid

上で挙げたコントロールはどれも使用機会は多いと思いますが、WPF の場合、上に挙げたコントロールは全て ItemsControl を継承しているので、多少の違いはあれど 1 つのコントロールの使い方を覚えれば他のコントロールにも応用できます。

Windows Form でも ListBox や ComboBox は ListControl を継承していましたが、TreeView や ListView は継承元が違うため項目追加の方法等は統一されていませんでしたし、Windows Form ではコントロールのプロパティやメソッドを直接呼び出す実装が一般的なので『似てはいるけど別物』的な感覚も強かったと思います。

WPF(特に MVVM)のリスト系コントロールへ項目を追加・削除する方法はデータバインディングで抽象化されているため、どのリスト系コントロールを使用しても実装内容はほとんど同じです。本エントリでは ListBox を例にしてリスト系コントロールを MVVM パターンで取り扱う方法を紹介します。

リスト系コントロールの項目を仮想化する場合の設定

項目追加の前に、まずはリスト系コントロールを使用する場合に必要になる事も多い【項目の仮想化】について紹介します項目の仮想化も ItemsControl に実装されているので、上で紹介した 5 種類のリスト系コントロール全てで同じように適用できます。

そして、ItemsControl の【項目の仮想化(VirtualizingPanel.IsVirtualizing)】はデフォルトで有効(true)なので、本来、仮想化を無効にするときだけ XAML へ指定すれば良いと思うかもしれませんが、実は仮想化に関係する項目はデフォルト値から変更したいプロパティも多いので、管理人の場合、VirtualizingPanel.IsVirtualizing は常に XAML へ書くようにしています。

src. 1 は Prism の部分 View(UserControl)に ListBox を置いた場合の XAML です。

【項目の仮想化】は src. 1 の 11 ~ 13 行目のように VirtualizingPanel 添付プロパティに値を設定します。

以下は VirtualizingPanel 添付プロパティで設定することの多いプロパティです。

プロパティ名内容
VirtualizingPanel.IsVirtualizingtrue を設定すると【項目の仮想化】が有効になります。
デフォルト値は true なので ListBox 等、ItemsControl を継承したリスト系コントロールはデフォルトで【項目の仮想化】が有効になっています。
VirtualizingPanel.VirtualizationMode仮想化した項目の表示方式を以下から選択します。

  • Standard
    • VirtualizationMode のデフォルト値。必要になったら作成して不要になったら(スクロールで項目が表示されなくなった場合等)破棄します。
  • Recycling
    • 毎回新しい項目を作成せず、作成した項目を再利用します。こちらの方がパフォーマンスが優れています。
VirtualizingPanel.ScrollUnitスクロールの単位を以下から選択します。

  • Item
    • ScrollUnit のデフォルト値。
      項目単位でスクロールします。
  • Pixel
    • ピクセル単位でスクロールします。

上記の項目を設定していると以下のようなエラーが表示される場合があります。

エラー XDG0066 Measure が ItemsHost パネルで呼び出された後に ItemsControl の VirtualizationMode 添付プロパティを変更することはできません。

上記エラーは表示される場合とされない場合があるようですが、原因はエラーメッセージの通り ItemsControl.ItemsSource の記述位置です。上記のエラーは ItemsSource の記述位置を変更すれば解消されるはずなので、ItemsSource プロパティは VirtualizationMode 添付プロパティより後(下もしくは右)に書く癖をつけた方が良いと思います。

VirtualizingPanel の設定値

上で紹介した 3 つのプロパティについて、もう少しだけ詳しく紹介します。

ItemsControl に大量の項目を表示したい場合、2 行目の VirtualizationMode に【Recycling】に設定するとパフォーマンスが良くなると思います。但し、項目数が数十個程度の場合でも 1 項目毎に画像を表示する等の重い処理があるとパフォーマンスの違いが体感できる場合もあると思います。

3 行目の ScrollUnit は説明だけだと分かりにくいと思うので fig. 1、2 を見てください。

fig.1 ScrollUnit = Item

fig.2 ScrollUnit = Pixel

<span class="su-quote-cite"><a href="https://chitoku.jp/programming/pixel-based-scroll-in-wpf-4" target="_blank">WPF 4 でもピクセル単位スクロールがしたいっ!! - ちとくのホームページ</a></span>

ものすごく分かり易い画像を作られているブログがあったので、上の引用先サイトの管理者である『ちとくさん』に許可を取ってお借りしました。

ListBox のスクロールと聞いて想像するのは恐らく fig. 2 の ScrollUnit = Pixel の方ではないでしょうか?少なくとも管理人が想像したのは fig. 2 の方の動きでした。ScrollUnit = Item を設定した fig. 1 はスクロールと言いうより項目の中身を入れ替えているように見えるので管理人的には違和感がありました。

このようなプロパティの設定値はそれぞれの好み等もあると思いますが、管理人は以下の組み合わせで使用する事がほとんどです。

プロパティ名推奨値
VirtualizingPanel.IsVirtualizingtrue
VirtualizingPanel.VirtualizationModeRecycling
VirtualizingPanel.ScrollUnitPixel

次章からは MVVM パターンで ItemsControl(ListBox)へ項目を設定する方法を紹介します。

ListBox を MVVM パターンで正しくバインドする

本章から ListBox(ItemsControl を継承したリスト系コントロール)と VM をバインドする方法を紹介します。以降で紹介する内容は WPF Prism episode: 14 と大きくは変わりませんが、もう少し詳しい内容になっています。

以下の引用は、リスト系コントロールを MVVM パターンでデータバインドする場合に管理人が指標としている方法です。

通常、最低 1 つの画面に 1 つの ViewModel が必要で、コレクション・ビュー(= ListBox や TreeView など)の項目ごとに操作があるなら、それの 1 項目ごと用の ViewModel も必要です(操作がなくても普通は作ります)。大抵、コレクション・ビューの各項目は操作を持つし、表示方式を Model のものから変えて表示したい場合が多いからです。

<span class="su-quote-cite"><a href="https://www.atmarkit.co.jp/fdotnet/chushin/greatblogentry_02/greatblogentry_02_03.html" target="_blank">MVVM パターンの常識 ― 「M」「V」「VM」の役割とは? Page 3 - @IT</a></span>

上の引用は旧連載の初期に紹介して、新連載に変わってからも何度か紹介している『MVVMパターンの常識 ― 「M」「V」「VM」の役割とは? – @IT』の 3 ページ目に書かれている文章です。

引用文中に具体的な実装方法等は書かれていませんが、リスト系コントロールを MVVM パターンでバインドする方法は上の引用文以外見た事が無いので、このエントリは上の引用文を元に管理人が色々な場所で見かけた情報を組み合わせて考え付いた方法です。あくまでも管理人の個人的見解ですし、間違っているかもしれないので異論・反論等は大歓迎です。

尚、上の引用文は以降の文中でも登場するので覚えておいてください。

VM とバインドする ListBox

fig. 3 はこれまでの連載で紹介してきたサンプルアプリに ListBox を置いた画面です。

fig.3 ListBox の表示サンプル

標準 ListBox とはかなり見た目や操作のエフェクトが違いますが、Material Design In XAML Toolkit をインストールしていると ListBox を置いただけで fig. 3 のような見た目になります。Material Design In XAML Toolkit の ListBox は枠無しデザインなので、場所を分かり易くするために GroupBox で囲んでいます。

src. 2 は fig. 3 の XAML から ListBox の周辺を抜粋したものです。

src. 2 で重要なのは 24 ~ 66 行目の【ItemTemplate】で囲んだ部分です。

src. 2 では 1 レコードを複数行・複数列の少し複雑なレイアウトで表示するために複数の Grid を大げさにネストしていますが、要するデータの表示エリアを細かく分けているに過ぎません。そして、リスト系コントロールの項目は src. 2 の通り【ItemTemplate – DataTemplate】内に指定したレイアウト通りに描画されます。そのため【DataTemplate】に囲まれた部分を View と見なす事もできると管理人は思っています。

Windows Form のリスト系コントロールは単一項目のみ表示可能なものと複数項目表示可能なものに分かれていましたが、WPF の場合はどのリスト系コントロールでも複数項目や複数行のレイアウトが表示可能です。ASP.NET の WebForm で ListView を使ったことがあれば同じ感覚でレイアウトできると思います。

少し話は逸れますが、ListBox の項目カスタイマイズで、少し前に Twitter で見かけて驚いたのが ListBox の各項目を東北地方の県の形に描画した『ListBoxをカスタマイズして都道府県の地図を選択するUIを作成する – Yamakiの日記』です。

13 年前(2021/3 現在)に書かれた記事と言う事にも驚きましたが、WPF が発表された直後から上のリンク先に書かれているような事が出来た事にも驚いたので、上の記事を初めて読んだ時の管理人は正にポルナレフ状態でしたw

WPF のコントロールカスタマイズの可能性を感じる記事ですが、今の所、管理人にはそこまで外観をカスタマイズしたい欲求はないので Material Design In XAML Toolkit のようなパッケージを適用するだけで十分だと思っています。

ListBox で重要度が高いプロパティ

WPF の ListBox を使用する場合、管理人が必ず設定するのは以下のプロパティです。

プロパティ名内容
HorizontalContentAlignment各リスト項目の表示位置を以下の列挙型の中から設定します。

  • Left
  • Center
  • Right
  • Stretch
ItemsSourceListBox に表示する項目のデータソースを指定します。

それぞれを簡単に紹介します。

HorizontalContentAlignment

このプロパティはコントロールの水平位置を指定する HorizontalAlignment とは違い、リスト項目の水平位置を設定します。HorizontalContentAlignment プロパティの初期値は『Left』なので設定しない場合は左寄せで表示されますが、リスト項目自体の Width は【ItemTemplate – DataTemplate】内に配置したコントロールに設定した値(文字列)の幅しか確保されません

そのため、コントロール(ListBox)自体の幅を基準に Grid 等で Width を分割しようとしても上手くいかないので、HorizontalContentAlignment には【Stretch】を設定します。ItemsControl を継承した全てのコントロールで【Stretch】を設定すべきかは又、別のエントリで紹介しますが、少なくとも ListBox の場合は HorizontalContentAlignment = Stretch は設定必須だと思います。

ItemsSource

リスト系コントロールへ表示する項目データを保持したリストを指定します。今まで紹介したバインディングと同じく Window や View(UserControl)の VM に定義されているプロパティ名を指定しますが、プロパティは ObservableCollection を継承した型で定義されている必要があります。

この連載では VM のインタフェースに ReactiveProperty を使用する方針なので、ObservableCollection<T> を継承した ReactiveCollection で定義したプロパティ名を指定します。

リスト項目の ViewModel

部分 View(UserControl)の VM を紹介する前に、本章の最初に紹介した引用文を思い出してください。『リストの 1 項目毎に VM を作成する』と書かれているので、まずはリスト項目用の VM を作成します。と言っても特別な事をする訳ではなく、プロジェクトに VM 用のクラスを追加するだけです。

Prism をインストールしていれば VM 用のテンプレートが用意されているので、fig. 4 のように新しい項目の追加ダイアログからリスト項目用の VM をプロジェクトに追加します。

fig.4 ListBoxItem 用の ViewModel を追加

保存場所等は特に決まりがありませんが、ListBox を配置した部分 View と同じプロジェクト(Prism Module)に置きました。複数の Prism Module から参照されるような場合は別プロジェクトに分ける方が良いと思います。命名規則も決まりはありませんが、部分 View や Window の VM 命名規則と合わせて、コントロール名(BleachList)+ “ItemViewModel” としています。

リスト項目用の VM と言っても部分 View や Window とバインドする場合と変わりません。Window や UserControl に配置したコントロールを対象にするか【DataTemplate】で囲んだ領域に配置したコントロールを対象にするかの違いしかないので、src. 3 のようなリスト項目用の VM を作成します。

ListBoxItem の DataTemplate には読み取り専用コントロールしか配置していないので、定義しているプロパティは全て ReadOnly の ReactiveProperty と言う違いはありますが、実装的には step: 7 で紹介した部分 View 用の VM と何も変わりません。

コンストラクタのパラメータで渡されるエンティティ系モデルと双方向でバインドするのも同じですが、構造的には少し違いがあります。Prism では Window や 部分 View の VM は自動的に DI コンテナへ登録されますが、src. 3 の VM は DI コンテナへ登録されないため、コンストラクタのパラメータは実装者自身が渡すコードを書く必要があると言う違いがあります。

次項では src. 3 のリスト項目用 VM を ListBox の項目とバインドする方法を紹介します。

部分 View 用の ViewModel

ListBox の基底クラスである ItemsControl は ItemsSource にバインドしたコレクションのメンバを項目として表示するので、src. 4 のように ReadOnlyReactiveCollection<BleachListItemViewModel> 型で定義したプロパティと ListBox.ItemsSource をバインドします。

ListBox とバインドするプロパティを【ReadOnly】な型で定義するのは意外に感じる人も居るかもしれませんが、ListBox 側から ItemsSource へ項目を追加することは無いので【ReadOnly 付】で問題ありません。ListBox 以外のリスト系コントロールでもここで紹介している方法でバインドできます。

そして 28 行目では ReactiveProperty と同じく VM のコンストラクタで『SearchResults プロパティ』を初期化していますが、src. 4 のキモ… と言うか、このエントリのキモになるのが 29 行目の ToReadOnlyReactiveCollection 拡張メソッドです。

今まででも ReactiveProperty の初期化時には To(ReadOnly)Reactive~ 的なメソッドを呼び出す例を多く紹介したので、字面的に this.adapter.SearchResults(実体は ObservableCollection<PersonSlim>)を ReadOnlyReactiveCollection 型に変換すると思いがちですが、ToReadOnlyReactiveCollection メソッドを呼び出すと this.adapter.SearchResults と完全に同期したコレクションが返ります
完全に同期したコレクションがどのようなものを指すのかは以降で、サンプルコード等と併せて紹介します。

そしてこの ReactiveCollection が備える機能の中でも neuecc さんの設計の妙と言えるのが、ToReadOnlyReactiveCollection メソッドの第 1 引数に変換用のラムダ式を指定する点だと管理人個人的には思っています。ここでラムダ式を挟む事で PersonSlim ⇒ BleachListItemViewModel の変換も同時に行えるのが ReactiveCollection 最大のメリットだと思います。

次項では ToReadOnlyReactiveCollection メソッドを指定した ReactiveCollection がどのように動作するのかを紹介していきます。

ListBox への項目追加と削除

本項では fig. 5 のように、画面初期表示時の ListBox は空(Count = 0 件)で、検索ボタンをクリックすると ListBox へ項目を追加する場合の実装を紹介します。

fig.5 ReactiveCollection をバインドした ListBox の動作

fig. 5 の検索ボタンクリック Command は src. 4、33 行目の通り ReactiveSamplePanelAdapter.SearchCharacterAsync メソッドを呼び出しています。

そして src. 5 は ReactiveSamplePanelAdapter.SearchCharacterAsync メソッドの中身です。

この Adapter(ReactiveSamplePanelAdapter)は step: 8 で紹介した通り、物理的には Model に位置するクラスですが、実際は VM ⇔ Model 間の連携処理をまとめただけのクラスなので、VM に書かれているコードとしても読むことができます

SearchCharacterAsync メソッドの中身自体はシンプルで IDataAgent.GetAllCharactersAsync から取得した List を自身の SearchResults プロパティへ AddRange しているだけですが、これだけで fig. 5 のように ListBox へ項目が追加されます

そしてクリアボタンも同様に ReactiveSamplePanelAdapter 自身の SearchResults からメンバをクリアするだけで View 側の ListBox から項目がクリアされることが確認できます。

つまり、ListBox の項目操作は部分 View の VM(ReactiveSamplePanelViewModel)に定義した ReadOnlyReactiveCollection<BleachListItemViewModel> ではなく ToReadOnlyReactiveCollection の変換元である ReactiveSamplePanelAdapter に定義した ObservableCollection<PersonSlim> を操作すれば済む事になります。

実は、この動作を可能にしているのが上で紹介した ToReadOnlyReactiveCollection メソッドの第 1 引数に指定した変換用のラムダ式です。このラムダ式はパッと見、連動元コレクション内に存在するメンバを初期化時に変換するためだけに適用されるように見えますが、連動元コレクションに Add や Insert した場合にも呼び出されるので、fig. 5 のように空のコレクションへ後から要素を追加した場合でも View 側の ListBox へ項目が追加されます

前項に書いた【完全に同期したコレクション】はこのような状態を指しています。

少し話は逸れますが、 src. 5、26 行目で呼び出している ObservableCollection.AddRange を補足します。本来、ObservableCollection に AddRange メソッドは定義されていませんが、Prism.Wpf で拡張メソッドとして提供されています。この AddRange 拡張メソッドを呼び出すと List をまとめて追加できるので、非常に便利です。

ただ不思議なのは、ReactiveSamplePanelAdapter を含むプロジェクトは Prism Module ではなくクラスライブラリプロジェクトテンプレートから作成していますし、Prism 関連のパッケージも参照していないのに何故だか呼び出せています…

このサンプルで Prism.Wpf を呼び出せている理由は不明ですが『IntelliSense に出てけーへんやん』と言う場合は、Prism.Wpf を参照に追加して、【System.Collections.ObjectModel 名前空間】を using すれば使用できると思います。

このサンプルで AddRange 拡張メソッドが呼び出せている理由が判明したら又、この記事を更新するつもりです。

項目の破棄

そして ReactiveCollection の便利な点は項目の追加だけでなく、クリアや削除時に項目の Dispose を呼び出してくれる点も挙げられます。このサンプルで紹介している ReactiveCollection は src. 3 の BleachListItemViewModel がメンバなのので、src. 3 のように BleachListItemViewModel で IDisposable を継承して、コンストラクタで渡されるエンティティ系モデルを Dispose すると、連動元の ObservableCollection が保持するメンバまで Dispose されます。

ListBox.ItemsSource に ReactiveCollection ではなく ObservableCollection 型のプロパティをバインドしてメンバの破棄が必要な場合は、Clear や Remove 時に別途メンバを Dispose する必要があるので、これだけでも ReactiveCollection を使用するメリットがあると管理人は思っています。

DataTemplate の DataType

そして src. 2 では敢えて触れませんでしたが、本エントリで紹介しているサンプルのようにリスト項目用の VM を作成する場合、ListBox の DataTemplate には src. 6 のように ItemsSource とバインドするコレクションメンバの型を DataType に指定する必要があります。

11 行目のように DataType を指定すると DataTemplate へバインド先を指定する際に IntelliSense に BleachListItemViewModel のメンバが表示されるようになりますが、実は DataType には何を指定しても実行時に Reflection で取得した型がバインドされるので、間違った型を指定しても現時点ではエラーにはなりません

ただ、今後主流になるのは間違いない WinUI 3 ではエラーになる可能性も考えられるので、何を設定すべき項目なのかは覚えておいた方が良いと思います。

ListBox で選択された項目の取得

引き続き本項ではユーザが選択した項目の取得について紹介します。Windows Form では SelectedIndexChanged や SelectedValueChanged イベント内で SelectedIndex プロパティを処理する事も多かったと思いますが、WPF の場合、SelectedIndex プロパティとバインドした VM 側プロパティの Setter へ処理を書く形に変わっています

この連載では VM のインタフェースに ReactiveProperty を使用するので、src. 7 のように SelectedCharacterIndex を Subscribe します。

src. 7 の Subscribe は step: 8 で紹介したように EventToReactiveCommand と Xaml.Behaviors.Wpf を組み合わせて SelectedIndexChanged イベントで処理することも可能ですが、MVVM パターンでは src. 7 のように変更通知プロパティを利用するのがセオリーなので、src. 7 の形に慣れておく方が良いと思います。

src. 7 で Subscribe 先に指定している ReactiveSamplePanelAdapter.UpdatePersonFromSearchResults が src. 8 です。

実行すると fig. 6 のように選択項目の内容を別項目に反映することができます。

fig.6 ListBox の選択項目に反応した表示データの更新

実装内容自体は大したことがありませんが、部分的に補足します。src. 7 の Subscribe 前に指定している Where と src. 8 の UpdatePersonFromSearchResults 先頭の if 文は同じ意味なので、本来どちらか 1 つあれば良い処理ですが、サンプルなので両方を含めています。例えば、ReactiveSamplePanelAdapter を複数の VM で使用するような場合、src. 8 の if 文だけで問題ありませんが、呼び出し条件が VM 毎で違うような場合等は src. 8 の if 文は削除して、src. 7 の Where のみを使用するように使い分けます。

ListBox へ選択項目の設定

そして、ListBox.SelectedIndex は値の取得だけでなく fig. 7 のように値を設定することもできます。

fig.7 SelectedIndex へ値を設定

fig. 7 は【ListBox を選択ボタン】をクリックすると【名前】に入力した文字が部分一致するキャラクターを選択しています。ListBox がフォーカスを持っていないので、よく見ないと選択項目が判別しにくいですが、名前に入力した文字と PersonSlim.Name が最初に部分一致したキャラクターが選択されるのが分かると思います。

src. 9 は fig. 7 の【ListBox を選択ボタン】Command の初期化です。

今まで紹介した AsyncReactiveCommand の初期化と比べて特別な処理をしているように見えるかもしれませんが、【ListBox を選択ボタン】は ListBox にデータが表示済みで、名前に 1 文字以上入力されている場合だけ実行したいので、IObservable<bool> を配列で束ねて全て true の場合のみボタンの IsEnabled を true にしています。

Command の実行可否が複数ある場合、src. 9 のように ReactiveProperty に含まれる CombineLatestValuesAreAllTrue 拡張メソッドが便利です。この拡張メソッドは IObservable<bool> 型の配列(リスト)を監視して全てが true の場合のみ以降へ処理を流します

又、30 行目の ObserveProperty は INotifyPropertyChanged から呼び出せる拡張メソッドで、これも ReactiveProperty に含まれています。ReadOnlyReactiveCollection.Count は int 型のプロパティなので変更は通知されませんが、ObserveProperty に渡すと変更を通知(ここでは IObservable<int> として)してくれるようになります。

これら 2 つの拡張メソッドを使って『ListBox にデータが表示済みで、名前に 1 文字以上入力されている場合』を判定しています。この辺りの実装は地味に使う機会も多いと思うので覚えておくと重宝します。

そして、src. 10 は 36 行目で呼び出している this.adapter.GetCharacterIndex の中身です。

Linq では珍しいインデックスを返すメソッドです。Select や Where にはインデックスも渡すオーバーロードが用意されているので、それを使用して名前が部分一致するインスタンスのインデックスを返しています。

SelectedIndex のバインド位置

この連載の step: 7 からこのエントリまで、ReactiveProperty を使用して VM ⇔ Model 間を双方向でバインドする方法を紹介してきましたが、SelectedIndex は Model とバインドしていません(View ⇔ VM 間は双方向)。これは SelectedIndex があまりにも View 寄りな値であることと、揮発性の値であることが理由です。

揮発性の値とは永続化されない値を意味します。永続化されないデータを Model まで引き込むのは違和感があったので、今のサンプルコードの形になりました。一応、何度か書き直していて、SelectedIndex を Model まで引き込んだパターンも書いてみましたが、やはり『揮発性の値を Model で処理する』と言う違和感が拭えませんでした。

但し、SelectedIndex を VM で止めた構造にすると src. 9、35 行目の int(実際は Task<int>)を返すメソッドが引っ掛かりました。理由は step: 8 でも紹介した尾上さんが書かれた以下の引用との兼ね合いです。

ViewModel に公開する Model のインタフェースは以下の二つしかありません。

Model のステートの公開とその変更通知
Model の操作のための戻り値のないメソッド
ステートの公開とその変更通知を行うのは簡単な話でしょう。リッチクライアントの Model はステートフルです。そのステートを公開しないと ViewModel と View は表示すべき情報がありません。そしてその変化を ViewModel と Viewに伝えるために変更通知を行うのも当然の事です。

Model の操作のためのメソッドには戻り値がない・・これにはひっかかる方も多いかもしれません。しかしこれも難しい話ではありません。

Model のメソッド呼び出しは何をもたらすのでしょうか?。それは Model 内状態の変化(あるいは外部サービス呼び出しとそれに伴う Model 内状態の変化)と、なんらかのイベント発生(通信エラー発生とか)しかないのです。ViewModel が Model の影であれば当然それしかないのです。ViewModel が Model を呼び出して Model から戻り値を受け取って何になるんでしょう?それは Model 内のステートの不完全な意味のないコピーでしかありません。

<span class="su-quote-cite"><a href="http://ugaya40.hateblo.jp/entry/model-mistake" target="_blank">ViewModel に対する Model のインターフェース</a></span>

上記の引用内容から Model が公開するメソッドは戻り値を持たないように作成すべきと思いつつこのエントリを書いてきましたが、src. 9 の 35 行目で Task<int> を返すメソッドを書いてしまっています。

まあ、そこまで原理主義を貫くつもりもありませんが、こんな記事を書いている以上、最低でも言い訳は必要かな…? とは思っているので、以下 ↓ 言い訳ですw

Model に値を返すメソッドを定義した言い訳

src. 10 の通り、値を返す GetCharacterIndex メソッドは ReactiveSamplePanelAdapter に定義しています。そして何度も書いていますが、このアダプタは本来、MVVM パターンには存在しない要素です。

ここで紹介しているアダプタは VM から Model を呼び出す場合の記述量を軽減するために管理人が独自に取り入れた方法であり、物理的には Model に分類されるプロジェクトに配置されていますが、論理的には VM に書くべき記述を分割しているに過ぎません。そのため、アダプタに定義する public なプロパティ・メソッドは VM の private なプロパティ・メソッドと同意なので値を返すメソッドとして定義しても上の引用文に書かれている原則からは外れないと考えています。

ただ、管理人的に原則は大事だと思っていますが、値を返すメソッドにしないとどうにもならない場面は出て来ると思っているので、その際は値を返すメソッドを定義するのもしょうがないだろうな… とは思っている事まで合わせて言い訳とします。

SelectedIndex を利用した項目操作

そして SelectedIndex をバインドしていると fig. 8 のように ListBox の項目をリアルタイムで操作できます。

fig.8 ListBox の項目操作

ListBox の項目操作は追加・削除時と同じく連動元の ObservableCollection を SelectedIndex で操作するだけなので、ここではサンプルコードを紹介しません。実際のコードは GitHub リポジトリ を見てください。

一応、ボタンの実行可否も組み込んであるのでリスト系コントロールを使う場合の参考にはなると思います。

選択項目を取得・設定する 2 つのプロパティ

ここまでは ListBox の選択項目を取得・設定するためのプロパティとして SelectedIndex を紹介してきましたが、ListBox にはもう 1 つ選択項目を取得・設定する SelectedItem プロパティも用意されていて、src. 11 のようにバインドできます。

Microsoft Docs 等では SelectedItem は Object 型のプロパティなので、18 行目は ReactivePropertySlim<Object> で宣言しても構いませんが、構造的に ItemsSource にバインドしたコレクションのメンバが返されるので、18 行目のように BleachListItemViewModel を取得・設定するプロパティとして宣言することもできます。

このエントリで紹介しているようにリスト系コントロールの項目用に VM を作成した場合は、項目用の VM が返るので、BleachListItemViewModel へ src. 12 のように読み取り専用プロパティを追加します。

src. 12 のように読み取り専用の通常プロパティ(変更通知しないプロパティ)を定義して内部の PersonSlim を取得できるようにしておくこともできますが、ListBox の操作はインデックスで行うことが多いので、リスト項目のエンティティ系モデルが取得できても、使い道があまり無いかもしれません。

加えて、SelectedItem で取得・設定する BleachListItemViewModel は VM のプロジェクトに位置する(View とも同じ)ため、Model のプロジェクトからは参照できない(相互参照になる)という縛りも出て来ます。VM 内部でしか使用できない型と言う事に気を付けて使用する必要がある事は忘れないようにしてください。

SelectedValue と SelectedValuePath

もう 1 つ、SelectedValue と SelectedValuePath を使って選択項目値の取得と設定をすることも可能です。SelectedValue は src. 13 のように SelectedValuePath に設定したプロパティの値を取得・設定するために使用します。

確かに、必要な値をダイレクトに取得・設定できますが、MVVM パターンで作成する場合、データの受け渡しはクラス単位で行う事が多いので、独立した単体の値は取り扱いに困ることも多いと管理人個人的には思っています。

SelectedValue を使用してはいけないとか、使用すべきでないとは思っていませんが、使い所をちゃんと考えてから使用すべきプロパティだと思っています。

選択項目についてのまとめ的な

ここまで Selected~ 的なプロパティを紹介してきましたが、選択項目に関係するプロパティは ItemsControl ではなく ListBox 自体で実装されている事は忘れないでください。そのため、選択項目に関係する操作はリスト系コントロール全てで共通ではありません

具体例を挙げれば、SelectedIndex は ListBox にはありますが、TreeView には存在しない、ListBox では取得・設定可能な SelectedItem は TreeView では読み取り専用… と言うような違いがあります。

違いがあると言っても SelectedItem の使い方が 180゜違う… 的な違いではなく用意されているか・用意されていないかと言う違いなので、ここで紹介した方法はどのコントロールにもほとんど応用できるはずです。ListBox はリスト系コントロールの中でも Selected~ 系を多くサポートしているコントロールなので、ここで紹介した方法を全て使いこなせるようになっていて損はないと思います。

ListBox で複数項目を選択

項目の複数選択もコントロール毎に実装されているため、複数項目選択可能なコントロールと不可能なコントロールがありますが、ListBox は複数項目選択可能なコントロールに分類されます

ListBox で複数項目を選択可能にするには以下の SelectionMode プロパティを設定します。

プロパティ名内容
SelectionMode項目選択モードを以下の SelectionMode 列挙型の中から設定します。

  • Single
    • デフォルト値。
      単一項目のみ選択可能
  • Multiple
    • 複数項目選択可能。項目をクリックする度、選択・未選択が切り替わります。
  • Extended
    • 複数項目選択可能。Windows エクスプローラーと同じく Ctrl、Shift キーを押しながら項目をクリックすることで選択できます。

SelectionMode へ『Single 以外の値』を設定すると画面上の ListBox は複数項目選択可能になりますが、選択されている全項目の取得は単純なバインドでは取得できません。SelectedItems と言うそのものズバリっぽいプロパティは存在しますが、依存プロパティではないので XAML に書くだけでコンパイルエラーになります。

参考までに Google で検索すると『バインド可能な SelectedItems を提供するビヘイビア』を作成する… と言う方法を紹介しているブログ等が多く見つかりますが、そんなビヘイビアを作成しなくても取得できる方法があります。

ListBox の項目を複数選択するための準備

ここまで色々なサンプルを紹介してきて View の余白に余裕が無くなってきたので、以降のサンプル用に新規 View を追加しました。GitHub リポジトリからソースコードをダウンロードして動かす場合、src. 14 のように PrismSample プロジェクトの MainWindowViewModel コンストラクタのコメントを変更することで起動時の View を切り替えれるようにしています。

以降は 22 行目が実行される状態のサンプルになります。

ビヘイビアを作成せず ListBox から複数の選択項目を取得するサンプル

複数の選択項目を取得するには src. 15 のハイライト部を追加します。

src. 15 のハイライト部を追加すると、リスト項目に定義されているプロパティとバインドできるようになります。src. 15 では 16 行目に指定しているように ListBoxItem のプロパティを対象にしています。

尚、src. 15 にひっそりと書いている【BasedOn】については『WPF UI Gallery exhibition #3』で詳しく紹介しているのでそちらを見てください。

そして、リスト項目とバインドする VM(BleachListItemViewModel)へ src. 16 のようにバインド先プロパティを追加します。

単純な bool 型のプロパティなので、今まで紹介した方法で問題なくバインドできるはずなので、fig. 9 のように操作します。

fig.9 複数の項目を選択

選択項目は src. 17 のように LINQ 等で簡単に取得できます。

取得結果は console. 1 のように出力されます。

SearchResults は BleachListItemViewModel をメンバに持つコレクションなので、Select で PersonSlim のリストに加工していますが、Select を削除して BleachListItemViewModel を操作しても構いません。後は C# の世界なので好きなように操作できます。又、src. 10 で紹介したようにインデックスを受け取るオーバーロードでインデックスを操作するのもアリだと思います。

ListBox に複数の選択項目を設定する

選択項目の取得とは逆に、src. 18 のように選択項目を設定することもできます。

src. 18 を実行すると fig. 10 のように 40 代のキャラクターのみ選択することができます。

fig.10 複数の選択項目を設定

まあ、キャラクターの誕生年はあくまでも管理人が独断と偏見で適当に設定した値なのでスルーでお願いします。

複数選択項目のまとめ的な

このように各リスト系コントロールで用意されている【~Item】の IsSelected をバインドすればビヘイビア等を作成しなくても複数の選択項目を取得・設定できます。

ですが、この方法は複数項目の選択のために用意されている訳ではなく単一選択の場合でも同様に適用できることは忘れないでください。TreeView は複数項目が選択できないコントロールで、TreeView.SelectedItem は読み取り専用ですが、この方法を適用すれば VM で選択項目の取得・設定が可能です。

各リスト系コントロールには【~Item】と言う名前のクラスが用意されているので、ListBox なら ListBoxItem、TreeView なら TreeViewItem 的な名前のクラスがあるので、src. 15 のように指定すれば IsSelected 以外のプロパティもバインドできます。MsDocs 等で ~Item のプロパティを眺めてみると問題が解決できる事もあると思います。

リスト系コントロールを MVVM パターンでバインドする方法のまとめ的な

ここまでリスト系コントロールを MVVM パターンでバインドする場合の基本的な方法を紹介してきましたが、『面倒くさっ!』と思った人も多いと思います。同じようなプロパティを持つクラスをいくつも作る必要がありますし、作成したクラス間で値の受け渡しも必要になるので面倒なのは間違いないと思います。

特にリスト系コントロールの場合はリスト項目用の VM まで作成する必要があるので更に面倒さは増すと思いますが、実はリスト項目用の VM を挟まず Model を直接バインドする事も一応可能です。ですが、View で Model を参照すると言う事は、View が Model に依存することになるので、View の変更が Model まで影響したり、逆に Model の変更が View まで影響するような状況が発生する可能性が高くなります。

丁度上で紹介した【複数項目の選択】の IsSelected プロパティが良い例なので、項目用の VM を使用せず Model を直接 ListBoxItem へバインドした場合を想像してください。画面でしか使用しないような IsSelected プロパティを PersonSlim へ追加する気持ち悪い設計になります。

まあ、View では完全表示専用で仕様が絶対変わらないと言うなら 38,652 歩譲って Model を ListBoxItem へバインドする手も無くはない気もしますが、MVVM パターンからは外れる悪手なので面倒でも項目用の VM を作成すべきだと思います。

MVVM パターンでアプリを作成する場合、似た構造のクラスをいくつも作成する事は避けて通れないので、WPF の場合なら ReactiveProperty で Model と双方向バインドしたり、AutoMapper を使用したり等… で面倒さを軽減するしかありません。

そして、ListBox を MVVM パターンでバインドする場合の ReactiveCollection がどれだけ強力かも分かっていただけたと思います。Model で保持している List<T> と ListBox の表示が完全に同期するメリットは MVVM パターンに慣れれば慣れるほど大きくなると思います。

VM のインタフェースを ReactiveCollection ではなく ObservableCollection で定義する例は紹介していませんし、管理人も経験がありませんが、このエントリで紹介したサンプルと同じように動作させるのは、かなりのコードを追加する必要がありそうな気はするので、ReactiveCollection だけでも使う価値はあると管理人個人的には感じています。

step: 9 の後書き的な

step: 8 の公開が 2020/10/10 なので、ほとんど半年近くぶりの連載再開です。2020/10 初めにアニメ一覧を作成し始めて結構手を取られていましたが、やっとアニメ一覧作成のコツみたいなものも分かってきたので、メインコンテンツにもウエイトをかけれるようになりました。とは言え、アニメ一覧と並行で更新するので以前のような 2 週間に 1 度の更新ペースは厳しい気はしています。

現状、どの程度のペースで更新できそうかも言えませんが、出来れば最低月 1 回は新規記事を公開したいとは思っています。まあ、HUNTER×HUNTER に比べれば可愛いものだと思うw ので、待っていただけている方がいらっしゃるなら気長にお待ちください。

ReactiveCollection についてはほとんど紹介できたと思いますが、ListBox へ連番の項目を追加したり、Enum を表示する方法等が書き切れなかったので、step: 10【Enum と ListBox は使いよう】を書きました!良ければ見てください。

元々の予定ではこの step: 9 を書き終えたらこの『.NET Core WPF MVVM Prism 入門』は一時休止して『WPF UI Gallery』のテコ入れをする予定でしたが、このエントリの文字数が予想以上に多く(この時点で、46,000 文字オーバー…)なってしまったので、次回の step: 10 は今回入らなかった続きになる予定です。

まあ、続きと言っても MVVM からは少しだけ離れて、ListBox 関連のオマケ的な内容を書くつもりなので、少し軽めな内容になる予定です。そして、今回もいつもの通りサンプルコードは GitHub リポジトリ に上げています。

 

次回記事「Enum と ListBox は使いよう【step: 10 .NET 5 WPF MVVM 入門 2020】」

 

 

 

おすすめ

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください