WPF episode: 5 ~ご注文は TreeView ですか?~

← 前回記事【WPF episode: 4 ~ DI だけど Unity さえあれば関係ないよね~】

前回記事では Prism Shell で読み込んだデータオブジェクトを DI コンテナの【Unity】を経由して TreeView を配置した Prism Module へ渡す所まで紹介したので、今回は、Module で受け取ったデータオブジェクトから TreeViewItem を作成する手順を紹介します。
尚、この回は Prism から少しだけ離れて、WPF 標準の TreeView コントロールと、ReactiveProperty の内容がメインになります。

NavigationTree Module に置いた TreeView は WpfTestAppData の内容を左図のように表示して、MainWindow 右側の View を切り替えることが目的です。

尚、この記事は Visual Studio 2017 Community Edition で .NET Framework 4.7.2 以上 と C# + Prism 7.1 + ReactiveProperty を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。

前準備

ここまでは WPF の目玉機能の 1 つと言える、『バインディング』についてはほぼ触れていませんでしたが、今回辺りから少しずつ増えていく予定で TreeViewItem の表示もデータバインディングを使用します。

前回記事の最初の方でも少し触れましたが、Prism にも BindableBase を始めとしたデータバインディングに便利な仕組みも当然含まれてはいますが、バインディングのインタフェースには【ReactiveProperty】を使用します。
info ReactiveProperty は言わずと知れた超有名で Prism と組み合わせて使われることも多いと思われるライブラリなので、ここでの細かい説明等は省きます。

NuGet で『reactiveproperty』を検索すると見つかるので NavigationTree プロジェクトへインストールしてください。

info ReactiveProperty をインストールすると Reactive Extensions の各ライブラリも併せてインストールされます。(上図の【依存関係】に見えている System.Reactive 等がインストールされます)

TreeViewItem の表示

TreeView に表示する TreeViewItem は木構造になっている必要があり、前回記事に書いた WpfTestAppData は階層的な構造だとは言えると思いますが、木構造ではありません。

TreeView を扱っている他ブログ等では、モデルを直接 TreeView へバインドしている記事を見かけることが多い印象ですが、管理人的にデータオブジェクトが都合よく木構造になっていることは多くないと思っています。今回のデータオブジェクト(WpfTestAppData)も TreeView へバインドする事を考えると本来、データオブジェクトには不要なプロパティを追加したり、データオブジェクトの構造を変更したりする必要がありそうだったので、どんな構成で作成するか少し悩みました。

TreeView に表示したいからと言う理由だけで Model の構造を変えるのは MVVM 的には本末転倒だと思うので、『Model と VM の間に TreeViewItem 用の VM を作成する』極めて MVVM 的な構成を採ることにしました。
この構成は episode: 1 でも紹介した Livet の製作者である尾上雅則さんが書かれた @IT の記事 Page3 にある

通常、最低1つの画面に1つのViewModelが必要で、コレクション・ビュー(=ListBoxやTreeViewなど)の項目ごとに操作があるなら、それの1項目ごと用のViewModelも必要です(操作がなくても普通は作ります)。
MVVMパターンの常識 ― 「M」「V」「VM」の役割とは? Page3

と言う一文を参考に、TreeViewItem についても下図のような構造で作成します。

「MVVM だから必ず分けなきゃ!」と言う原理主義的な発想ではなく、TreeView と TreeViewItem が親子関係なので、VM も親子関係になっている方が自然だと考えたからです。
以下が作成した子項目用の VM で、このインスタンスは TreeView の VM が保持します。

info 前回の記事で作成したモデルを別プロジェクトに分割している場合、当然ですがモデルのプロジェクトへの参照設定が必要です。

TreeViewItem へバインドするための VM としては最低限必要な ItemText プロパティと Children プロパティを RectiveProperty を使用して作成しています。
又、ReactiveProperty は WPF で使用する場合のみ、親となるクラスが INotifyPropertyChanged インタフェースを実装していないとメモリーリークする可能性があるそうなので(参考:ReactivePropertyの後始末)、モデルと同様に BindableBase を継承しています。
併せて IDisposable インタフェースを継承して、RectiveProperty を一括で Dispose するための【System.Reactive.Disposables.CompositeDisposable】をフィールドとして宣言しています。
独り言 「この Dispose はどこから呼ばれんねん?」と言うごく素朴な疑問はとりあえず、胸の奥にしまっておいてくださいw

ReactiveProperty の初期化

ReactiveProperty を使用すると V – VM 間だけではなく M – VM 間での同期も通知も可能になります。
ReactiveProperty で定義するプロパティは INotifyPropertyChanged から派生したクラスのプロパティ(M や VM のプロパティ)と接続することが可能なので、TreeViewItem として表示したいオブジェクトを子項目用 VM のコンストラクタパラメータに渡して ReactiveProperty に接続しています。
注意 上記ソースコードで使用している ReadOnlyReactivePropertySlim については後述します。
参考 紹介するまでも無い程に有名なページですが、ReactiveProperty で何ができるかを知りたい場合は【MVVMをリアクティブプログラミングで快適にReactivePropertyオーバービュー】に一通り書かれています。

この TreeView に表示するデータは 4 種類とも違うクラスなので、ItemText プロパティに接続するデータオブジェクトのプロパティは個々のクラスで異なります。そのため、パラメータに指定されたオブジェクトの型で分岐して ItemText プロパティを初期化しています。
info 型 switch は C# 7.0 からサポートされた構文なので、使っている Visual Studio や .NET Framework のバージョンによってはコンパイルエラーになる場合があります。
参考 型スイッチ – ++C++; // 未確認飛行 C

ReactiveProperty の初期化構文で使用している Reactive Extensions の各種拡張メソッドについて、PhysicalInformation クラスの場合を例に紹介します。
弱音 Reactive Extensions も現在勉強中なので間違っている事を書いているかもしれません(泣) 間違っている所等あれば教えていただけるとありがたいです。

.ObserveProperty()
INotifyPropertyChanged から派生したオブジェクト(Model)のプロパティを IObservable に変換して列挙してくれます。
【Reactive.Bindings.Extensions】の using が必要です。
.Select()
上から来た IObservable の値を取得します。
PhysicalInformation クラスの場合のみ他のクラスと違い、モデルの値をそのまま使用せず、null 値の場合は、文字列:『新しい測定』に置き換えて表示し、null でない場合は日付をフォーマットして表示しています。モデルの値をそのまま使う場合は不要なメソッドです。
又、コンストラクタのパラメータに文字列を渡した場合は、型変換を挟まないとコンパイルエラーが出てしまうため、Select メソッドで型変換しています。
【System.Reactive.Linq】の using が必要です。
.ToReadOnlyReactivePropertySlim()
上から来た IObservable を ReadOnlyReactivePropertySlim に変換します。
Reactive.Bindings】の using が必要です。
.AddTo()
使用した ReactiveProperty を一括で Dispose するために、上から来た IDisposable を CompositeDisposable に追加します。
【Reactive.Bindings.Extensions】の using が必要です。

このように、ReactiveProperty を Model のプロパティにバインドする場合、Model のプロパティとの接続は Reactive Extensions で記述する事が前提となります。
(構文自体はテンプレに近く、ほぼ同じパターンなので何度か書いている内に慣れると思います)

『ReactiveProperty』自体、文字数が多いので、プロパティを何個も作成すると手がつりそう… とお嘆きの貴兄には、コードスニペットを使用することでタイプ量を減らすことができます。
ReactivePropertyのコードスニペット – かずきのBlog@hatena』にも書かれている通り、ReactiveProperty の NuGet パッケージにはコードスニペットが含まれていて、インストールはされませんが手動で登録することで使用できるようになります。

ソリューション配下の【packages/ReactiveProperty.x.x/Snippet/csharp6】フォルダ内にあるファイルを【コードスニペットマネージャー】から登録すると使用できるようになります。
「ソリューション配下に packages フォルダがねーよ!」という場合は、NuGet の Web ページからパッケージを直接ダウンロードして拡張子を『.nupkg → .zip』に変更すると取り出せます。
コードスニペット自体の詳細は、上記かずきさんのページで確認してください。
Visual Studio 2017 を使用している場合は、NuGet のパッケージ管理法が変わっているため、『%UserProfile%\.nuget\packages』配下にダウンロードされている場合もあります。

Model と VM をバインドしない場合は VM のプロパティに ReactiveProperty を使用せず、Prism の BindableBase の構文でプロパティを記述する方法もあります。(その場合は AutoMapper 等が使用できれば、M – VM 間の値の入れ替えが楽になると思われます [管理人は未確認])

つぶやき 業務系のシステムのプロジェクトで Prism を導入できる確率は高そうな気はしますが、ReactiveProperty の導入は厳しそうですね…
特に業務系の現場では Reactive Extensions が書ける技術者は多くなさそうな印象ですし、Reactive Extensions の書き方自体に難色を示すリーダーも多そうですし…

ReadOnlyReactivePropertySlim について
上記ソースコードで使用している ReadOnlyReactivePropertySlim は主に Model で使用することを目的に軽量版 ReactiveProperty として Ver. 4.1.0 から追加されたクラスです。
参考 ReactivePropertySlim詳解 – neue.cc

内部動作的な詳細は上記リンク先に書かれている通りで、無印 ReactiveProperty からバリデーション機能を削除して、内部処理も再設計したことで、無印と比べてかなり高速に動作するそうです。
Model での使用を目的にとは書かれていますが、VM でもバリデーションが不要な個所ではガンガン使っていこうと思っています。

又、ReadOnly な ReactiveProperty とはその名の通り外部からは ReadOnly なので、
ex. ReactiveProperty<hoge> { get; }
と同じ意味ですが、ReadOnly 付は Model 又は自プロパティへの接続が必須になっていて、単純に初期値をセットして自プロパティと同じように使いたいという場合は、ReadOnly 無しを使用する必要があるようです。(明記されている情報は見当たりませんでしたが、コンストラクタのパラメータ『IObservable source』の指定が必須になっているため、Model のプロパティか、VM に定義済みの別プロパティに接続する必要があるようです)

管理人的には、内部で単純に値をセットするだけの ReadOnly な自プロパティとして動作して欲しいと思うことがありますが ReadOnly 付の方では出来ないようです…

木構造の作成

子項目用の VM を木構造に整形するための TreeViewItemCreator クラスです。

見た通り何の工夫もなくベタで木構造を作っているだけで、別クラスにしたのも特に意味は無く、TreeView の VM 側へ直に記述しても構いませんので、その辺りはお好みで。

そして、TreeView 本体の VM です。

これも見た通りで、TreeViewItemCreator.Create() の戻り値から自身の TreeNodes プロパティを設定しているだけです。設定した TreeNodes プロパティを XAML 側の ItemSource にバインドすることで、TreeView に TreeViewItem が表示されるようになります。

又、コンストラクタに追加したパラメータにデータオブジェクトを指定しているので、前回記事で書いた通り、このパラメータには DI コンテナの Unity からデータオブジェクトが自動でインジェクションされます

NavigationTree XAML

そして、VM と対になる View 側の XAML です。

  1. TreeView.ItemsSource プロパティに、VM の TreeNodes ( ReactiveCollection) プロパティをバインドします。
  2. 【TreeView.ItemTemplate】を木構造で表示するため、【HierarchicalDataTemplate】を追加して、DataType プロパティへ【x:Type nt:TreeViewItemViewModel】を指定します。
    これは、TreeViewItem の型に TreeViewItemViewModel を使用することを表します。
  3. 【HierarchicalDataTemplate.ItemsSource プロパティ】を 子項目用 VM の Children プロパティにバインドすることで、各 TreeViewItem の子項目として Children プロパティを使用することを指定します。
  4. HierarchicalDataTemplate 内へ TreeViewItem を描画するためのコントロールとして TextBlock を配置します。ここに配置したコントロールが TreeViewItem の表示要素として使用されます。
    ex. CheckBox を配置すれば CheckBox が描画されます)
    配置した TextBlock.Text プロパティを子項目用 VM の ItemText にバインドすることで ItemText プロパティの値が表示されます。

上のようなゴチャゴチャした文章を読むより、コピペするだけで即、画面が再現できるのは XAML の大きな利点だと言えますね。

加えて重要ですが、忘れやすい点として ReactiveProperty で定義したプロパティとバインドする XAML側には【ItemText.Value】のように『.Value』を付けないと正常に動作しないので覚えておいてください。(管理人もよく忘れます)

独り言 この例では HierarchicalDataTemplate.DataType プロパティへ『TreeViewItemViewModel』を指定していますが、『DataType = {x:Type TreeViewItem}』と指定しても問題なく動作します。
なので実際、何を指定するのが正解なのかよく分かっていません…(どっちを指定しても良いのかな?)

これで、TreeView へ TreeViewItem を表示する準備は整いました。サンプルアプリを実行すると、下図のような画面が表示され、TreeViewItemCreator で作成した通りの構成で表示されます。
注意 下図の TreeViewItem は手動で展開しています

memo 実行時、TreeViewItem のラベルに『{string: 新しい生徒}』等と表示されてしまう場合は、ReactiveProperty とバインドしている XAML 側のプロパティから【.Value】が抜けている事が原因なので、XAML に【.Value】を追加するだけで正常に表示されるようになるはずです。

XAML での画面作成は Windows From に慣れ親しんだタイプの人間からすると、バインドしたいプロパティ名等を VM からコピペしないと設定できないのは本当に面倒です。(どこかのブログでも見かけましたが、Windows Form と比べて退化してんじゃね?と思うことも多いです)

実際、XAML の記述自体で難しいと感じる箇所はあまり無いのではないでしょうか?
XAML について大して知らなくても画面の作成に困ることはなさそうなので、XAML の構文等については必要になったタイミングで必要になった所だけ覚えれば良いと管理人的には思っていますが、WPF 入門的な記事ではまず、データバインドとは?とか、XAML の記法… は等から始まる記事が大半で、読み進めようとしてもアプリが作れそうな画が見えてこないのが苦痛過ぎて、どれも最後まで読み切ることができませんでした。(XAML の理解が不要と言いたい訳ではなく、WPF についてほぼ何も知らない時点では不要だと思っています。)

実際、ASP.NET 等の Web 系の作成経験があれば、XAML 自体への抵抗は大して無いはずですし、最初に XAML の説明から入るような入門記事等を見て挫折した管理人のようなタイプの人も結構多いんじゃないかと勝手に想像していて、この連載記事ではまず「動かせる簡単な WPF アプリを作ってみることが WPF への入門だ!」的な思いで書いています。
(ある程度 XAML で画面が作れるようになって、WPF への理解が増えてきた今では、以前に挫折した入門記事が役に立つ場合もあるので、良い記事だと思っています)

TreeViewItem のアイコン

やはり TreeView に表示される内容が文字だけだとかなり貧弱ですし寂しいので、この記事の先頭辺りに貼ったキャプチャのように、ItemText の左横へ画像を表示します。

Windows Form で TreeViewItem(Windows Form では TreeNode)に画像を表示するには ImageList コントロールに画像を登録すれば表示できましたが、WPF には ImageList コントロールはありません。
方法は何パターンかあるようですが、ここでは手軽に画像を表示する方法としてアセンブリに埋め込んだリソースファイル(画像)を読み込む方法を紹介します。(XAML に記述する <Window.Resource> ではありません)

まず、適当な画像を用意します。(大き過ぎると縮小した時に潰れてしまうので、32px 角前後のサイズ)管理人は【Icons8】とかで探すことが多いです。(個人使用の場合であれば、Icons8 へのリンクを張るだけで無料で使用できるようです)
記事の先頭に貼った TreeView のキャプチャに使用している画像は【FatCow Web Hosting】で無料配布されている PNG 画像を使用しています。

用意した画像を NavigationTree プロジェクトのリソースへ追加します。

プロジェクトリソースへ画像を追加する方法は Windows Form の場合と変わっていません。
そして、先ほど紹介した【NavigationTree.xaml】に以下のハイライト行を追加します。

TextBlock を 1 つだけ配置していた TreeViewItem の ItemTemplate を Orientation = “Horizontal” に指定した StackPanl に入れ替えて、Panel 内に Image コントロールと TextBlock コントロールを配置し直すことで、Image コントロールと TextBlock コントロールが横並びに表示されるようになります。
又、Image コントロールの Height プロパティと Width プロパティを設定することで、表示する画像が 25px 角にリサイズされて描画されるよう指定しています。
そして、XAML に追加した Image コントロールの Source プロパティを子項目用 VM の ItemImage プロパティにバインドして、アイコンを VM から取得しています。

Image.Source プロパティは Windows Form の Bitmap クラスとは全く別物の【System.Windows.Media.ImageSource】型の抽象クラスで、Bitmap 系の画像や Draw 系の画像、インターネット上の URL 等を直接指定して読み込むことが可能なクラスで、アセンブリへ埋め込んだリソース(以降、アセンブリリソース)を取得したい場合は、アセンブリリソースファイルへの URI を指定することで表示されます。

パック URI スキーム

アセンブリリソースファイルは Windows Form であれば、【Properties.Resources.Icon1】のように指定するだけで読み込めていましたが、WPF ではリソースファイルの場所を指定するためにパック URI スキームと言う構文を使用しなければならなくなりました。

パック URI スキームは以下のようなあまり見慣れない構文を使用します。

pack://application:,,,/Subfolder/ResourceFile.xaml

WPF におけるパッケージの URI】には、リソースファイルの URI を指定するための構文が何種類か書かれていますが、とりあえず以下の 2 種類が分かれば戦えます。

ローカル アセンブリ リソース ファイル
自身のアセンブリ内に配置されたリソースファイルのパスを指定する構文。
ex.pack://application:,,,/Subfolder/ResourceFile.png
参照アセンブリ リソース ファイル
参照しているアセンブリ内に配置されたリソースファイルのパスを指定する構文。
ex. pack://application:,,,/ReferencedAssembly;component/Subfolder/ResourceFile.png

ソースを書く前に、WPF ではリソースにファイルを追加しただけではリソースとして認識されないので、ファイル自体をリソースとしてビルドするよう指定する必要があります。リソースに追加したんだから自動で認識してくれればいいのに手作業で変更する必要があります。

対象のファイルを選択して、プロパティウィンドウから【ビルドアクション】を【Resource】に設定するだけですが、設定し忘れると『System.IO.IOException: ‘リソース ‘resources/hoge.png’ を検索できません。’』という意味不明な例外が発生するので忘れないようにしましょう。

ビルドアクションを設定したら、子項目用 VM に【ItemImage プロパティ】をとりあえず追加して、コンストラクタを以下のように変更して動作テストをしてみます。

NavigationTree プロジェクトに追加したリソースファイルを指定したいので、『ローカルアセンブリリソースファイル』の例に沿って書けば何の問題も無い気がしますが、いざ実行してみると【System.IO.IOException: 「リソース ‘resources/user.png’ を検索できません。」例外】が飛んで来ます … orz

ソリューション内にプロジェクトが 1 つだけの場合であれば、『ローカルアセンブリリソースファイル』の構文で正常に動作するはずですが、今回のサンプルアプリのようにクラスライブラリプロジェクトも混在しているようなソリューションの場合には認識を変える必要があります。

このサンプルアプリのソリューション構成は左下図ですが、アプリを実行した場合は、exe プロジェクトをルートとして、参照しているアセンブリ(DLL)がルートからぶら下がる右下図のようなイメージで動作することは何となく分かってもらえると思います。

『pack://application:,,,』から始まるパック URI パスは、実行時の構成を元に指定する必要があるようで、『ローカル アセンブリ リソース ファイル』の構文が書けるのはリソースファイルをルート要素の exe プロジェクトに置いた場合のみです。
リソースファイルを DLL プロジェクトへ含めた場合は、『参照アセンブリ リソース ファイル』の構文で指定しないと認識されないようです。これは、リソースだけをまとめて別 DLL に切り出した場合も同様です。

つまり、子項目用 VM のコンストラクタに書くパック URI は自身のアセンブリに含まれたリソースファイルであっても、参照アセンブリのリソースとして指定する必要があり、パック URI のパスを以下のように修正すると認識されるようになります。

パック URI を指定する時は以下を参考にしてください。

上記のように変更すると例外は出ずアイコンも正常に表示されるようになります。
コンストラクタのパック URI のパスを修正した子項目用 VM の最終的な全ソースが以下です。

そしてサンプルアプリを実行すると、TreeView に画像が描画されるようになるはずです。

この記事の最初に貼ったキャプチャのイメージに近い画面が表示されたと思います。
(最初に貼ったキャプチャはマージンやパディング等を設定してレイアウトを微調整しています)

TreeView に TreeViewItem を表示する方法はここまでです。
次回はネタを Prism に戻して、TreeViewItem をクリックすると MainWindow の右側へ対応した編集画面を表示(画面遷移)する方法を紹介します。

又、今回書いたソースもリポジトリに上げておきます。

【WPF episode: 6 ~されどイベントは ViewModel と踊る~】次回記事 →

あわせて読みたい

コメントを残す

%d人のブロガーが「いいね」をつけました。