WPF Prism episode: 5 ~ TreeView の MVVM には ReactiveProperty が埋まっている ~
前回は VM ⇔ View 間のバインディングインタフェースに採用した ReactiveProperty を使った MVVM 入門編と ReactiveProperty(ReactiveCollection)を TreeView の ItemsSource に指定するまでを紹介したので、今回は TreeView を MVVM パターンで正しくバインドする方法と、ReactiveProperty 入門 part. 2 として基本的な Reactive Extension(Rx)を紹介しています。
※ このエントリでは TreeView にバインドする例を紹介していますが、ListView、ListBox 等の List 系コントロールであれば同じ方法でバインドできます。
2020/9/13 追記
このエントリを書いている頃より MVVM パターンへが多少理解できたため、現在似た内容の連載を公開中です。【連載】.NET Core WPF Prism MVVM 入門 2020 の step: 7『ReactiveProperty を編む』でこのエントリに近い範囲の内容を書いているので、良ければそちらもご覧ください。
バインディングインタフェースには ReactiveProperty を使用していますが、Prism のみでバインドする場合も考え方は変わりません。
最終的には TreeViewItem へアセンブリリソースから取得したアイコンを表示するまでを紹介します。
※ 元々このエントリに書いていた内容の一部は episode: 4.5 へ分割しました
尚、この記事は Visual Studio 2017 Community Edition で .NET Framework 4.7.2 以上 と C# + Prism 7.1 + ReactiveProperty を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。
ReactiveProperty と TreeViewItem を MVVM パターンでバインドする
TreeView を MVVM パターンでバインドする方法は、この連載で何度も紹介している Livet の製作者である尾上雅則さんの『MVVMパターンの常識 ― 「M」「V」「VM」の役割とは? – @IT』Page3 に書かれている以下の引用文を参考にしました。
通常、最低 1 つの画面に 1 つの ViewModel が必要で、コレクション・ビュー(= ListBox や TreeView など)の項目ごとに操作があるなら、それの 1 項目ごと用の ViewModel も必要です(操作がなくても普通は作ります)。
MVVMパターンの常識 ― 「M」「V」「VM」の役割とは? Page3上の引用文を TreeView に置き換えて考えると『VM と View の間にもう 1 つ TreeViewItem 用の VM を作成して、TreeViewItem の VM は TreeView の VM が保持する』と解釈して、下 fig. 1 のようなクラス構成イメージで作成することにしました。
又、このような構成は上で引用した尾上さんの言葉通り、TreeViewだから…と言う訳ではなく ListBox や ListView 等、子項目を持つコントロールの場合は TreeView と同じく子項目用の VM も作成すべきです。
以降では上 fig. 1 の真ん中に置いた【TreeViewItemViewModel】を作成していきます。
ReactiveProperty を初期化するための基本の ‘キ’
今回のエントリでは最終的にサンプルアプリを起動すると下 fig. 2 の画面が表示されるよう進めます。
まずは前回エントリでも紹介した NavigationTree.xaml です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <UserControl x:Class="WpfTestApp.Views.NavigationTree" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:prism="http://prismlibrary.com/" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:nt="clr-namespace:WpfTestApp.ViewModels" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300" prism:ViewModelLocator.AutoWireViewModel="True"> <Grid> <TreeView ItemsSource="{Binding TreeNodes}"> <TreeView.ItemTemplate> <HierarchicalDataTemplate DataType="{x:Type nt:TreeViewItemViewModel}" ItemsSource="{Binding Children}"> <TextBlock Text="{Binding ItemText.Value}" /> </HierarchicalDataTemplate> </TreeView.ItemTemplate> </TreeView> </Grid> </UserControl> |
まず、HierarchicalDataTemplate の DataType に今から作成する【TreeViewItemViewModel】に【x:Type マークアップ拡張】を付けて指定します。
併せて 7 行目へ ViewModels.TreeViewItemViewModel を参照するための ViewModels 名前空間のエイリアス:【nt】も宣言しています。
(NodeType から命名した覚えがありますが、名前空間のエイリアスなので【vm】等にすれば良かったと後悔しています)
独り言 HierarchicalDataTemplate.DataType は HierarchicalDataTemplate に使用する型を指定しますが、ここへ『DataType = {x:Type TreeViewItem}』と指定しても特に問題なく動作してしまいます… 動作するとしても TreeViewItem にバインドする型を指定するのが正解でしょうね
尚、HierarchicalDataTemplate の ItemsSource と TextBlock.Text プロパティについては後述しますが、ここで最も重要なのはバインド先が ReactiveProperty の場合は『ItemText.Value』のように末尾に【.Value】が必要な所です!
XAML に書く時は IntelliSense も働かないので慣れない内は本当によく忘れますし、慣れても忘れます!
View に値が表示されないと言うような場合は【.Value】が抜けていただけ…のような事はよく起こるので、XAML を書く場合は忘れないように気合を入れてから書きましょう!
ReactiveProperty でプロパティを定義する場合は本当に忘れやすいポイントなので気を付けてください!
重要なことなので何回も書きました。
プロジェクトに新規追加した TreeViewItem 用の VM(TreeViewItemViewModel)に ReactiveProperty でバインドプロパティを書いたのが src. 2 です。
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 | using System.Reactive.Linq; using Prism.Mvvm; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace WpfTestApp.ViewModels { public class TreeViewItemViewModel : BindableBase, IDisposable { /// <summary>TreeViewItemのテキストを取得します。</summary> public ReadOnlyReactivePropertySlim<string> ItemText { get; } /// <summary>子ノードを取得します。</summary> public ReactiveCollection<TreeViewItemViewModel> Children { get; } /// <summary>TreeViewItem の元データを取得します。</summary> public object SourceData { get; } = null; /// <summary>ReactivePropertyのDispose用リスト</summary> private System.Reactive.Disposables.CompositeDisposable disposables = new System.Reactive.Disposables.CompositeDisposable(); /// <summary>コンストラクタ</summary> /// <param name="treeItem">TreeViewItem の元データを表すobject。</param> public TreeViewItemViewModel(object treeItem) { this.Children = new ReactiveCollection<TreeViewItemViewModel>() .AddTo(this.disposables); this.SourceData = treeItem; switch (this.SourceData) { case PersonalInformation p: this.ItemText = p.ObserveProperty(x => x.Name) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; case PhysicalInformation ph: this.ItemText = ph.ObserveProperty(x => x.MeasurementDate) .Select(d => d.HasValue ? d.Value.ToString("yyy年MM月dd日") : "新しい測定") .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; case TestPointInformation t: this.ItemText = t.ObserveProperty(x => x.TestDate) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; case string s: this.ItemText = this.ObserveProperty(x => x.SourceData) .Select(v => v as string) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; } } /// <summary>オブジェクトを破棄します。</summary> void IDisposable.Dispose() { this.disposables.Dispose(); } } } |
ReactiveProperty で宣言している Children プロパティ(14 行目)と ItemText プロパティ(11 行目)は、それぞれ XAML の HierarchicalDataTemplate の ItemsSource と、TreeViewItem のラベルになる TextBlock.Text プロパティにバインドします。
Prism で VM を作成する場合、View へ表示するデータは DI コンテナの Unity からコンストラクタへインジェクションされる Model の値を使用する場合が多いため、必然的にコンストラクタが肥大化する傾向にあります。
Model → VM へ値の移動が必要なので肥大化するのはどうしようもありませんが、ReactiveProperty を使用しない場合は、AutoMapper 等を使用すれば値の入れ替えが多少楽になりそうです。
※ 管理人は使ったことがありません。
ReactiveProperty は前回紹介した通りバインドプロパティをシンプルに記述できるだけでなく、以下のような機能を備えているため、ReactiveProperty を使用しない場合と比べてコンストラクタが更に肥大化する可能性があります。
- Reactive Linq や拡張メソッドを使用した柔軟な値設定
- Model と VM の双方向バインド(単一方向のバインドも可能)
- Model と双方向でバインドしていても Validation でエラーが発生した場合は Model への反映を中止
- View のイベントパラメータを ReactiveProperty にバインド
ReactiveProperty が備える機能はもちろん上記 4 点だけではありませんが、全てを一気に紹介できないので、この連載の中で必要になった部分のみ小出しで紹介していく予定ですが、すぐに全機能を一通り知りたい場合は前回のエントリでも紹介した『MVVMとリアクティブプログラミングを支援するライブラリ「ReactiveProperty v2.0」オーバービュー – かずきのBlog@hatena』を参照してください。
そして、上のサンプルコードに書いたコンストラクタの長い式は、ItemText プロパティ 1 つだけを初期化しています。
これは、1 つの VM で 4 種類の TreeViewItem に対応するためで、TreeView を使用する場合にはよくあるパターンだと思いますが、このサンプルは管理人のクラス設計ミスもあって長い式になっています。
基底クラスかインタフェースでも使っていればもう少しシンプルに書けたと後悔しています。
info 型 switch は C# 7.0 からサポートされた構文なので、使っている Visual Studio や .NET Framework のバージョンによってはコンパイルエラーになる場合があります。
参考 型スイッチ – ++C++; // 未確認飛行 C
通常のプロパティを 1 つ初期化するのに必要なコード量は、上で紹介したサンプルコードの case ブロック 1 つ分程度の長さになることが多いですが、Model と双方向で同期したり値を変換したりするとドンドン長くなります。
コンストラクタだけで百数十行とかになると見通しは悪くなるでしょうし、メソッドに分割したくなったりしますが、せっかく ReadOnly で定義したのに private であれ Setterを追加するのは何となく微妙な気はします。
XAML の Binding 先に【.Value】が必要なことと合わせて、コンストラクタが肥大しやすいのが ReactiveProperty の数少ないデメリットの 1 つだと言えるかもしれません。
ReactiveProperty を初期化する ReactiveExtension(Rx)超初級編
ItemText プロパティの初期化時に使用している各種拡張メソッドについて、PhysicalInformation クラスと文字列を受け取った場合を例に紹介します。
弱音 Rx もまだ分からないことの方が多いので間違っている事を書いているかもしれません(泣)… 間違っている所等あれば教えていただけるとありがたいです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | using System.Reactive.Linq; using Reactive.Bindings; using Reactive.Bindings.Extensions; case PhysicalInformation ph: this.ItemText = ph.ObserveProperty(x => x.MeasurementDate) .Select(d => d.HasValue ? d.Value.ToString("yyyy年MM月dd日") : "新しい測定") .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; case string s: this.ItemText = this.ObserveProperty(x => x.SourceData) .Select(v => v as string) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; |
抜粋したサンプルコードの中で使用している拡張メソッドの一覧です。
ItemText プロパティは ReadOnly なので ReactiveProperty と Model は単方向で同期しています。
ObserveProperty() |
INotifyPropertyChanged インタフェースを継承した Model のプロパティを IObservable に変換して列挙するメソッド。ReactiveProperty を初期化する場合は、このメソッドから開始することが多いのでよく使います。 【Reactive.Bindings.Extensions】の using が必要です。 |
Select() |
上から来た IObservable の値を取得(射影)します。 サンプルコードでは TreeViewItem に表示する文字列を取得するために使用していますが、PhysicalInformation クラスの場合のみ対象の値(MeasurementDate プロパティ)が null の場合は文字列:『新しい測定』に置き換えて表示、null でない場合は日付をフォーマットして表示しています。Model の値をそのまま使う場合は不要なメソッドです。 又、コンストラクタのパラメータが object なので文字列を受け取ると型の不一致でコンパイルエラーが出ます。 それを回避するため Select メソッドでキャストしています。 【System.Reactive.Linq】の using が必要です。 |
ToReadOnlyReactivePropertySlim() |
上から来た IObservable を ReadOnlyReactivePropertySlim に変換します。 ReactiveProperty にはこれ以外にも ToReactivePropertySlim() や ToReactiveProperty()、ToReadOnlyReactiveCollection() 等の変換用拡張メソッドが用意されています。 【Reactive.Bindings】の using が必要です。 |
AddTo() |
前回のエントリで紹介済みですが、VM に定義した ReactiveProperty を一括で Dispose するために上から来た IDisposable を CompositeDisposable に追加する拡張メソッドです。 【Reactive.Bindings.Extensions】の using が必要です。 |
構文自体はテンプレに近く、ほぼ同じパターンなので何度か書いている内に慣れると思います。
ここで紹介した拡張メソッドは ReactiveProperty に含まれているものがほとんどなので、Rx 標準の拡張メソッドや Rx 自体の基本は、じんぐるさんの『Rx入門 – xin9le.net』等を参照してください。
この時点で実行すると下 fig. 3 の画面が起動します。
ここまでは TreeViewItem のラベルを設定しているだけなので、実際に表示すると TreeViewItem は閉じた状態で起動します。そのため fig. 3 は TreeViewItem を手動で展開した後の画面です。
memo 実行時、TreeViewItem のラベルに『{string: 新しい生徒}』等と表示されてしまう場合は、XAML 側の Binding に【.Value】を付け忘れている事が原因の場合も多いので XAML を確認してみてください。
プログラムを書くのが好きな人だと Rx の書き方を見ると結構ワクワクする人も多そう(管理人だけ?)ですが、業務系システムのプロジェクトへReactiveProperty を導入するのはハードルが高そうな気がします。
Prism 導入のハードルは高くない気はしますが Rx が書ける業務システム系の技術者は多くなさそうな印象ですし、Rx 自体を知らない上流側の人も多いでしょうから説得するには苦労しそうな気がします。
現場に採用されなくても Rx は覚えておいて損は無い技術だと思うので、自分が作成するアプリ等に導入していろいろ触ってみると良いと思います。
ReadOnly あり・無しで変わる ReactiveProperty の初期化
管理人がこのサンプルを書いていた頃はあまり理解できていなかったので、サンプルコードに ReadOnly あり・無しが混在していますが、VM へ定義するバインドプロパティは、本来 ReadOnly で定義するものだと思います。
(コントロールからの入力を受け取るプロパティは除く)
TreeViewItemViewModel.Children プロパティも本来は ReadOnlyReactiveCollection で定義すべきでした。
ReadOnly あり・無し ReactiveProperty の違いを簡単なサンプルコードで比較します。
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 | using Prism.Mvvm; using Reactive.Bindings; using Reactive.Bindings.Extensions; using System.Collections.ObjectModel; namespace WpfTestApp.ViewModels { public class EpisodeSampleViewModel : BindableBase { // ReadOnly無しのReactiveProperty public ReactiveProperty<string> Name { get; set; } public ReactiveCollection<int> Numbers { get; } // ReadOnly付きのReactiveProperty public ReadOnlyReactiveProperty<string> NickName { get; } public ReadOnlyReactiveCollection<PersonalInformation> Persons { get; } public ReadOnlyReactiveCollection<PhysicalInformation> PhysicalInformations { get; } private string nickNameSource = string.Empty; private ObservableCollection<PersonalInformation> personList = null; private ReactiveCollection<PhysicalInformation> physicals { get; set; } public EpisodeSampleViewModel() { // ReadOnlyでないReactivePropertyは変数のように扱える this.Name = new ReactiveProperty<string>(string.Empty); this.Numbers = new ReactiveCollection<int>() { 0, 1, 2 }; // ReadOnlyのReactivePropertyは必ずソースオブジェクトが必要 this.NickName = this.ObserveProperty(x => x.nickNameSource) .ToReadOnlyReactiveProperty(); // つまり、以下のような初期化はできない //this.NickName = new ReadOnlyReactiveProperty<string>("テスト"); // この場合の初期化時パラメータにはIObservable<string>が必要と言うエラーになるので // ソースにする変数は必須。 // ReadOnlyReactiveCollectionのソースにはObservableCollectionか // ReactiveCollectionから作成するのが楽。 this.personList = new ObservableCollection<PersonalInformation>(); this.personList.Add(new PersonalInformation() { Name = "テスト1" }); this.personList.Add(new PersonalInformation() { Name = "テスト2" }); this.personList.Add(new PersonalInformation() { Name = "テスト3" }); this.Persons = this.personList .ToReadOnlyReactiveCollection(); this.physicals = new ReactiveCollection<PhysicalInformation>(); this.physicals.Add(new PhysicalInformation() { Id = 0 }); this.physicals.Add(new PhysicalInformation() { Id = 1 }); this.PhysicalInformations = this.physicals .ToReadOnlyReactiveCollection(); } } } |
上のサンプルコードのように、ReadOnly 無しの ReactiveProperty は普通の変数と同じように扱えますが、ReadOnly ありの ReactiveProperty はソースとなる変数が必ず必要になります。
サンプルコードではローカルに宣言した変数をソースに設定していますが、通常は Model のプロパティをソース変数に指定する場合が多いと思います。
ReadOnly 無しの ReactiveProperty でも
ex. ReactiveProperty<Person> Hoge { get; }
のように宣言すれば View 側からは ReadOnlyReactiveProperty と同じですが、VM 側から見た場合は少し異なります。
ReadOnly 無しの場合、Hoge.Value.Name = “テスト” のように VM 内の private 変数と同じように操作できますが、ReadOnly ありの場合はソース変数(通常は Model のプロパティ)を操作した結果が VM を通して View へ通知されると言う違いです。
ReactivePropertySlim のススメ
ReactiveProperty と紹介しつつサンプルコードに書いているのは ReactivePropertySlim なので、疑問に感じる人もいるかもしれませんが ReadOnlyReactivePropertySlim は主に Model で使用することを目的に Ver. 4.1.0 から追加された軽量版 ReactiveProperty です。
参考 ReactivePropertySlim詳解 – neue.cc
内部動作については上記リンク先に書かれている通りで無印 ReactiveProperty からバリデーション機能を削除して内部処理も再設計したことで、無印と比べてかなり高速に動作するようです。
Model での使用を目的にとは書かれていますが、管理人的には VM でもバリデーションが不要な個所ではガンガン使っていこうと思っています。
ReactiveProperty のコードスニペット
『ReactiveProperty』と言う単語自体、文字数が多いのでプロパティを何個も作成すると手がつりそう… とお嘆きの貴兄には、コードスニペットを使用することでタイプ量を減らすことができます。
『ReactivePropertyのコードスニペット – かずきのBlog@hatena』にも書かれている通り、ReactiveProperty の NuGet パッケージにはコードスニペットが含まれていて、インストールはされませんが手動で登録することで使用できるようになります。
2020/9/13 追記
上記では Nuget パッケージにコードスニペットが含まれていると書いていますが、最新の Ver.7.2.0 ではパッケージに含まれていません。コードスニペットのインストールについては現在新規連載中の .NET Core WPF Prism MVVM 入門 2020 step: 7 を見てください。
ReactiveProperty をインストールしたソリューション配下の【packages/ReactiveProperty.x.x/Snippet/csharp6】フォルダ内にあるファイルを【コードスニペットマネージャー】へ登録すると使用できるようになります。
「ソリューション配下に packages フォルダがねーよ!」という場合は、ReactiveProperty の NuGet Web ページ から直接パッケージをダウンロードして、拡張子を『.nupkg → .zip』に変更すると取り出せます。
コードスニペット自体の詳細は、上記かずきさんのページ で確認してください。
Visual Studio 2017 以降を使用している場合は、NuGet のパッケージ管理法が変わっているため、『%UserProfile%\.nuget\packages』配下にダウンロードされている場合もあります。
TreeViewItem へアイコンを表示する
下 fig. 4 のように TreeView へ文字列とアイコンを表示すると TreeView らしくなるので、TreeViewItem にアイコンを表示する方法を紹介します。
Windows Form で TreeViewItem(Windows Form では TreeNode)にアイコンを表示するには ImageList コントロールに画像を追加すれば表示できましたが、WPF に ImageList コントロールはありません。
方法は何通りかあるようですが、ここでは Windows Form の頃から慣れ親しんでいるアセンブリのリソースに埋め込んだ画像をを表示する方法を紹介します。
XAML に記述する Resource と区別するために以降ではアセンブリリソースとします。
アセンブリリソースのアイコンをバインドして TreeViewItem へ表示
まず、適当な画像(大き過ぎると縮小した時に潰れてしまうので、32px 角前後のサイズ)を用意します。
管理人は【Icons8】等で探すことが多いので一度覗いてみると良いと思います。
(個人使用の場合であれば、Icons8 へのリンクを張るだけで無料で使用できるようです)
fig. 4 の画面に使用している画像は【FatCow Web Hosting】で無料配布されている PNG 画像を使用しています。
用意した画像を NavigationTree プロジェクトのリソースへ追加します。
アセンブリリソースへ画像を追加する方法は Windows Form の場合と変わりませんが、WPF の場合、アセンブリリソースにファイルを追加しただけではリソースとして認識されません。
リソースとして認識させるには、追加したファイルのビルドアクションを【Resource】に設定する必要があります。
リソースに追加したんだから自動で認識してくれれば良いのに、手作業で設定する必要があります。
対象のファイルを選択して、プロパティウィンドウから【ビルドアクション】を【Resource】に設定するだけですが、設定し忘れると例外:『System.IO.IOException: ‘リソース ‘resources/hoge.png’ を検索できません。’』が発生するので忘れないようにしましょう。
そして、【NavigationTree.xaml】を src. 5 のように変更します(ハイライト部)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <UserControl x:Class="WpfTestApp.Views.NavigationTree" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:prism="http://prismlibrary.com/" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:nt="clr-namespace:WpfTestApp.ViewModels" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300" prism:ViewModelLocator.AutoWireViewModel="True"> <Grid> <TreeView ItemsSource="{Binding TreeNodes}"> <TreeView.ItemTemplate> <HierarchicalDataTemplate DataType="{x:Type nt:TreeViewItemViewModel}" ItemsSource="{Binding Children}"> <StackPanel Orientation="Horizontal"> <Image Source="{Binding ItemImage.Value}" Width="25" Height="25" /> <TextBlock Text="{Binding ItemText.Value}" /> </StackPanel> </HierarchicalDataTemplate> </TreeView.ItemTemplate> </TreeView> </Grid> </UserControl> |
TextBlock のみ配置していた所に StackPanel と Image コントロールを追加しました。
Windows Form アプリの作成経験はあるけど WPF にはまだ慣れていない場合、「こんな所にパネル!?」と驚くくらい WPF の画面レイアウトにはパネルを多用します。
管理人の場合 StackPanel と Grid は使用頻度が非常に高く、コントロールを縦か横に並べたい場合には必ず StackPanel を使っています。
最初は慣れないかもしれませんが、画面を作っていく内に慣れると思うので、とりあえず数を作ることが慣れる早道だと思うのでガンガン作りましょう。
又、Windows Form では PictureBox.SizeMode = Strech 等で画像の描画サイズを指定していましたが、WPF の場合は Image コントロール自体の Height、Width プロパティを設定すると表示する画像がリサイズされて描画されます。
そして Image.Source には Windows Form の Bitmap クラスとは全く別物の【System.Windows.Media.ImageSource】型の抽象クラスで、Bitmap 系の画像や Draw 系の画像、インターネット上の URL 等を直接指定して読み込むことが可能です。
そして、アセンブリリソースの画像を Image コントロールに表示するには、対象画像への URI をパック URI スキームと言う構文で指定する必要があります。
アセンブリリソースの URI を指定するパック URI スキーム
Windows Form でアセンブリリソースを読み込む時は【Properties.Resources.Icon1】のように指定するだけでしたが、WPF ではリソースファイルの場所を指定するためにパック URI スキーム と言う構文を使用する必要があり、以下のような見慣れない構文を使用します。
pack://application:,,,/Subfolder/ResourceFile.xaml
【WPF におけるパッケージの URI】には、リソースファイルの URI を指定するための構文が何種類か書かれていますが、とりあえず以下の 2 種類が分かれば戦えます。
- ローカル アセンブリ リソース ファイル
- 自身のアセンブリ内に配置されたリソースファイルのパスを指定する構文。
ex.pack://application:,,,/Subfolder/ResourceFile.png - 参照アセンブリ リソース ファイル
- 参照しているアセンブリ内に配置されたリソースファイルのパスを指定する構文。
ex. pack://application:,,,/ReferencedAssembly;component/Subfolder/ResourceFile.png
現状のサンプルアプリで UI 要素を含むプロジェクトは以下のような構成になっています。
NavigationTree プロジェクトに追加したリソースファイルをプロジェクト内の VM から呼び出すので『ローカルアセンブリリソースファイル』の構文で指定すれば良さそうな気がするので、src. 6 のようにコンストラクタを変更して動作を確認してみます。
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 | /// <summary>TreeViewItem のImageを取得します</summary> public ReactiveProperty<System.Windows.Media.ImageSource> ItemImage { get; } // ↑ プロパティを追加 /// <summary>コンストラクタ</summary> /// <param name="treeItem">TreeViewItem の元データを表すobject。</param> public TreeViewItemViewModel(object treeItem) { this.Children = new ReactiveCollection<TreeViewItemViewModel>() .AddTo(this.disposables); this.SourceData = treeItem; var imageFileName = string.Empty; switch (this.SourceData) { case PersonalInformation p: this.ItemText = p.ObserveProperty(x => x.Name) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); imageFileName = "user.png"; break; case PhysicalInformation ph: this.ItemText = ph.ObserveProperty(x => x.MeasurementDate) .Select(d => d.HasValue ? d.Value.ToString("yyy年MM月dd日") : "新しい測定") .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; case TestPointInformation t: this.ItemText = t.ObserveProperty(x => x.TestDate) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; case string s: this.ItemText = this.ObserveProperty(x => x.SourceData) .Select(v => v as string) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); break; } if (! string.IsNullOrEmpty(imageFileName)) { var img = new System.Windows.Media.Imaging.BitmapImage( new Uri("pack://application:,,,/Resources/" + imageFileName, UriKind.Absolute)); this.ItemImage = new ReactiveProperty<System.Windows.Media.ImageSource>(img) .AddTo(this.disposables); } } |
いざ実行すると例外:【System.IO.IOException: 「リソース ‘resources/user.png’ を検索できません。」】が飛んで来ます … orz
ですが、上のサンプルコードで指定しているパック URI を src. 7 のように参照アセンブリ リソース ファイルの構文に変更すると正常に実行できます。
1 2 3 4 5 6 7 8 | if (! string.IsNullOrEmpty(imageFileName)) { var img = new System.Windows.Media.Imaging.BitmapImage( new Uri("pack://application:,,,/NavigationTree;component/Resources/" + imageFileName, UriKind.Absolute)); this.ItemImage = new ReactiveProperty<System.Windows.Media.ImageSource>(img) .AddTo(this.disposables); } |
どうも Windows Form とは違って、パック URI に指定するパスはアプリケーション実行時の構造を元に指定する必要があるようです。
『ローカル アセンブリ リソース ファイル』の構文が書けるのはリソースをスタートアッププロジェクトに置いた場合だけのようで、リソースを DLL プロジェクトへ含めた場合は、『参照アセンブリ リソース ファイル』の構文で指定する必要があるようです。
これは、リソースだけを別 DLL に切り出した場合も同様です。
参照アセンブリ リソース ファイルの構文は以下のように指定します。
パック URI のパスを参照アセンブリ リソース ファイル構文で書き直した TreeViewItemViewModel の最終的な全ソースが 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 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 71 72 73 74 75 76 77 78 79 80 81 | using System.Reactive.Linq; using Prism.Mvvm; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace WpfTestApp.ViewModels { public class TreeViewItemViewModel : BindableBase, IDisposable { /// <summary>TreeViewItemのテキストを取得します。</summary> public ReadOnlyReactivePropertySlim<string> ItemText { get; } /// <summary>TreeViewItem のImageを取得します</summary> public ReactiveProperty<System.Windows.Media.ImageSource> ItemImage { get; } /// <summary>子ノードを取得します。</summary> public ReactiveCollection<TreeViewItemViewModel> Children { get; } /// <summary>TreeViewItem の元データを取得します。</summary> public object SourceData { get; } = null; /// <summary>ReactivePropertyのDispose用リスト</summary> private System.Reactive.Disposables.CompositeDisposable disposables = new System.Reactive.Disposables.CompositeDisposable(); /// <summary>コンストラクタ</summary> /// <param name="treeItem">TreeViewItem の元データを表すobject。</param> public TreeViewItemViewModel(object treeItem) { this.Children = new ReactiveCollection<TreeViewItemViewModel>() .AddTo(this.disposables); this.SourceData = treeItem; var imageFileName = string.Empty; switch (this.SourceData) { case PersonalInformation p: this.ItemText = p.ObserveProperty(x => x.Name) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); imageFileName = "user.png"; break; case PhysicalInformation ph: this.ItemText = ph.ObserveProperty(x => x.MeasurementDate) .Select(d => d.HasValue ? d.Value.ToString("yyy年MM月dd日") : "新しい測定") .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); imageFileName = "heart.png"; break; case TestPointInformation t: this.ItemText = t.ObserveProperty(x => x.TestDate) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); imageFileName = "test.png"; break; case string s: this.ItemText = this.ObserveProperty(x => x.SourceData) .Select(v => v as string) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); if (s == "身体測定") { imageFileName = "physical_folder.png"; } else if (s == "試験結果") { imageFileName = "test_folder.png";} break; } var img = new System.Windows.Media.Imaging.BitmapImage( new Uri("pack://application:,,,/NavigationTree;component/Resources/" + imageFileName, UriKind.Absolute)); this.ItemImage = new ReactiveProperty<System.Windows.Media.ImageSource>(img) .AddTo(this.disposables); } /// <summary>オブジェクトを破棄します。</summary> void IDisposable.Dispose() { this.disposables.Dispose(); } } } |
そしてサンプルアプリを実行すると、TreeView に画像が描画されるようになります。
これで TreeView に TreeViewItem が表示されるようになったので、次回はネタを Prism に戻して、TreeViewItem をクリックすると MainWindow の右側へ対応した編集画面を表示(画面遷移)する方法を紹介する予定でしたが、間にエントリを 1 つ挟んで TreeViewItem の IsExpanded をバインドする方法を紹介します。
2020/9/13 追記
新規連載中の .NET Core WPF Prism MVVM 入門 2020 で ReactiveProperty の入門エントリの step: 7 を公開しました。Model ⇔ VM 間を ReactiveProperty で双方向バインドする方法を紹介しています。
又、今回書いたソースも GitHub リポジトリ に上げています。
次回記事「TreeViewItem を MVVM パターンで展開する【extra: 1 WPF Prism】」