WPF Prism extra: 3 ~ とある TreeView の状況一覧 (Context menu) ~

前回記事「TreeViewItem を MVVM パターンで選択する【extra: 2 WPF Prism】」

 

Prism 入門本編の展開の都合上、コンテキストメニューが必要になるため、今回の extra は TreeView へコンテキストメニューを追加する方法を紹介します。
この extra: 3 の内容は元々 Prism 入門本編の episode: 8 の一部でしたが、Prism からは離れた内容なので extra シリーズの単発記事として分割することにしました。

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

WPF TreeViewItem のコンテキストメニュー

Prism 入門本編で公開を予定している内容の都合上、サンプルアプリから新規データを追加する機能が必要になりました。
新規データの追加は fig. 1 のようにコンテキストメニューから追加する UI が適していると思うので TreeView にコンテキストメニューを追加する方法を紹介します。

fig.1 コンテキストメニュー

XAML にコンテキストメニューを追加する

追加するコンテキストメニューは TreeView ではなく TreeViewItem 用のコンテキストメニューなので、src. 1 のように ItemTemplate 内の StackPanel へコンテキストメニューを追加します。

src. 1 は、元々 episode: 8 で紹介していたコードです。

今回 extra シリーズとして単体記事にするために再度コンテキストメニューについて調べた時に気が付きましたが、コンテキストメニューを追加するには src. 1 のようにコントロールへ『StackPanel.ContextMenu』を直接記述するパターンと、コンテキストメニューをリソースへ登録しておいて StaticResource として読み込む 2 つのパターンがあります。

検索上位記事の多くはコンテキストメニューをリソースから読み込むコード例を紹介していますが、『何故リソースから読み込むか?』等の理由も書かれていませんし、どんなメリットがあるかも分からないので調べてみました。



コンテキストメニューをリソースから読み込む

検索上位の記事で見かけることが多い、コンテキストメニューをリソースから読み込むパターンはコンテキストメニューをリソースに登録しておくと他のコントロールや他の UserControl 等で使い回せてリソースの消費量が節約できると言う理由で紹介しているのであれば理解できますが、単一のコントロールにコンテキストメニューを表示する場合でもリソースから読み込んでいる例が大半なのは納得できませんでした。
使い回さないのにリソースから読み込む利点は無いように感じます。

XAML を src. 2 のように変更するとコンテキストメニューはリソースから読み込まれるようになります。

TreeView のみで使用するコンテキストメニューなので TreeView のリソースへ追加しています。
TreeViewItem は ItemTemplate から生成されるので ItemTemplate 内へ実体を設定するより、リソースから読み込んで使い回す方がメモリの使用量が少なく済みそうだと予想してメモリ使用量を計測してみました。

メモリ使用量の計測

実際のメモリ使用量がどのように変化するか『デバッガーなしでメモリ使用量を分析する – Visual Studio Docs』を参考に Visual Studio のパフォーマンスプロファイラでメモリ使用量を計測しました。

メモリ使用量計測時はサンプルアプリを fig. 2 のように操作します。

fig.2 メモリ使用量を計測した時の操作

実際にコンテキストメニューを表示する前に、5 つの View を Load し終えてからコンテキストメニューを表示する手順で計測しました。これは、未 Load の View があると表示する度にメモリ使用量も増加するだろうと考えたからです。

全 View を Load し終わった後でコンテキストメニューを表示すればコンテキストメニュー自体のメモリ使用量が計測できるだろうと予想しました。

コンテキストメニューをリソースに置いた場合の計測結果

fig. 3 ~ 5 はコンテキストメニューをリソースから読み込んだ場合の計測結果で、fig. 3 は全 View を表示し終わった直後のメモリ使用量です。

fig.3 全 View 表示後(リソースから読込)

fig. 4 はコンテキストメニューを最初に表示した直後のメモリ使用量です。

fig.4 コンテキストメニューを最初に表示した直後(リソースから読込)

そして fig. 5 が全 TreeViewItem のコンテキストメニューを表示し終わった後のメモリ使用量です。

fig.5 全 TreeViewItem のコンテキストメニュー表示後(リソースから読込)

fig. 4 でコンテキストメニューを最初に表示してからメモリ使用量は以下のように遷移していました。

49.3 MB → 49.4 MB → 50.2 MB → 50.3 MB → 50.4 MB → 50.5 MB → 50.6 MB → 50.7 MB → 50.8 MB → 50.9 MB

50.2 MB は 21 秒過ぎた辺りで下から 4 つ目のコンテキストメニューを開いた直後のメモリ使用量で、4 つ目のコンテキストメニューを開いて以降は 0.1 MB ずつ増えていき、サンプルアプリ終了直前は 50.9 MB でした。
コンテキストメニューを開く以外の操作をしていないのに 0.1 MB ずつ増加した理由は分かりません。

単純計算ではコンテキストメニューを 5 回開くと 1.6 MB のメモリを消費するようです。
デバッグモードで計測しているからか使用量の多さに驚きますが、差を取って比較するだけなので問題は無いはずです。

コンテキストメニューをコントロールへ直接追加した場合の計測結果

続いて fig. 6 ~ 8 は src. 1 のように ItemTemplate 内の StackPanel へ直接コンテキストメニューを追加した場合の計測結果で、fig. 6 は全 View を表示し終わった直後のメモリ使用量です。

fig.6 全 View 表示後(コントロールに直接設定)

fig. 7 はコンテキストメニューを最初に表示した直後のメモリ使用量です。

fig.7 コンテキストメニューを最初に表示した直後(コントロールに直接設定)

そして fig. 8 は全 TreeViewItem のコンテキストメニューを表示し終わった後のメモリ使用量です。

fig.8 全 TreeViewItem のコンテキストメニュー表示後(コントロールに直接設定)

コンテキストメニューを直接コントロールに設定した場合、fig. 7 でコンテキストメニューを最初に表示してからメモリ使用量は以下のように遷移していました。

49.4 MB → 49.5 MB → 49.9 MB → 50.0 MB → 51.1 MB → 51.2 MB → 51.3 MB → 51.4 MB

リソースから読み込んだ場合と同じくこちらも 51.1 MB が 20 秒過ぎた辺りで下から 4 つ目のコンテキストメニューを開いた直後のメモリ使用量です。
4 つ目のコンテキストメニューを開いた以降は 0.1 MB ずつ増えていき、サンプルアプリ終了直前のメモリ使用量は 51.4 MB でした。



メモリ使用量計測結果のまとめ

GC のタイミング等もあるので一概には言えないと思いますが、単純計算では以下のような結果になります。

  • リソースから読み込んだ場合のコンテキストメニュー 1 つ辺りのメモリ使用量は 0.32 MB
  • コントロールに直接設定したコンテキストメニュー 1 つ辺りのメモリ使用量は 0.4 MB

上記の結果からリソースから読み込む場合の方がメモリ使用量は少なくて済みそうと考えられます。

コンテキストメニュー 1 つあたりの差は 0.08 MB ですが、アプリ全体でのメモリ使用量もリソースから読み込む場合の方が 0.5 MB 少なく済みます。
当てになるかは分かりませんが、計測結果から判断すると TreeView のような List 系コントロールの項目にコンテキストメニューを設定する場合は、リソースから読み込む方がメモリ使用量が少なくなると考えられるためリソースから読み込む方法を採用します。

又、コンテキストメニューを UserControl のリソースに登録した場合もついでに計測してみると以下のような結果になりました。
※ パフォーマンスプロファイラのキャプチャ等は貼りません

49.3 MB → 49.4 MB → 49.6 MB → 49.8 MB → 49.9 MB → 50.1 MB → 50.9 MB → 51.1 MB

管理人的に差が出るとは思っていませんでしたが、親のリソースに追加した場合の方がメモリ使用量が多くなるようなので、使い回し等しない場合は、コントロール自体のリソースに追加した方がメモリ使用量は少なくて済むと考えられます。

TreeViewItem のコンテキストメニューを MVVM パターンでバインドする

メモリ使用量の計測結果からリソースへ登録したコンテキストメニューを読み込む方法を採用したので、NavigationTree.xaml は src. 3 のように書きます。(内容は src. 2 と同じです)

MenuItem の Header に指定している『新規データの追加(_A)』のアンダーバーは【アクセラレータキー】を表します。Windows Form では「&A」と書いていましたが、WPF でアクセラレータキーを設定する場合はアンダーバーに変更されています

XAML を src. 3 のように変更すればコンテキストメニューの表示は出来るので、次はコンテキストメニューの Command を VM へバインドします。

コンテキストメニューの DataContext

コンテキストメニューの MenuItem は ICommandSource を継承しているので episode: 6 で紹介した System.Windows.Interactivity を使用しなくても Command をバインドするだけでメニューの Click イベントを処理できます

ですが、以下のような気になる記述を見つけました。

データコンテキストの継承は論理ツリーによって行われますが、ContextMenuやToolTipといった新しくウィンドウが開くものにおいては、そこで論理ツリーが途切れるため、データコンテキストが継承されなくなります。

こういうケースでデータコンテキストを引っ張ってくるには、PlacementTargetプロパティで新しいウィンドウ(ContextMenu)を表示する元となった要素(TreeViewItem)を参照するのが定番です。

TreeView のコンテキスト メニューへ独自コマンドをバインドするには - Microsoft コミュニティ

つまり、コンテキストメニューの Command を src. 4 のように記述しないとバインドできないと書かれています。

バインドを記述する前に、実際にコンテキストメニューから DataContext が見えないのかデバッグモードで XAML を確認してみました。

fig.9 コンテキストメニューの DataContext

fig. 9 の通り、コンテキストメニューの DataContext は TreeViewItemViewModel に設定されているので、TreeViewItemViewModel へは普通に Command をバインドできそうです。

では、上記で引用した内容は嘘かと言うとそうではありません。
このサンプルアプリは episode: 5 で紹介した通り TreeViewItem 毎に VM とバインドしているのでコンテキストメニューの DataContext も TreeViewItemViewModel に設定されます。

ですが、src. 4 のような記述は TreeView にコンテキストメニューを表示する方法を扱っている他のブログでもよく見かける記述ですが、TreeViewItem 毎に VM とバインドしていれば普通に Command をバインドする時と同じ記述にできます
これは、コンテキストメニューをリソースから読み込む場合でも、コントロールに直接追加した場合でも同じです。

管理人的には List 系コントロールを MVVM パターンでバインドする場合、VM をネストした構成で作成するのが本来だと思いますが、そのような構成を説明しているページはあまり見た覚えがありませんし、上で引用した先のページに貼られているコードのように src. 4 のような記述を紹介しているサイト大半です。
これは、項目ごとに VM とバインドせず、TreeViewItem に直接 Model をバインドする例で紹介している所がほとんどだからだと思います。

極端なことを言えば、管理人的には List 系コントロールだけではなく View に配置したコントロールごとに VM を作成してバインドするのもアリだと思っていますが、又いずれ記事にまとめられたらと思っています。

但し処理の都合上、親であるコントロール(TreeView)の VM とバインドしたい場合は他ブログに書かれているように『RelativeSource FindAncestor』を指定すれば親の DataContext とバインドできます。

コンテキストメニューを親コントロールと項目のどちらの VM にバインドすべきかは処理の都合もあるので一概には言えませんが、管理人的には項目にバインドする方が良いと思っているので、ここではコンテキストメニューを TreeViewItem の VM とバインドする方で話を進めます。

コンテキストメニューの Command をバインドする VM が特定できたので、後は VM へ ReactiveCommand か DelegateCommand を定義します。

コンテキストメニューの IsEnabled を ReactiveCommand から設定する

今回追加するコンテキストメニューは常に Click できる訳ではなく、fig. 10 の『身体測定』や『試験結果』のようにカテゴリを示す項目を選択している場合のみコンテキストメニューが有効になるようにします。

fig.10 コンテキストメニューの有効・無効

XAML に配置した ICommandSource とバインドする ICommand.CanExecute から返す値は ICommandSource の IsEnabled に反映されます

episode: 6 で紹介した Prism の DelegateCommand を使用する場合は CanExecute ~メソッドから bool を返すだけなので、 episode: 6 のコード例を参考にしてもらえれば書けると思いますが、ReactiveCommand を使用する場合は全く違います。

ReactiveCommand で CanExecute を設定するには IObservable<bool> から生成する必要があります。
分かりにくい表現だと思いますが、言い換えるとコントロールの IsEnabled に設定したい IObservable<bool> から ReactiveCommand を生成するとコントロールの IsEnabled にも反映される仕組みになっています。

具体的には TreeViewItemViewModel を src. 5 のように変更します。

コンテキストメニューの『新規データの追加』にバインドする AddNewDataCommand を初期化する際(37 ~ 44 行目)に IObservable<bool> から ToReactiveCommand すると、IObservable<bool> の値が CanExecute に返されます

ReactiveCommand の生成元になる IObservable<bool> は ReactiveProperty や ReactivePropertySlim が IObservable を継承しているため、ReactiveProperty(Slim)<bool> から ToReactiveCommand できます

そのため、CanExecute の判定条件が 1 つだけの場合は src. 6 のように書くことができます。

判定条件が複数の場合は、Reactive Extension(Rx)で IObservable<bool> を返すよう書けば良いので、一例として src. 5 では Reactive.Bindings.Extensions 名前空間に含まれる CombineLatestValuesAreAllTrue メソッドを使用して指定した全ての条件が true の場合のみ以降の処理が実行される実装にしています。

この連載ではお馴染みのかずきさんの『MVVMをリアクティブプログラミングで快適にReactivePropertyオーバービュー』でも IObservable<bool> を返す色々な例が紹介されてるので参考になります。

src. 5 では『カテゴリ項目選択されている場合のみ』と言う複数の条件で判定するため、IsSelected、IsCategory 2 つのプロパティを指定していて、IsSelected プロパティは extra: 2 で追加したプロパティで自分自身が選択されているかを表し、今回新たに追加した IsCategory プロパティで自分自身がカテゴリ項目かどうかを判定しています。

サンプルアプリを実行してコンテキストメニューを表示すると、fig. 10 のようにカテゴリノードが選択されている場合のみ【新規データの追加メニュー】が有効になっているはずです。
続いて AddNewDataCommand で TreeViewItem に子項目を追加します。



TreeViewItem に子項目を追加する

今回 TreeViewItem へ子項目を追加するためにメソッドの位置やパラメータ等の変更を少し多めに入れましたが、ここで紹介するような内容では無いので興味がある方は GitHub リポジトリ で見てください。
大きく変更したソースは以下の通りです。

上記の変更で子項目を簡単に追加できるようにしたので、追加したコンテキストメニューから呼び出す Command は src. 7 のように、生成した子項目を Children プロパティへ Add するだけで済んでいます。

ReactiveCollection はメンバの追加・削除等を View へ通知してくれるので、Add するだけで fig. 11 のように子項目が追加されるようになります。

fig.11 新規身体測定データの追加

TreeViewItem を右クリックで選択

これでコンテキストメニューが表示され『新規データの追加』から子項目も追加されるようになりましたが、右クリックだけでは TreeView の選択項目が移動しないのに気が付くと思います。

これは WPF だけでなく Windows Form の TreeView も同じ動きですが、Windows エクスプローラーでは右クリックで選択項目が移動するのに TreeView コントロールでは元の位置から動かないので非常に使いにくいと思います。

管理人は VB 4.0 の頃から VisualStudio 系を触っていて、その頃からずっと感じていますが、Microsoft は何故コントロールの標準機能をデフォルトで組み込まないんですかね…?
「全ての機能取り込め!」とは思いませんが、TreeViewItem を右クリックで選択するとか、TextBox がフォーカスを受け取ると全テキストを自動で選択するとか、OS 標準動作の中でもポピュラーな動作位は対応してくれても良いんじゃないかと思います…
自分でアプリを作る時も、仕事で新しいプロジェクトに入った時も、毎回似たようなコードを書き過ぎて、さすがにもう飽きました…

と、愚痴はこの位にして、このアプリでも Windows エクスプローラーと同じように右クリックでもツリーノードが選択できるように XAML を src. 8 のように変更します。

そして、NavigationTree のコードビハインドを src. 9 のように変更すると右クリックで TreeViewItem が選択されるようになります。

episode: 1 で「コードビハインドには何も書かない原理主義を目標に進める」と書きましたが、この部分では断念しました…

少し言い訳を書かせてもらうと…
Blend.Interactivity に含まれる ChangePropertyAction が使えそうだと思っていて、XAML に記述するだけでイケると予想していましたが、実際に試してみると「依存プロパティ以外は対象外です」と実行時例外が出ました… orz

依存プロパティや Behavior を作ればできそうですが、Prism とはかけ離れた内容になりそうなので却下しました。
コードビハインドにイベントハンドラを書くのは抵抗がありましたが、よく考えると、この NavigationTree は Prism の Module であり、それに含まれる UserControl です

MVVM パターンで作成するとしても、追加した UserControl の内部動作をコードビハインドに書くのは当たり前なので、これはこれでアリじゃないかと思えてきました。
とは言っても、コードビハインドに書くのは、あくまでもユーザのアクションに対する表示のフィードバック等だけでビジネスロジック等には全く関わらない処理に限定すべきだと思います

それに、管理人がこのサンプルアプリを実際のアプリとして作成するなら、おそらく TreeView を継承したカスタムコントロールで作成すると思いますが、カスタムコントロールの作成も Prism とはかけ離れた内容ですし、何より現時点では管理人自身カスタムコントロールを作ったことが無いのでコードビハインドでの制御に逃げました。

src. 8、9 のように記述して実行すると右クリックで TreeViewItem が選択され、コンテキストメニューも問題なく表示されるはずです。

予想以上のボリュームになりましたが、extra: 3 はここで終了です。

ここで紹介したソースコードも他の Prism 入門の episode と同じく GitHub リポジトリ に上げていますが、このエントリの内容は元々 episode: 8 の一部だったため episode: 8 のソリューション に含まれています。
extra: 2、3 と extra シリーズが続きましたが、次回は Prism 入門本編の episode シリーズに戻ります。

 

次回記事「画面遷移のパラメータたちが INavigationAware から来るそうですよ?【episode: 7 WPF Prism】」

 

 

おすすめ

コメントを残す

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

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