ReactiveCollection 世代の ListBox 達【step: 9 .NET 5 WPF MVVM ReactiveCollection 入門 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 です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <UserControl x:Class="PrismSample.ReactiveMvvm.ReactiveSamplePanel" ~ 略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> ~ 略 ~ <GroupBox Grid.Row="3" Header="コレクションとバインドするListBox" Margin="20,8,20,20"> <Grid> ~ 略 ~ <ListBox Grid.Column="0" VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" VirtualizingPanel.ScrollUnit="Pixel" ItemsSource="{Binding SearchResults}"> <ListBox.ItemTemplate> <DataTemplate DataType="{x:Type local:BleachListItemViewModel}"> <TextBlock Text="{Binding Name}" /> </DataTemplate> </ListBox.ItemTemplate> </ListBox> ~ 略 ~ </Grid> </GroupBox> </Grid> </UserControl> |
【項目の仮想化】は src. 1 の 11 ~ 13 行目のように VirtualizingPanel 添付プロパティに値を設定します。
以下は VirtualizingPanel 添付プロパティで設定することの多いプロパティです。
プロパティ名 | 内容 |
---|---|
VirtualizingPanel.IsVirtualizing | true を設定すると【項目の仮想化】が有効になります。 デフォルト値は true なので ListBox 等、ItemsControl を継承したリスト系コントロールはデフォルトで【項目の仮想化】が有効になっています。 |
VirtualizingPanel.VirtualizationMode | 仮想化した項目の表示方式を以下から選択します。
|
VirtualizingPanel.ScrollUnit | スクロールの単位を以下から選択します。
|
上記の項目を設定していると以下のようなエラーが表示される場合があります。
エラー XDG0066 Measure が ItemsHost パネルで呼び出された後に ItemsControl の VirtualizationMode 添付プロパティを変更することはできません。上記エラーは表示される場合とされない場合があるようですが、原因はエラーメッセージの通り ItemsControl.ItemsSource の記述位置です。上記のエラーは ItemsSource の記述位置を変更すれば解消されるはずなので、ItemsSource プロパティは VirtualizationMode 添付プロパティより後(下もしくは右)に書く癖をつけた方が良いと思います。
VirtualizingPanel の設定値
上で紹介した 3 つのプロパティについて、もう少しだけ詳しく紹介します。
ItemsControl に大量の項目を表示したい場合、2 行目の VirtualizationMode に【Recycling】に設定するとパフォーマンスが良くなると思います。但し、項目数が数十個程度の場合でも 1 項目毎に画像を表示する等の重い処理があるとパフォーマンスの違いが体感できる場合もあると思います。
3 行目の ScrollUnit は説明だけだと分かりにくいと思うので fig. 1、2 を見てください。
ものすごく分かり易い画像を作られているブログがあったので、上の引用先サイトの管理者である『ちとくさん』に許可を取ってお借りしました。
ListBox のスクロールと聞いて想像するのは恐らく fig. 2 の ScrollUnit = Pixel の方ではないでしょうか?少なくとも管理人が想像したのは fig. 2 の方の動きでした。ScrollUnit = Item を設定した fig. 1 はスクロールと言いうより項目の中身を入れ替えているように見えるので管理人的には違和感がありました。
このようなプロパティの設定値はそれぞれの好み等もあると思いますが、管理人は以下の組み合わせで使用する事がほとんどです。
プロパティ名 | 推奨値 |
---|---|
VirtualizingPanel.IsVirtualizing | true |
VirtualizingPanel.VirtualizationMode | Recycling |
VirtualizingPanel.ScrollUnit | Pixel |
次章からは MVVM パターンで ItemsControl(ListBox)へ項目を設定する方法を紹介します。
ListBox を MVVM パターンで正しくバインドする
本章から ListBox(ItemsControl を継承したリスト系コントロール)と VM をバインドする方法を紹介します。以降で紹介する内容は WPF Prism episode: 14 と大きくは変わりませんが、もう少し詳しい内容になっています。
以下の引用は、リスト系コントロールを MVVM パターンでデータバインドする場合に管理人が指標としている方法です。
通常、最低 1 つの画面に 1 つの ViewModel が必要で、コレクション・ビュー(= ListBox や TreeView など)の項目ごとに操作があるなら、それの 1 項目ごと用の ViewModel も必要です(操作がなくても普通は作ります)。大抵、コレクション・ビューの各項目は操作を持つし、表示方式を Model のものから変えて表示したい場合が多いからです。
MVVM パターンの常識 ― 「M」「V」「VM」の役割とは? Page 3 - @IT上の引用は旧連載の初期に紹介して、新連載に変わってからも何度か紹介している『MVVMパターンの常識 ― 「M」「V」「VM」の役割とは? – @IT』の 3 ページ目に書かれている文章です。
引用文中に具体的な実装方法等は書かれていませんが、リスト系コントロールを MVVM パターンでバインドする方法は上の引用文以外見た事が無いので、このエントリは上の引用文を元に管理人が色々な場所で見かけた情報を組み合わせて考え付いた方法です。あくまでも管理人の個人的見解ですし、間違っているかもしれないので異論・反論等は大歓迎です。
尚、上の引用文は以降の文中でも登場するので覚えておいてください。
VM とバインドする 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 の周辺を抜粋したものです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | <UserControl x:Class="PrismSample.ReactiveMvvm.ReactiveSamplePanel" ~ 略 ~ xmlns:local="clr-namespace:PrismSample.ReactiveMvvm" mc:Ignorable="d" d:DesignHeight="480" d:DesignWidth="640" d:DataContext="{d:DesignInstance local:ReactiveSamplePanelViewModel}" prism:ViewModelLocator.AutoWireViewModel="True"> <Rectangle Grid.Row="1" Margin="0,5,0,15" Height="1" Fill="{DynamicResource MaterialDesignDivider}" /> <Button Grid.Row="2" Content="検索" Width="100" Command="{Binding SearchButtonClick}" Cursor="Hand"/> <GroupBox Grid.Row="3" Header="コレクションとバインドするListBox" Margin="20,8,20,20"> <ListBox VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" VirtualizingPanel.ScrollUnit="Pixel" ItemsSource="{Binding SearchResults}" HorizontalContentAlignment="Stretch"> <ListBox.ItemTemplate> <DataTemplate DataType="{x:Type local:BleachListItemViewModel}"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="0.7*"/> <RowDefinition Height="0.3*"/> </Grid.RowDefinitions> <Grid Grid.Row="0"> <Grid.ColumnDefinitions> <ColumnDefinition Width="0.35*"/> <ColumnDefinition Width="0.65*"/> </Grid.ColumnDefinitions> <Grid Grid.Column="0"> <Grid.RowDefinitions> <RowDefinition Height="0.3*"/> <RowDefinition Height="0.7*"/> </Grid.RowDefinitions> <StackPanel Grid.Row="0" Orientation="Horizontal"> <TextBlock Grid.Column="0" Text="{Binding LastNameKana.Value, Mode=OneWay}" /> <TextBlock Grid.Column="1" Margin="10 0 0 0" Text="{Binding FirstNameKana.Value, Mode=OneWay}" /> </StackPanel> <TextBlock Grid.Row="1" FontSize="18" FontWeight="Bold" Text="{Binding Name.Value, Mode=OneWay}"/> </Grid> <TextBlock Grid.Column="1" FontSize="25" FontWeight="Bold" Text="{Binding Zanpakuto.Value, Mode=OneWay}" /> </Grid> <StackPanel Grid.Row="1" Orientation="Horizontal"> <TextBlock Text="生年月日:" /> <TextBlock Text="{Binding BirthDay.Value, Mode=OneWay}" /> </StackPanel> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </GroupBox> </Grid> </UserControl> |
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 | 各リスト項目の表示位置を以下の列挙型の中から設定します。
|
ItemsSource | ListBox に表示する項目のデータソースを指定します。 |
それぞれを簡単に紹介します。
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 をプロジェクトに追加します。
保存場所等は特に決まりがありませんが、ListBox を配置した部分 View と同じプロジェクト(Prism Module)に置きました。複数の Prism Module から参照されるような場合は別プロジェクトに分ける方が良いと思います。命名規則も決まりはありませんが、部分 View や Window の VM 命名規則と合わせて、コントロール名(BleachList)+ “ItemViewModel” としています。
リスト項目用の VM と言っても部分 View や Window とバインドする場合と変わりません。Window や UserControl に配置したコントロールを対象にするか【DataTemplate】で囲んだ領域に配置したコントロールを対象にするかの違いしかないので、src. 3 のようなリスト項目用の VM を作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | using Prism.Mvvm; using Reactive.Bindings; using Reactive.Bindings.Extensions; using System; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; namespace PrismSample.ReactiveMvvm { /// <summary>BLEACHキャラクターListBoxItem用ViewModelを表します。</summary> public class BleachListItemViewModel : BindableBase, IDisposable { /// <summary>キャラクター名を取得します。</summary> public ReadOnlyReactivePropertySlim<string> Name { get; } /// <summary>キャラクター姓の読み仮名を取得します。</summary> public ReadOnlyReactivePropertySlim<string> LastNameKana { get; } /// <summary>キャラクター名の読み仮名を取得します。</summary> public ReadOnlyReactivePropertySlim<string> FirstNameKana { get; } /// <summary>誕生日を文字列として取得します。</summary> public ReadOnlyReactivePropertySlim<string> BirthDay { get; } /// <summary>斬魄刀銘を取得します。</summary> public ReadOnlyReactivePropertySlim<string> Zanpakuto { get; } private PersonSlim bleachCharacter = null; private CompositeDisposable disposable = new CompositeDisposable(); /// <summary>コンストラクタ。</summary> /// <param name="bleachChara">VMが仲介するBLEACHキャラクターのインスタンス。</param> public BleachListItemViewModel(PersonSlim bleachChara) { this.bleachCharacter = bleachChara; this.bleachCharacter.AddTo(this.disposable); this.Name = this.bleachCharacter.Name .ToReadOnlyReactivePropertySlim() .AddTo(this.disposable); this.LastNameKana = this.bleachCharacter.LastNameKana .ToReadOnlyReactivePropertySlim() .AddTo(this.disposable); this.FirstNameKana = this.bleachCharacter.FirstNameKana .ToReadOnlyReactivePropertySlim() .AddTo(this.disposable); this.BirthDay = this.bleachCharacter.BirthDay .Where(v => v.HasValue) .Select(v => v.Value.ToString("yyyy/MM/dd")) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposable); this.Zanpakuto = this.bleachCharacter.Zanpakuto .ToReadOnlyReactivePropertySlim() .AddTo(this.disposable); } ~ 略 ~ } } |
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 をバインドします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | using Prism.Mvvm; using Prism.Navigation; using Reactive.Bindings; using Reactive.Bindings.Extensions; using System; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading.Tasks; using System.Windows.Input; namespace PrismSample.ReactiveMvvm { /// <summary>ReactivePropertyのデータバインディングサンプル用ViewModelを表します。</summary> public class ReactiveSamplePanelViewModel : BindableBase, IDestructible { ~ 略 ~ /// <summary>ListBoxに表示するBLEACHキャラクターを取得します。</summary> public ReadOnlyReactiveCollection<BleachListItemViewModel> SearchResults { get; } /// <summary>検索ボタンClickコマンド。</summary> public AsyncReactiveCommand SearchButtonClick { get; } ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="samplePanelAdapter">ReactiveSamplePanel用アダプタを表すIReactiveSamplePanelAdapter。(DIコンテナからインジェクションされる。)</param> public ReactiveSamplePanelViewModel(IReactiveSamplePanelAdapter samplePanelAdapter) { ~ 略 ~ this.SearchResults = this.adapter.SearchResults .ToReadOnlyReactiveCollection(x => new BleachListItemViewModel(x)) .AddTo(this.disposables); this.SearchButtonClick = new AsyncReactiveCommand() .WithSubscribe(async () => await this.adapter.SearchCharacterAsync()) .AddTo(this.disposables); } ~ 略 ~ } } |
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 の検索ボタンクリック Command は src. 4、33 行目の通り ReactiveSamplePanelAdapter.SearchCharacterAsync メソッドを呼び出しています。
そして src. 5 は ReactiveSamplePanelAdapter.SearchCharacterAsync メソッドの中身です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | using System; using System.Collections.ObjectModel; using System.Reactive.Disposables; using System.Threading.Tasks; using Prism.Ioc; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace PrismSample { /// <summary>ReactiveSamplePanel用のAdapter</summary> public class ReactiveSamplePanelAdapter : IReactiveSamplePanelAdapter { /// <summary>ViewとバインドするPersonSlimを取得します。</summary> public PersonSlim Person { get; private set; } /// <summary>検索結果に表示するキャラクターを取得します。</summary> public ObservableCollection<PersonSlim> SearchResults { get; } ~ 略 ~ /// <summary>検索条件に一致するキャラクターを検索します。</summary> /// <returns>非同期処理のTask。</returns> public async Task SearchCharacterAsync() { using (var agent = this.container.Resolve<IDataAgent>()) { this.SearchResults.AddRange(await agent.GetAllCharactersAsync(this.Person)); } } /// <summary>検索結果をクリアします。</summary> /// <returns>非同期のTask。</returns> public Task ClearAllCharactersAsync() => Task.Run(() => this.SearchResults.Clear()); ~ 略 ~ } } |
この 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 に指定する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <UserControl x:Class="PrismSample.ReactiveMvvm.ReactiveSamplePanel" ~略 ~ xmlns:local="clr-namespace:PrismSample.ReactiveMvvm" ~略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> ~略 ~ <ListBox Grid.Column="0" ~略 ~ SelectedIndex="{Binding SelectedCharacterIndex.Value}"> <ListBox.ItemTemplate> <DataTemplate DataType="{x:Type local:BleachListItemViewModel}"> ~略 ~ </DataTemplate> </ListBox.ItemTemplate> </ListBox> ~略 ~ </Grid> </GroupBox> </Grid> </UserControl> |
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 します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | using System; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading.Tasks; using System.Windows.Input; using Prism.Mvvm; using Prism.Navigation; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace PrismSample.ReactiveMvvm { /// <summary>ReactivePropertyのデータバインディングサンプル用ViewModelを表します。</summary> public class ReactiveSamplePanelViewModel : BindableBase, IDestructible { ~ 略 ~ /// <summary>ListBoxで選択された項目のインデックスを取得・設定します</summary> public ReactivePropertySlim<int> SelectedCharacterIndex { get; } ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="samplePanelAdapter">ReactiveSamplePanel用アダプタを表すIReactiveSamplePanelAdapter。(DIコンテナからインジェクション。)</param> public ReactiveSamplePanelViewModel(IReactiveSamplePanelAdapter samplePanelAdapter) { ~ 略 ~ this.SelectedCharacterIndex = new ReactivePropertySlim<int>(-1) .AddTo(this.disposables); this.SelectedCharacterIndex.Where(i => 0 <= i) .Subscribe((i) => this.adapter.UpdatePersonFromSearchResults(i)); ~ 略 ~ } ~ 略 ~ } } |
src. 7 の Subscribe は step: 8 で紹介したように EventToReactiveCommand と Xaml.Behaviors.Wpf を組み合わせて SelectedIndexChanged イベントで処理することも可能ですが、MVVM パターンでは src. 7 のように変更通知プロパティを利用するのがセオリーなので、src. 7 の形に慣れておく方が良いと思います。
src. 7 で Subscribe 先に指定している ReactiveSamplePanelAdapter.UpdatePersonFromSearchResults が src. 8 です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | using System; using System.Collections.ObjectModel; using System.Reactive.Disposables; using System.Threading.Tasks; using AutoMapper; using Prism.Ioc; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace PrismSample { /// <summary>ReactiveSamplePanel用のAdapter</summary> public class ReactiveSamplePanelAdapter : IReactiveSamplePanelAdapter { /// <summary>ViewとバインドするPersonSlimを取得します。</summary> public PersonSlim Person { get; private set; } /// <summary>検索結果に表示するキャラクターを取得します。</summary> public ObservableCollection<PersonSlim> SearchResults { get; } ~ 略 ~ /// <summary>SelectedCharacterIndexの変更通知処理を表します。</summary> /// <param name="index">新たに選択された項目のインデックスを表すint。</param> public void UpdatePersonFromSearchResults(int index) { if ((index < 0) || (this.SearchResults.Count <= index)) return; this.Person.Id.Value = this.SearchResults[index].Id.Value; this.Person.Name.Value = this.SearchResults[index].Name.Value; this.Person.BirthDay.Value = this.SearchResults[index].BirthDay.Value; this.Person.Zanpakuto.Value = this.SearchResults[index].Zanpakuto.Value; } ~ 略 ~ } } |
実行すると fig. 6 のように選択項目の内容を別項目に反映することができます。
実装内容自体は大したことがありませんが、部分的に補足します。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 は【ListBox を選択ボタン】をクリックすると【名前】に入力した文字が部分一致するキャラクターを選択しています。ListBox がフォーカスを持っていないので、よく見ないと選択項目が判別しにくいですが、名前に入力した文字と PersonSlim.Name が最初に部分一致したキャラクターが選択されるのが分かると思います。
src. 9 は fig. 7 の【ListBox を選択ボタン】Command の初期化です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | using System; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading.Tasks; using System.Windows.Input; using Prism.Mvvm; using Prism.Navigation; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace PrismSample.ReactiveMvvm { /// <summary>ReactivePropertyのデータバインディングサンプル用ViewModelを表します。</summary> public class ReactiveSamplePanelViewModel : BindableBase, IDestructible { ~ 略 ~ /// <summary>ListBoxで選択された項目のインデックスを取得・設定します</summary> public ReactivePropertySlim<int> SelectedCharacterIndex { get; } ~ 略 ~ /// <summary>ListBoxを選択ボタンのClickコマンドを表します。</summary> public AsyncReactiveCommand SelectListBoxButtonClick { get; } ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="samplePanelAdapter">ReactiveSamplePanel用アダプタを表すIReactiveSamplePanelAdapter。(DIコンテナからインジェクション。)</param> public ReactiveSamplePanelViewModel(IReactiveSamplePanelAdapter samplePanelAdapter) { ~ 略 ~ this.SelectListBoxButtonClick = new[] { this.SearchResults.ObserveProperty(x => x.Count).Select(c => 0 < c), this.Name.Select(n => 0 < n.Length) } .CombineLatestValuesAreAllTrue() .ToAsyncReactiveCommand() .WithSubscribe(async () => this.SelectedCharacterIndex.Value = await this.adapter.GetCharacterIndex()) .AddTo(this.disposables); ~ 略 ~ } } } |
今まで紹介した 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 の中身です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | using System; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Disposables; using System.Text.RegularExpressions; using System.Threading.Tasks; using AutoMapper; using Prism.Ioc; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace PrismSample { /// <summary>ReactiveSamplePanel用のAdapter</summary> public class ReactiveSamplePanelAdapter : IReactiveSamplePanelAdapter { /// <summary>ViewとバインドするPersonSlimを取得します。</summary> public PersonSlim Person { get; private set; } /// <summary>検索結果に表示するキャラクターを取得します。</summary> public ObservableCollection<PersonSlim> SearchResults { get; } ~ 略 ~ /// <summary>名前に入力した文字列と部分一致するキャラクターのインデックスを取得します</summary> /// <returns>SearchResults内のメンバインデックスを表すint。</returns> public Task<int> GetCharacterIndex() { return Task.Run(() => { var resultIndexes = this.SearchResults.Select((p, i) => new { Person = p, Index = i }) .Where(x => Regex.IsMatch(x.Person.Name.Value, Regex.Escape(this.Person.Name.Value))) .Select(x => x.Index) .ToList(); if (resultIndexes.Count == 0) return -1; return resultIndexes.First(); }); } ~ 略 ~ } } |
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 内のステートの不完全な意味のないコピーでしかありません。
ViewModel に対する Model のインターフェース上記の引用内容から 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 の項目をリアルタイムで操作できます。
ListBox の項目操作は追加・削除時と同じく連動元の ObservableCollection を SelectedIndex で操作するだけなので、ここではサンプルコードを紹介しません。実際のコードは GitHub リポジトリ を見てください。
一応、ボタンの実行可否も組み込んであるのでリスト系コントロールを使う場合の参考にはなると思います。
選択項目を取得・設定する 2 つのプロパティ
ここまでは ListBox の選択項目を取得・設定するためのプロパティとして SelectedIndex を紹介してきましたが、ListBox にはもう 1 つ選択項目を取得・設定する SelectedItem プロパティも用意されていて、src. 11 のようにバインドできます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | using System; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading.Tasks; using System.Windows.Input; using Prism.Mvvm; using Prism.Navigation; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace PrismSample.ReactiveMvvm { src. 12 /// <summary>ReactivePropertyのデータバインディングサンプル用ViewModelを表します。</summary> public class ReactiveSamplePanelViewModel : BindableBase, IDestructible { ~ 略 ~ /// <summary>ListBoxで選択された項目を取得・設定します。</summary> public ReactivePropertySlim<BleachListItemViewModel> SelectedCharacter { get; } ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="samplePanelAdapter">ReactiveSamplePanel用アダプタを表すIReactiveSamplePanelAdapter。(DIコンテナからインジェクション。)</param> public ReactiveSamplePanelViewModel(IReactiveSamplePanelAdapter samplePanelAdapter) { ~ 略 ~ this.SelectedCharacter = new ReactivePropertySlim<BleachListItemViewModel>(null) .AddTo(this.disposables); ~ 略 ~ } } } |
Microsoft Docs 等では SelectedItem は Object 型のプロパティなので、18 行目は ReactivePropertySlim<Object> で宣言しても構いませんが、構造的に ItemsSource にバインドしたコレクションのメンバが返されるので、18 行目のように BleachListItemViewModel を取得・設定するプロパティとして宣言することもできます。
このエントリで紹介しているようにリスト系コントロールの項目用に VM を作成した場合は、項目用の VM が返るので、BleachListItemViewModel へ src. 12 のように読み取り専用プロパティを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | using Prism.Mvvm; using Reactive.Bindings; using Reactive.Bindings.Extensions; using System; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; namespace PrismSample.ReactiveMvvm { /// <summary>BLEACHキャラクターListBoxItem用ViewModelを表します。</summary> public class BleachListItemViewModel : BindableBase, IDisposable { ~ 略 ~ /// <summary>VMに設定したエンティティ系モデルを取得します。</summary> public PersonSlim SourcePerson { get; } = null; ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="bleachChara">VMが仲介するBLEACHキャラクターのインスタンス。</param> public BleachListItemViewModel(PersonSlim bleachChara) { this.SourcePerson = bleachChara; this.SourcePerson.AddTo(this.disposable); ~ 略 ~ } ~ 略 ~ } } |
src. 12 のように読み取り専用の通常プロパティ(変更通知しないプロパティ)を定義して内部の PersonSlim を取得できるようにしておくこともできますが、ListBox の操作はインデックスで行うことが多いので、リスト項目のエンティティ系モデルが取得できても、使い道があまり無いかもしれません。
加えて、SelectedItem で取得・設定する BleachListItemViewModel は VM のプロジェクトに位置する(View とも同じ)ため、Model のプロジェクトからは参照できない(相互参照になる)という縛りも出て来ます。VM 内部でしか使用できない型と言う事に気を付けて使用する必要がある事は忘れないようにしてください。
SelectedValue と SelectedValuePath
もう 1 つ、SelectedValue と SelectedValuePath を使って選択項目値の取得と設定をすることも可能です。SelectedValue は src. 13 のように SelectedValuePath に設定したプロパティの値を取得・設定するために使用します。
1 2 3 4 5 6 | <ListBox Grid.Column="0" ~ 略 ~ SelectedValuePath="Name" SelectedValue="{Binding SelectedValue.Value, Mode=TwoWay}"> ~ 略 ~ </ListBox> |
確かに、必要な値をダイレクトに取得・設定できますが、MVVM パターンで作成する場合、データの受け渡しはクラス単位で行う事が多いので、独立した単体の値は取り扱いに困ることも多いと管理人個人的には思っています。
SelectedValue を使用してはいけないとか、使用すべきでないとは思っていませんが、使い所をちゃんと考えてから使用すべきプロパティだと思っています。
選択項目についてのまとめ的な
ここまで Selected~ 的なプロパティを紹介してきましたが、選択項目に関係するプロパティは ItemsControl ではなく ListBox 自体で実装されている事は忘れないでください。そのため、選択項目に関係する操作はリスト系コントロール全てで共通ではありません。
具体例を挙げれば、SelectedIndex は ListBox にはありますが、TreeView には存在しない、ListBox では取得・設定可能な SelectedItem は TreeView では読み取り専用… と言うような違いがあります。
違いがあると言っても SelectedItem の使い方が 180゜違う… 的な違いではなく用意されているか・用意されていないかと言う違いなので、ここで紹介した方法はどのコントロールにもほとんど応用できるはずです。ListBox はリスト系コントロールの中でも Selected~ 系を多くサポートしているコントロールなので、ここで紹介した方法を全て使いこなせるようになっていて損はないと思います。
ListBox で複数項目を選択
項目の複数選択もコントロール毎に実装されているため、複数項目選択可能なコントロールと不可能なコントロールがありますが、ListBox は複数項目選択可能なコントロールに分類されます
ListBox で複数項目を選択可能にするには以下の SelectionMode プロパティを設定します。
プロパティ名 | 内容 |
---|---|
SelectionMode | 項目選択モードを以下の SelectionMode 列挙型の中から設定します。
|
SelectionMode へ『Single 以外の値』を設定すると画面上の ListBox は複数項目選択可能になりますが、選択されている全項目の取得は単純なバインドでは取得できません。SelectedItems と言うそのものズバリっぽいプロパティは存在しますが、依存プロパティではないので XAML に書くだけでコンパイルエラーになります。
参考までに Google で検索すると『バインド可能な SelectedItems を提供するビヘイビア』を作成する… と言う方法を紹介しているブログ等が多く見つかりますが、そんなビヘイビアを作成しなくても取得できる方法があります。
ListBox の項目を複数選択するための準備
ここまで色々なサンプルを紹介してきて View の余白に余裕が無くなってきたので、以降のサンプル用に新規 View を追加しました。GitHub リポジトリからソースコードをダウンロードして動かす場合、src. 14 のように PrismSample プロジェクトの MainWindowViewModel コンストラクタのコメントを変更することで起動時の View を切り替えれるようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | using System; using Prism.Mvvm; using Prism.Regions; using PrismSample.ReactiveMvvm; namespace PrismSample { /// <summary>メイン画面の ViewModelを表します。</summary> public class MainWindowViewModel : BindableBase, IDisposable { ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="regMan">PrismのRegionを管理するIRegionManager。(DIコンテナからインジェクションされる)</param> public MainWindowViewModel(IRegionManager regMan) { this.regionManager = regMan; //this.regionManager.RegisterViewWithRegion("ContentRegion", typeof(BindSamplePage)); //// 起動時にView上部に入力用のTextBoxがあるViewを表示する場合は↓のコメントを外す //this.regionManager.RegisterViewWithRegion("ContentRegion", typeof(ReactiveSamplePanel)); // 起動時にListBoxのみのViewを表示する場合は↓のコメントを外す this.regionManager.RegisterViewWithRegion("ContentRegion", typeof(ReactiveSample2)); } ~ 略 ~ } } |
以降は 22 行目が実行される状態のサンプルになります。
ビヘイビアを作成せず ListBox から複数の選択項目を取得するサンプル
複数の選択項目を取得するには src. 15 のハイライト部を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | <UserControl x:Class="PrismSample.ReactiveMvvm.ReactiveSample2" ~ 略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> <Grid> ~ 略 ~ <GroupBox Grid.Row="2" Header="コレクションとバインドするListBox" Margin="10 0 10 10"> <ListBox VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" VirtualizingPanel.ScrollUnit="Pixel" ItemsSource="{Binding SearchResults, Mode=OneWay}" HorizontalContentAlignment="Stretch" SelectionMode="Extended"> <ListBox.ItemContainerStyle> <Style TargetType="{x:Type ListBoxItem}" BasedOn="{StaticResource MaterialDesignListBoxItem}"> <Setter Property="IsSelected" Value="{Binding IsSelected.Value, Mode=TwoWay}" /> </Style> </ListBox.ItemContainerStyle> <ListBox.ItemTemplate> ~ 略 ~ </ListBox.ItemTemplate> </ListBox> </GroupBox> </Grid> </UserControl> |
src. 15 のハイライト部を追加すると、リスト項目に定義されているプロパティとバインドできるようになります。src. 15 では 16 行目に指定しているように ListBoxItem のプロパティを対象にしています。
尚、src. 15 にひっそりと書いている【BasedOn】については『WPF UI Gallery exhibition #3』で詳しく紹介しているのでそちらを見てください。
そして、リスト項目とバインドする VM(BleachListItemViewModel)へ src. 16 のようにバインド先プロパティを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | using Prism.Mvvm; using Reactive.Bindings; using Reactive.Bindings.Extensions; using System; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; namespace PrismSample.ReactiveMvvm.ViewModels { /// <summary>BLEACHキャラクターListBoxItem用ViewModelを表します。</summary> public class BleachListItemViewModel : BindableBase, IDisposable { ~ 略 ~ /// <summary>選択されている・されていないを取得・設定します。</summary> public ReactiveProperty<bool> IsSelected { get; } ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="bleachChara">VMが仲介するBLEACHキャラクターのインスタンス。</param> public BleachListItemViewModel(PersonSlim bleachChara) { ~ 略 ~ this.IsSelected = new ReactiveProperty<bool>(false) .AddTo(this.disposable); } ~ 略 ~ } } |
単純な bool 型のプロパティなので、今まで紹介した方法で問題なくバインドできるはずなので、fig. 9 のように操作します。
選択項目は src. 17 のように LINQ 等で簡単に取得できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading.Tasks; using Prism.Commands; using Prism.Mvvm; using Prism.Navigation; using PrismSample.ReactiveMvvm.ViewModels; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace PrismSample.ReactiveMvvm { public class ReactiveSample2ViewModel : BindableBase, IDestructible { /// <summary>ListBoxに表示するBLEACHキャラクターを取得します。</summary> public ReadOnlyReactiveCollection<BleachListItemViewModel> SearchResults { get; } /// <summary>ListBoxで選択された項目のインデックスを取得・設定します。</summary> public ReactivePropertySlim<int> SelectedCharacterIndex { get; } ~ 略 ~ /// <summary>項目取得ボタンのClickコマンドを表します。</summary> public AsyncReactiveCommand GetItemsClick { get; } ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="samplePanelAdapter">Model呼び出し用のアダプタを表すIReactiveSamplePanelAdapter。(DIコンテナからインジェクションされる)</param> public ReactiveSample2ViewModel(IReactiveSamplePanelAdapter samplePanelAdapter) { ~ 略 ~ this.SelectedCharacterIndex = new ReactivePropertySlim<int>(-1) .AddTo(this.disposables); this.ShowListItemsClick = new AsyncReactiveCommand() .WithSubscribe(() => this.adapter.SearchCharacterAsync()) .AddTo(this.disposables); this.GetItemsClick = this.SelectedCharacterIndex.Select(i => 0 <= i) .ToAsyncReactiveCommand() .WithSubscribe(() => { return Task.Run(() => { var selectedItems = this.SearchResults.Where(i => i.IsSelected.Value) .Select(i => i.SourcePerson) .ToList(); selectedItems.ForEach(i => Debug.WriteLine(i.ToString())); }); }); } ~ 略 ~ } } |
取得結果は console. 1 のように出力されます。
1 2 3 4 5 6 7 8 9 10 11 12 | クラス名: PersonSlim Id: 7 Name: 黒崎 一心 BirthDay: 1973/12/31 0:00:00 クラス名: PersonSlim Id: 9 Name: 四楓院 夜一 BirthDay: 1989/07/11 0:00:00 クラス名: PersonSlim Id: 10 Name: 京楽 春水 BirthDay: 1980/07/07 0:00:00 |
SearchResults は BleachListItemViewModel をメンバに持つコレクションなので、Select で PersonSlim のリストに加工していますが、Select を削除して BleachListItemViewModel を操作しても構いません。後は C# の世界なので好きなように操作できます。又、src. 10 で紹介したようにインデックスを受け取るオーバーロードでインデックスを操作するのもアリだと思います。
ListBox に複数の選択項目を設定する
選択項目の取得とは逆に、src. 18 のように選択項目を設定することもできます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading.Tasks; using Prism.Commands; using Prism.Mvvm; using Prism.Navigation; using PrismSample.ReactiveMvvm.ViewModels; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace PrismSample.ReactiveMvvm { public class ReactiveSample2ViewModel : BindableBase, IDestructible { ~ 略 ~ /// <summary>選択項目を設定ボタンのClickコマンドを表します。</summary> public AsyncReactiveCommand SelectItemsClick { get; } ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="samplePanelAdapter">Model呼び出し用のアダプタを表すIReactiveSamplePanelAdapter。(DIコンテナからインジェクションされる)</param> public ReactiveSample2ViewModel(IReactiveSamplePanelAdapter samplePanelAdapter) { ~ 略 ~ this.SelectItemsClick = this.SearchResults.ObserveProperty(x => x.Count) .Select(c => 0 < c) .ToAsyncReactiveCommand() .WithSubscribe(() => { return Task.Run(() => { this.SearchResults .Where(vm => 40 <= vm.SourcePerson.Age.Value && vm.SourcePerson.Age.Value <= 50) .ToList() .ForEach(vm => vm.IsSelected.Value = true); }); }); } ~ 略 ~ } } |
src. 18 を実行すると fig. 10 のように 40 代のキャラクターのみ選択することができます。
まあ、キャラクターの誕生年はあくまでも管理人が独断と偏見で適当に設定した値なのでスルーでお願いします。
複数選択項目のまとめ的な
このように各リスト系コントロールで用意されている【~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】」