WPF Prism extra: 2 ~ TreeViewItem を MVVM パターンで選択する ~
本編の流れからは少し外れる小ネタを紹介する extra シリーズの 2 回目は TreeView の TreeViewItem を MVVM パターンで選択する方法を紹介します。
このエントリは元々 episode: 7 に書いていた内容を少し修正しただけなので、Prism 入門本編の episode シリーズと同じサンプルを使用します。
尚、この記事は Visual Studio 2017 Community Edition で .NET Framework 4.7.2 以上 と C# + Prism 7.1 + ReactiveProperty を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。
MVVM パターンで TreeView の TreeViewItem を選択する
Prism 入門本編で紹介しているサンプルアプリの TreeView は現状 fig. 1 のように選択項目無しの状態で起動しています。
TreeView の項目をマウスクリック等で選択すれば画面左側に編集 View が表示されますが、手動で選択しないと画面左側に何も表示されないのはアプリの動作としては有り得ないので、画面起動時に TreeView のルート(新しい生徒)を選択して生徒情報編集 View も表示されるように修正します。
WPF の TreeViewItem.IsSelected プロパティで項目を選択する
Windows Form 場合、コードから TreeView の項目を選択するには TreeView.SelectedNode プロパティを設定することが多いと思いますが、WPF の TreeView.SelectedItem は読み取り専用プロパティなので値は設定できません。
そのため検索すると『TreeView を継承して SelectedItem をバインド可能にする』とか『Behavior を作る』等の方法が上位に出てきますが、episode: 5 で紹介したように TreeView の各項目ごとに VM とバインドする構成で作成していれば SelectedItem をバインドしなくても VM から設定できます。
と言うより TreeView や ListView 等の List 系コントロールを MVVM パターンでバインドする場合は、各項目ごとに VM をバインドするのが基本です。
TreeView の SelectedItem をバインドするのではなく、src. 1 のように TreeViewItem.IsSelected をバインドするだけで、SelectedItemChanged イベントも発生しますし TreeViewItem も選択状態になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <UserControl x:Class="WpfTestApp.Views.NavigationTree" ~ 略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> <Grid> <TreeView x:Name="mainTree" ItemsSource="{Binding TreeNodes}"> <TreeView.Resources> <!--2021/6/12 通りすがり様からのご指摘で修正 <Style TargetType="TreeViewItemViewModel">--> <Style TargetType="TreeViewItem"> <Setter Property="IsExpanded" Value="{Binding IsExpanded.Value, Mode=TwoWay}" /> <Setter Property="IsSelected" Value="{Binding IsSelected.Value, Mode=TwoWay}" /> </Style> </TreeView.Resources> ~ 略 ~ </TreeView> </Grid> </UserControl> |
XAML 側は extra: 1 で IsExpanded をバインドした時と同様に IsSelected を Setter Property で追加して、IsExpanded と同じく『Mode=TwoWay』を指定することで View の状態を VM 側でも取得できるようにしています。(双方向バインディング)
これで VM から IsSelected に値を設定できる準備はできたので、後は画面起動時(Loaded イベント)に IsSelected を設定する処理を追加します。
UserControl.Loaded イベントを ReactiveCommand とバインドする
サンプルアプリ起動時に TreeViewItem を選択するために UserControl.Loaded イベントをハンドルする EventTrigger を src. 2 のように追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <UserControl x:Class="WpfTestApp.Views.NavigationTree" ~ 略 ~ xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" xmlns:ri="clr-namespace:Reactive.Bindings.Interactivity;assembly=ReactiveProperty.NET46" ~ 略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> <i:Interaction.Triggers> <i:EventTrigger EventName="Loaded"> <ri:EventToReactiveCommand Command="{Binding Loaded}" /> </i:EventTrigger> </i:Interaction.Triggers> <Grid> <TreeView ItemsSource="{Binding TreeNodes}"> ~ 略 ~ |
Loaded イベントは Command として定義されていない為、episode: 6 で紹介した EventToReactiveCommand を使用して VM とバインドします。
episode: 6 では TreeView のイベントをハンドルしましたが、今回は UserControl のイベントをハンドルするので Interaction.Triggers を UserControl に追加している程度の違いしかありません。
詳しく知りたい場合や、Prism の DelegateCommand とバインドしたい場合は episode: 6 を参考にしてください。
UserControl.Loaded イベントと ReactiveCommand をバインドするには VM を src. 3 のように実装します。
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 | using System.Collections.ObjectModel; using System.Windows; using Prism.Mvvm; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace WpfTestApp.ViewModels { public class NavigationTreeViewModel : BindableBase, IDisposable { /// <summary>TreeViewItem を取得します。</summary> public ReadOnlyReactiveCollection<TreeViewItemViewModel> TreeNodes { get; } ~ 略 ~ /// <summary>SelectedItemChangedイベントハンドラ。</summary> public ReactiveCommand<RoutedPropertyChangedEventArgs<object>> SelectedItemChanged { get; } /// <summary>UserControlのLoadedイベントハンドラ。</summary> public ReactiveCommand Loaded { get; } ~ 略 ~ private TreeViewItemViewModel rootNode = null; ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="data">アプリのデータオブジェクト(Unity からインジェクション)</param> /// <param name="rm">IRegionManager(Unity からインジェクション)</param> public NavigationTreeViewModel(WpfTestAppData data, Prism.Regions.IRegionManager rm) { this.appData = data; this.regionManager = rm; this.rootNode = TreeViewItemCreator.Create(this.appData); var col = new ObservableCollection<TreeViewItemViewModel>(); col.Add(this.rootNode); this.TreeNodes = col.ToReadOnlyReactiveCollection() .AddTo(this.disposables); this.SelectedItemChanged = new ReactiveCommand<RoutedPropertyChangedEventArgs<object>>() .AddTo(this.disposables); this.SelectedItemChanged.Subscribe(e => this.nodeChanged(e)); this.Loaded = new ReactiveCommand() .AddTo(this.disposables); this.Loaded.Subscribe(() => this.rootNode.IsSelected.Value = true); } ~ 略 ~ } } |
episode: 6 と違い、今回はイベントパラメータの値を使用しないので ReactiveCommand を型パラメータ無しで宣言しています。(16 行目)
42 ~ 44 行目は ReactiveCommand の初期化部ですが、38 行目の SelectedItemChanged イベントと異なり Delegate 先を指定するのではなくラムダ式で処理を直接記述しています。
イベントで実行するのは一文だけですが、予め 31 行目の TreeViewItemCreator.Create メソッドの戻り値であるルートノードをフィールド(rootNode)へ退避しておくことで、rootNode の IsSelected.Value に true をセットするだけでルートノードが選択状態になり、38 行目の SelectedItemChanged イベントも併せて実行され、生徒情報編集 Viewも表示されるようになります。
このようなイベントの伝播は Windows Form と同じですね。
今回選択するのはルートノードですが、Linq 等で TreeNodes プロパティ(12 行目)から取得した TreeViewItemViewModel を操作すれば任意の Item に対してノードの選択や子項目の展開も可能です。
src. 4 は TreeView の各項目とバインドしている VM で、src. 4 のように ReactivePropertySlim で宣言している IsSelected に値を設定する時は、src. 3 の 44 行目のように【.Value】を指定する必要があります。
(これは指定しないとコンパイルエラーになるので忘れることはあまり無いと思います)
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 Prism.Commands; using System; using System.Collections.Generic; using System.Linq; 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 ReactivePropertySlim<bool> IsExpanded { get; set; } /// <summary>TreeViewItemが選択されているかを取得・設定します。</summary> public ReactivePropertySlim<bool> IsSelected { get; set; } ~ 略 ~ /// <summary>コンストラクタ</summary> /// <param name="treeItem">TreeViewItem の元データを表すobject。</param> public TreeViewItemViewModel(object treeItem) { ~ 略 ~ this.IsExpanded = new ReactivePropertySlim<bool>(true) .AddTo(this.disposables); this.IsSelected = new ReactivePropertySlim<bool>(false) .AddTo(this.disposables); } ~ 略 ~ } } |
ここまでで実行すると fig. 2 のようにルートノードが選択され MainWindow 右側に編集 View も表示された状態で起動するようになります。
(今回から MainWindow のサイズを 800×600 に変更しました)
WPF の TreeView は Windows Form の TreeView と異なり MultiSelect プロパティは定義されていないため、常に単一の項目しか選択できません。
そのため、選択された TreeViewItem 以外の IsSelected プロパティは全て false に設定されます。
試しに SelectedItemChanged イベントへブレークポイントを張って TreeView の選択項目を変更すると、VM の TreeNodes プロパティの中身は、選択された TreeViewItem の IsSelected プロパティのみ true に設定され、それ以外は全て false に設定されるのが確認できます。
起動直後のフォーカス設定
アプリ起動時に TreeView のルートノードが選択され、MainWindow 左側に編集 View が表示されるようになりましたが、キャプチャを見て分かる通り起動直後はフォーカスがどこにも設定されていないので TreeView にフォーカスをセットするよう修正します。
FocusManager で Load 直後のフォーカスを設定
Windows Form の場合であれば、TabIndex を振るか、Form.Load イベントの最後で、
this.ActiveControl = hogeControl;
等とすればアプリ起動時に任意のコントロールへフォーカスをセットする事が出来ましたが、WPF で初期フォーカスをセットするには『FocusManager 添付プロパティ』を XAML に設定するのがセオリーのようです。
アプリ起動時のフォーカスを TreeView にセットするために NavigationTree.xaml を src. 5 のように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <UserControl x:Class="WpfTestApp.Views.NavigationTree" ~ 略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> <i:Interaction.Triggers> <i:EventTrigger EventName="Loaded"> <ri:EventToReactiveCommand Command="{Binding Loaded}" /> </i:EventTrigger> </i:Interaction.Triggers> <Grid FocusManager.FocusedElement="{Binding ElementName=mainTree}"> <TreeView x:Name="mainTree" ItemsSource="{Binding TreeNodes}"> <TreeView.Resources> <Style TargetType="TreeViewItem"> <Setter Property="IsExpanded" Value="{Binding IsExpanded.Value, Mode=TwoWay}" /> <Setter Property="IsSelected" Value="{Binding IsSelected.Value, Mode=TwoWay}" /> </Style> </TreeView.Resources> ~ 以下略 ~ |
MVVM パターンで開発する場合、コントロールに名前を付ける必要はありませんが、FocusManager でフォーカスを設定するにはコントロールの名前が必要なので、フォーカスを設定するコントロールへ『x:Name=”hoge”』を追加します。(ここでは TreeView に「mainTree」を設定)
他のブログでは「Window クラスへ FocusManager プロパティを添付すればおk!」と書いてある所がほとんどでしたが、サンプルアプリの場合はフォーカスがセットされませんでした。
Prism で UserControl を動的にロードしているのが原因なのか、Grid をネストさせているのが原因なのかはよく分かりませんが、src. 5 のように TreeView を配置している Grid に FocusManager を添付すると fig. 3 のように、TreeView へフォーカスがセットされるようになりました。
Prism で View を動的に表示した後の Tab キーでのフォーカス移動
又、起動後に Tab キーでフォーカスを移動するとフォーカスが消失する箇所があることに気付くかもしれませんが、これは MainWindow に配置している ItemControl がフォーカスを受け取るのが原因なので、src. 6 のように ItemControl.IsTabStop = “false” を設定するとフォーカスが消失しなくなります。
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 | <Window x:Class="WpfTestApp.Views.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:prism="http://prismlibrary.com/" prism:ViewModelLocator.AutoWireViewModel="True" Title="{Binding Title}" Height="600" Width="800" WindowStartupLocation="CenterScreen" IsTabStop="False"> <Grid x:Name="BaseGrid"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <ToolBar x:Name="MainToolBar" Height="25" /> <StatusBar x:Name="MainStatus" Grid.Row="2" Height="25"/> <Grid x:Name="ClientGrid" Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="0.3*"/> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="0.7*"/> </Grid.ColumnDefinitions> <ContentControl x:Name="NaviTree" Grid.Column="0" IsTabStop="False" prism:RegionManager.RegionName="NaviTree" /> <GridSplitter Grid.Column="1" Width="3" HorizontalAlignment="Stretch"/> <ContentControl x:Name="EditorArea" Grid.Column="2" IsTabStop="False" prism:RegionManager.RegionName="EditorArea" /> </Grid> </Grid> </Window> |
フォーカスのハンドリングについては今の所、最適な方法を決めかねていることと、フォーカス制御のネタは当面の本題からは外れてしまうので今回は初期フォーカスを設定する方法だけ紹介します。
TreeViewItem レイアウトの微調整
これでアプリ起動時のフォーカスは TreeView へセットされ、ルートノードが初期選択項目となり、生徒情報編集 View も表示されるようになりました。
ただ、管理人個人的には TreeView の選択枠等が窮屈そうに見えたので src. 7 のように TreeViewItem のレイアウトを微調整しています。
1 2 3 4 5 6 7 8 9 | <TreeView.ItemTemplate> <HierarchicalDataTemplate DataType="{x:Type nt:TreeViewItemViewModel}" ItemsSource="{Binding Children}"> <StackPanel Orientation="Horizontal" Margin="0,3"> <Image Source="{Binding ItemImage.Value}" Width="25" Height="25" /> <TextBlock Text="{Binding ItemText.Value}" VerticalAlignment="Center" Padding="5,0" /> </StackPanel> </HierarchicalDataTemplate> </TreeView.ItemTemplate> |
scr. 7 のようにレイアウトを調整すると分かりにくいかもしれませんが、fig. 4 → fig. 5 のように変わります。
特に説明することはありませんが、上記のようにパディングやマージンを設定するだけで TreeViewItem の上下間隔や画像との距離等が微調整出来るのは XAML の利点だと思います。
見た目は個人の好みなので、調整したい人は参考程度に。
ここで紹介したソースコードも他の Prism 入門の episode と同じく GitHub リポジトリ に上げていますが、このエントリの内容は元々 episode: 7 の一部だったため episode: 7 のソリューション に含まれています。
次回も Prism 入門本編 episode シリーズではなく extra シリーズで TreeViewItem へコンテキストメニューを追加する方法を紹介します。
次回記事「とある TreeView の状況一覧 (Context menu)【extra: 3 WPF Prism】」
あれ、消されてしまった。
×:Style TargetType=”TreeViewItemViewModel”
〇:Style TargetType=”TreeViewItem”
じゃないでしょうか。
返信遅くなって申し訳ありません。
リポジトリは【TreeViewItem】になっていましたが、エントリ側を修正していなかったようなので修正しました!
ご指摘ありがとうございました。
1: NavigationTree.xaml の
は
じゃないでしょうか。