WPF episode: 7 ~ ReactiveProperty がバインドできないのはどう考えても Navigation が悪い!~

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

前回は、コントロールの任意のイベントを EventToReactiveCommand を使用して VM で EventArgs まで含めて受け取る方法と、Prism の IRegionManager.RequestNavigate を使用して画面を遷移する方法を紹介したので、今回は遷移先の VM で編集用データを受け取った後、ReactiveProperty を経由して編集用データと View をバインドする手順を紹介します。

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

TreeView.SelectedItem の設定

現時点でアプリを起動すると、TreeView が選択項目なしの状態(TreeView.SelectedItem == null)で起動するので、UserControl.Loaded イベントでルートノードを選択するよう変更します。
Loaded イベントのハンドルは前回記事で紹介した方法と同じく、ReactiveProperty に含まれる EventToReactiveCommand と ReactiveCommand を使用します。

以下は Loaded イベントを EventToReactiveCommand でハンドルするよう修正した NavigationTree.xaml です。

今回は TreeView ではなく UserControl のイベントをハンドルするので、Interaction.Triggers を UserControl に追加していますが、追加した内容は前回記事で TreeView.SelectedItemChanged イベントをハンドルした場合と変わりません。

VM 側です。

今回、イベントパラメータの値は不要なので、型パラメータ無しの ReactiveCommand を 16 行目に宣言しています。40 ~ 42 行目も前回記事で紹介した TreeView.SelectedItemChanged イベントの初期化部と変わりませんが、処理自体が 1 行なので Delegate せず、直接処理を記述しています。

Loaded.Subscribe() メソッドでは、TreeViewItemCreator.Create() で作成した ルートノードの IsSelected プロパティを true に設定しています。
この IsSelected プロパティは前回記事で IsExpanded プロパティをバインドした際、一緒に追加しておいたプロパティで View の TreeViewItem.IsSelected プロパティと双方向で同期しています。
IsSelected.Value としているのは、IsSelected プロパティは ReactivePropertySlim 型のプロパティなので、値を代入する場合は Value プロパティへセットする必要があるためです。

上記のとおり修正して実行すると、下のキャプチャのようにルートノードが選択された状態で起動するようになります。(キャプチャを取る度にリサイズするのが面倒なので、今回から MainWindow のサイズを 800×600 に変更しました)

fig.1 起動直後のサンプルアプリ MainWindow

Windows Form と同様に、ルートノードの IsSelected プロパティを true にセットすると、TreeView.SelectedItemChanged イベントが実行され、MainWindow 右側のリージョンに生徒情報編集 View が表示されます。

Windows Form で同様の処理を行うには、TreeView.SelectedItem プロパティを操作する場合が多いと思いますが、WPFの TreeView.SelectedItem プロパティは読み取り専用なので、VM とバインドしても VM 側から値を設定することはできません。
そのため、ググると『TreeView を継承したカスタムコントロールを作成する』とか『Behavior を作る』等の方法が多く見つかりますが、TreeViewItem.IsSelected をバインドすることで、TreeView.SelectedItem をバインドしたのと同等の処理が可能になり、TreeView.SelectedItemChanged イベントも同時に実行されます

試しに、VM の SelectedItemChanged イベントへブレークポイントを張って、TreeView の選択ノードを変更して、VM の TreeNodes プロパティ(List)の値を確認すると、選択されたノードの IsSelected プロパティのみが true で他ノードの IsSelected プロパティは false になっているのが確認できるはずです。

起動直後のフォーカス設定

前章の変更でアプリ起動時に TreeView のルートノードが選択されるようになりましたが、キャプチャを見て分かる通り、起動直後にはどこにもフォーカスが設定されていないので、TreeView にフォーカスがセットされるよう変更します。

Windows Form の場合であれば、TabIndex を振るか、Form.Load イベントの最後で、
this.ActiveControl = hogeControl;
等とすれば、アプリ起動時に任意のコントロールへフォーカスをセットする事が出来ましたが、WPF で初期フォーカスをセットするには、『FocusManager 添付プロパティ』を XAML に設定するのがセオリーのようです。

アプリ起動時のフォーカスを TreeView にセットするので、NavigationTree.xaml へ以下のハイライト部分を追加します。

MVVM パターンで開発する際、通常はコントロールに名前を付ける必要はありませんが、FocusManager でフォーカスを設定する場合は、対象のコントロールにName プロパティを設定する必要があるので、対象のコントロールへ『x:Name=”hoge”』を追加します。
ここでは TreeView の名前に「mainTree」を指定しています。

他のブログでは「Window クラスへ FocusManager プロパティを添付すればおk!」と書いてある所がほとんどでしたが、このアプリではフォーカスがセットされませんでした。
Prism で UserControl を動的にロードしているのが原因なのか、Grid をネストさせているのが原因なのかはよく分かりませんが、TreeView を配置している Grid に FocusManager を添付すると下図のように、TreeView へフォーカスがセットされるようになりました

fig.2 起動時にフォーカスをセットした MainWindow

又、起動後に Tab キーでフォーカスを移動するとフォーカスが消失する箇所が何か所かあるのに気付くと思います。これは MainWindow に配置している ItemControl がフォーカスを受け取るのが原因なので、以下のように ItemControl.IsTabStop = “false” を設定するとフォーカスが消失しなくなります。

フォーカスのハンドリングについては今の所、最適な方法を決めかねていることと、フォーカス制御のネタは当面の本題からは外れてしまうので、現時点では初期フォーカスのセットまでにします。

TreeViewItem レイアウトの微調整

これでアプリ起動時に、TreeView へフォーカスがセットされ、SelectedItem にルートノードがセットされ、生徒情報編集 View も表示されるようになりました。
ただ、管理人個人的には TreeViewItem が窮屈そうに見えたのでレイアウトを微調整しています。

特に説明することはありませんが、上記のようにパディングやマージンを設定するだけで TreeViewItem の上下間隔や画像との距離等が微調整出来るのは XAML の利点だと思います。
見た目は個人の好みなので、調整したい人は参考にしてください。

Prism の INavigationAware を使用したパラメータの受け渡し

今回の本題でもある、編集データの受け渡しですが、episode: 2 ~ 本記事 を順番に(順番通りじゃなくても構いませんが)全て読んでいただいている前提で文章を続けますので、出来れば読んでもらえるとありがたいです。

下図はサンプルアプリ内の現時点におけるデータオブジェクト配置図で、WpfTestApp データオブジェクト本体は Unity コンテナと NavigationTree Module 内に同一インスタンスが存在している状態です。

fig.3 サンプルアプリ データ配置図

又、現時点でデータオブジェクト内の各データは TreeView の VM が保持しているので、fig.3 の矢印で示しているように、これらの各データを EditorViews(Prism Module)内の各編集用 VM に渡してそれぞれの View へ表示します。

episode: 1 でも書きましたが、Prism は MVVM フレームワークと言うより、互いの関係が疎に保たれたコンポーネントを組み合わせたアプリケーションを作成するための【複合アプリケーション作成フレームワーク】が真の姿なので、コンポーネント間でデータを受け渡すための仕組みを複数備えています。

データを受け渡すための仕組みの 1 つ目は、episode: 4 で紹介した DI コンテナ(このサンプルでは Unity)で、これは DI コンテナに登録したオブジェクトを、主にコンストラクタで受け取る方法です。
コンストラクタで受け取ったデータはクラス内の private スコープの変数として保持するようなデータの受け渡しに適していると言えます。

2つ目の仕組みが、今回紹介する Prism.Regions.INavigationAware インタフェースです。
この方法は Prism の Navigation を使用して画面遷移する際に、渡したいデータをメソッドのパラメータにセットして受け渡す方法です。
このデータ受け渡し方法も DI コンテナの場合と同様、受け取ったデータをクラス内の private スコープの変数として保持するデータの受け渡しに適していると言えます。

先に、呼び出し側の NavigationTreeViewModel を実装します。

呼び出し元の NavigationTreeViewModel.SelectedItemChanged メソッドを上記のように、キーワードと渡したいデータをセットして実行すると、INavigationAware インタフェースを経由して遷移先画面へデータが渡されるようになります。

RequestNavigate() メソッドにセットしたパラメータの値を受け取るには、表示する遷移先画面用の VM(ここでは EditorViews プロジェクトに含まれる全 VM)を INavigationAware インタフェースから派生するよう変更して、以下の 3 メソッドを実装します。

  • void OnNavigatedTo(NavigationContext)
  • void OnNavigatedFrom(NavigationContext)
  • bool IsNavigationTarget(NavigationContext)

遷移先の View が表示されたタイミングにパラメータを受け取るには以下の 32 行目のように OnNavigatedTo メソッド内でキーワードを指定してパラメータを取得します。
ここでは、OnNavigatedTo メソッドの紹介だけなので、他の 2 つのメソッドは例外が飛ばない記述に変更しておきます。

取得したデータオブジェクト(パラメータ)のプロパティを VM のプロパティとして ReactiveProperty を経由して公開し、以下のように View とバインドしてデータを編集画面に表示します。

コンストラクタ以外での ReactiveProperty の初期化

実は上記のコードでは実行しても以下のように、編集 View へデータは表示されません

fig.4 バインディング失敗例

本来は episode: 4 で紹介した以下の DataLoader で設定した初期値が表示されるはずですが、バインド先の TextBox はブランクのままです。

ブレークポイントを張って確認すれば分かりますが、OnNavigatedTo メソッドで受け取ったデータオブジェクトには値がセットされているのが確認できるので、画面遷移でのデータ受け取りは問題なさそうです。

では何故、このような状況になっているかと言うと、以下のかずきさんのブログにも書かれているように、ReactiveProperty の初期化はコンストラクタで行うのが基本だと言うのが理由のようです。

ReactivePropertyは、コンストラクタで組み立てるのが一番スムーズなので出来るだけ外部からDIするものはプロパティでインジェクションするのではなくて、コンストラクタでインジェクションしましょう。そうすると、コンストラクタで準備万端状態になるので、ReactivePropertyの組み立てがスムーズに行えます。
ReactivePropertyとPrism for WinRTとの連携 - かずきのBlog@hatena

WPF でバインディングを設定した Window を起動すると、DataContext プロパティにデータソースがセットされたタイミングで、View 側からデータソースへ表示用の初期値を取得しに行きますが、上記のコードは ReactiveProperty の初期化 を VM のコンストラクタで行っていない為、View が取得する表示用の初期値は null 値のまま、fig.4 のように何も表示されないと考えられます。

これまでの連載記事で紹介した中から解決策を考えると、DI コンテナ(Unity)に個人情報データオブジェクトをインジェクションしてもらい、コンストラクタで ReactiveProperty を初期化する方法が考えられます。
事実、その方法であればデータは正常に表示されます。

ただし、それはこの個人情報編集画面のみの話で、『身体測定情報』と『試験結果情報』の 2 画面についてはデータオブジェクトをコンストラクタへインジェクションする方法では問題があります。
episode: 4 に書いた通り、この 2 データは List なので、1 つの View から複数のインスタンスを生成して、List 内の各データオブジェクトに対応する必要があります
そのため、今まで紹介してきたように単純にコンストラクタへパラメータを追加する方法は採れません。
せっかく OnNavigatedTo メソッドで対象のインスタンスが受け取れているので、この方法で何とかデータを表示する方法が無いか、いろんな手を試してみました。

まずは、正攻法的な方法として、ReactiveProperty の初期化がコンストラクタで必要なのであれば、コンストラクタでは、new した仮のオブジェクトをバインドしておいて、OnNavigatedTo メソッドで受け取ったデータオブジェクトに差し替える方法を試すため、以下のようなコードを書いてみました。

結果は『fig.4 バインディング失敗例』から変わらず、データは表示されませんでした… orz
気を取り直してググると、該当しそうなのは、お馴染みのかずきさんのブログから 2 件のみ。

上記 2 パターンとも実際に書いて試してみましたが、View にデータは表示されませんでした… orz
(新規のプロジェクトに丸コピして試した訳ではないので、管理人が書き間違えた可能性が高い気もします)
製作者の人がこれでできると書かれた内容で動作しなかったので、かなり諦めムードになりましたが、念のためコードを元に戻して、XAML をデバッグモードでチェックしてみました。

まず、下のキャプチャの赤枠で囲った部分【選択を有効にする】を選択します。

fig.5 XAML デバッグ:選択を有効にする

そして、以下のように値を確認したいコントロール【生徒氏名】を選択します。

fig.6 XAML デバッグ:コントロールを選択

対象のコントロールを選択すると、以下のように、IDE の【ライブ ビジュアル ツリー ウィンドウ】で対象のコントロールが選択された状態で開きます。

fig.7 XAML デバッグ:ライブ ビジュアル ツリー

そして、対象のコントロールを右クリックして、[プロパティの表示]を選択すると、下のような【ライブ プロパティ エクスプローラー】が開きます。

fig.8 XAML デバッグ:ライブ プロパティ エクスプローラー

DataContext に値が!?
XAML のデバッグにも慣れてないので、断言するのに躊躇いはありますが、これって実際はちゃんとバインドされているが、View に表示されていないだけじゃないかと予想しました。
その線でもう少し粘ることにして、とりあえず、現状を整理します。

コントロールにバインドしている VM のプロパティはデータオブジェクト(Model)のプロパティを ReactiveProperty を経由して公開していますが、View の DataContext には当然、単なるプロパティと認識されています。
そして、さっきも書きましたが、バインドが設定されたコントロールは DataContext が設定された時に初期値を取りに行って、それ以降はデータソースから INotifyPropertyChanged.PropertyChanged イベントが通知された場合のみ表示内容を更新する… と言うのがデータバインディングの仕組みのはずです。

つまり、バインドしているコントロールが表示を更新するのは、プロパティの値変更通知(INotifyPropertyChanged.PropertyChanged イベント)のみなので、PropertyChanged イベントを呼び出しているプロパティの Setter を呼ばないと表示が更新されません。
そのため、データソースの大元であるオブジェクトを別のインスタンスに変更しても各プロパティの Setter を呼ばない限りは表示が更新されないことが原因と考えられます。

であれば、オブジェクトを差し替えた後、各プロパティの変更通知を View に送れば良さそうな気がして方法を探すと、ReactiveProperty.ForceNotify() と言うメソッドがありました!
が、メソッドを呼んでみても、相変わらず TextBox はブランクのままでした… orz

万策尽きたかと諦めかけた所、VM は Prism の BindableBase を継承しているので、変更通知も当然持っていそうだと思って調べたら…ありました!BindableBase.RaisePropertyChanged メソッド
これを以下のように全プロパティ分呼び出してみます。

OnNavigatedTo メソッドは TreeView で項目を選択する度に呼び出されるため、1 度バインドするオブジェクトをセットした後は、ReactiveProperty の初期化を実行したくないので、保持する private 変数が null の場合のみ実行されるようにしています。
(この辺りがコンストラクタで初期化するパターンとの違いですね)

ここまで書いて実行すると、ようやく編集用データが View に表示されるようになりました。

fig.9 バインディング成功例

Prism の BindableBase を継承していない場合でも通常、VM は INotifyPropertyChanged インタフェースを継承しているはずなので、バインドしている全プロパティの PropertyChanged イベントを呼び出せば同じことができるはずです。

又、以下のように編集画面の XAML へ以下の UpdateSourceTrigger = “PropertyChanged” を追加します。

そしてサプルアプリを実行すると、TreeViewItem と 編集画面 View が同一のインスタンスを共有している為、編集画面で生徒氏名を編集すると、以下のキャプチャのように TreeViewItem にも値が即時に反映されるようになります。

fig.10 編集結果リアルタイム反映

以上の方法で、Prism の Navigation 経由で受け取ったパラメータを編集 View にバインドして表示できるようになりました。
又、サンプルコードは載せませんが、VM のコンストラクタで new したオブジェクトを OnNavigatedTo メソッドで別のインスタンスに差し替えるパターンも、差し替えた後で RaisePropertyChanged メソッドを呼び出すと View の表示が更新されることも確認しました。
(但し、オブジェクトを差し替える場合は、コンストラクタ・OnNavigatedTo メソッドの両方で ReactiveProperty の初期化処理を呼ぶ必要がありました。)

他の View も同じように実装すれば、パラメータで渡したデータオブジェクトが編集 View へ表示されるようになりますが、かなり長くなったので他の View についてはリポジトリで確認してください。
今回は、INavigationAware インタフェースの内、OnNavigatedTo メソッドしか紹介できなかったので、残りの 2 つについては次回以降の記事で紹介したいと思っています。

今回作成したコードもいつも通り GitHub リポジトリへ上げておきます。

この記事が、2018 年最後の episode になります。
次回は 2019 年 1 月中に公開できれば良いな… と考えています。

あわせて読みたい

コメントを残す

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