MahApps.Metro の HamburgerMenu から Prism の RequestNavigate で画面を切り替える【case: 1-1 WPF UI Gallery】
WPF MVVM L@bo シリーズも始めたばかりで大して進んでいないにもかからわず新たに別シリーズを立ち上げることにしました。
今回の連載はタイトル通り UI 部品の紹介をメインに進めていく予定で、紹介する UI ライブラリは GitHub で公開されているものをメインに取り上げていきたいと思っています。
そして第 1 回目は WPF Prism episode、WPF MVVM L@bo 両シリーズでも既に紹介済みですが、MahApps.Metro と Material Design In XAML Toolkit を取り上げます。
尚、この連載は Visual Studio 2019 Community Edition で .NET Core 3.1 以上 と C# + Prism 7.2 以降 + ReactiveProperty + Livet を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。
目次
この連載で紹介するサンプルの前提
この連載で紹介するサンプルの前提として、メインで紹介する UI ライブラリは当然インストールしますが、Prism と ReactiveProperty は必ず使用する前提で記事を書いていく予定です。
中には UI ライブラリの使い方だけ知りたいんだ!と思われる方もいると思いますが、管理人は MVVM パターンで WPF アプリを作成するのに Prism と ReactiveProperty 抜きでは作れないので、その辺りはご了承ください。
MahApps.Metro と Material Design In XAML Toolkit
MahApps.Metro と Material Design In XAML Toolkit は WPF アプリを良い感じの UI にしたいと思って調べると必ず(と言って良い程)見かけるライブラリなので知っている人も多いと思いますし、この :: halatio ghost :: でも既に 2 回程メインのネタとして取り上げましたが、突っ込んだ内容はほとんど紹介していないので第 1 回目の題材として取り上げることにしました。
まず、MahApps.Metro と Material Design In XAML Toolkit のインストールは【WPF Prism episode: 19 ~ MahApps.Metro と Material Design In XAML Toolkit たちは Prism でも余裕で生き抜くようです! ~】で紹介しているのでそちらを見てください。
(前章でも書きましたが、サンプルは Prism Blank App プロジェクトテンプレートから作成しています)
WPF Prism episode: 19 の通りに MahApps.Metro と Material Design In XAML Toolkit をインストールすると WPF の標準コントロールのスタイルが自動で Material Design In XAML Toolkit のスタイルに置き換わりますが、両ライブラリ共に独自コントロールも含んでいます。
今回はその中から MahApps.Metro の HamburgerMenu を紹介します。
MahApps.Metro HamburgerMenu
MahApps.Metro の HamburgerMenu は fig. 1 のような動作をするコントロールです。
(View の切り替えは別途実装が必要ですが、後半で紹介しています)
HamburgerMenu とはスマホのアプリや、スマホで表示した Web サイトのメニュー等に使用される場合が多く、fig. 1 左上の 3 本線アイコンがハンバーガーを連想させることからそう呼ばれるようになったようです。
MarApps.Metro の HamburgerMenu は動作プラットフォームが表示領域の狭いスマホではなく PC なので画面左端のバーが常に表示されていると言う違いはありますが、動作は fig. 1 の通り HamburgerMenu に追加したメニュー項目のクリックでアクティブな View を切り替えるような用途等に使用できます。
MahApps.Metro.IconPacks のインストール
MahApps.Metro と Material Design In XAML Toolkit をインストールしたら HamburgerMenu を使用する前準備として fig. 1 のように MahApps.Metro.IconPacks もインストールすることをお勧めします。
(このエントリは MahApps.Metro.IconPacks がインストール済の前提で進めます)
MahApps.Metro.IconPacks は fig. 2 の『ソリューションの Nuget パッケージの管理画面』から【mahapps】で検索すると見つかります。
アイコンを使用したいプロジェクトへ fig. 2 の赤枠で囲んだ【MahApps.Metro.IconPacks】をインストールすると準備は完了です。
又、fig. 2 でインストールした Nuget パッケージと併せて MahApps.Metro.IconPacks の Releases ページ で配布されている IconPacks.Browser もダウンロードしておくとアイコンを探すのが便利になります。
IconPacks.Browser を使うと fig. 3 のように MahApps.Metro.IconPacks に含まれるアイコンをカテゴリ毎に一覧表示したりフィルタで絞り込んだりできます。
ローカルで全てのアイコンを一覧表示したい場合等には便利なのでダウンロードすることをお勧めします。
HamburgerMenu を WPF の Window に配置する際の注意
Visual Studio の WPF デザイナで HamburgerMenu を配置すると fig. 4 のようになります。
fig. 4 では HamburgerMenu のパネルが画面左に表示されていますが、PanePlacement プロパティを変更して右側に表示することもできます。
fig. 1 の動作サンプルのように HamburgerMenu のクリックで View を切り替えたい場合、HamburgerMenu に必要な領域を赤枠の部分のみだと考えるとハマるので気を付けてください。
理由は後述しますが、このサンプルアプリでは HamburgerMenu をブルーで示した枠一杯になるよう配置しています。
HamburgerMenu の 外観
サンプルコードは GitHub リポジトリ にも上げていますが、src. 1 は HamburgerMenu を配置した MainWindow の XAML です。
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 | <metro:MetroWindow x:Class="MahAppsHamburgerMenu.MainWindow" ~ 略 ~ xmlns:metro="http://metro.mahapps.com/winfx/xaml/controls" xmlns:bh="http://schemas.microsoft.com/xaml/behaviors" ~ 略 ~ xmlns:md="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" xmlns:mns="clr-namespace:MahAppsHamburgerMenu.Menus" ~ 略 ~ TitleCharacterCasing="Normal"> <bh:Interaction.Triggers> <bh:EventTrigger EventName="ContentRendered"> <rp:EventToReactiveCommand Command="{Binding ContentRendered}" /> </bh:EventTrigger> ~ 略 ~ </bh:Interaction.Triggers> <Window.Resources> <DataTemplate x:Key="MenuData" DataType="{x:Type mns:HamburgerMenuItemViewModel}"> <StackPanel Orientation="Horizontal"> <iconPacks:PackIconFontAwesome Kind="{Binding IconKind.Value}" Margin="8, 10, 30, 10" Focusable="False" Width="32" Height="32" /> <TextBlock Text="{Binding MenuText.Value}" VerticalAlignment="Center" Style="{StaticResource MaterialDesignHeadline6TextBlock}" /> </StackPanel> </DataTemplate> </Window.Resources> ~ 略 ~ <metro:HamburgerMenu Grid.Column="0" ItemTemplate="{StaticResource MenuData}" OptionsItemTemplate="{StaticResource MenuData}" ItemsSource="{Binding MenuItems}" OptionsItemsSource="{Binding OptionMenuItems}" PaneBackground="{StaticResource MaterialDesignDarkBackground}" OpenPaneLength="300" DisplayMode="{Binding HamburgerMenuDisplayMode.Value}" IsPaneOpen="{Binding IsHamburgerMenuPanelOpened.Value, Mode=TwoWay}" SelectedItem="{Binding SelectedMenu.Value, Mode=TwoWay}" SelectedIndex="{Binding SelectedMenuIndex.Value, Mode=TwoWay}" SelectedOptionsItem="{Binding SelectedOption.Value, Mode=TwoWay}" SelectedOptionsIndex="{Binding SelectedOptionIndex.Value, Mode=TwoWay}"> <metro:HamburgerMenu.Content> <Grid> <Grid.RowDefinitions> <RowDefinition Height="48"/> <RowDefinition/> </Grid.RowDefinitions> <md:ColorZone Mode="Dark" Padding="10"> <TextBlock Text="MahApps.Metro サンプル" HorizontalAlignment="Center" Style="{StaticResource MaterialDesignHeadline6TextBlock}" /> </md:ColorZone> <metro:TransitioningContentControl x:Name="ContentRegion" Grid.Row="1" prism:RegionManager.RegionName="ContentRegion" Transition="RightReplace"/> </Grid> </metro:HamburgerMenu.Content> </metro:HamburgerMenu> </metro:MetroWindow> |
いつもは Window のコンテンツに Grid を置いて、その上にコントロールを配置していますが、src. 1 では HamburgerMenu を Window の Content に直接置いています。
src. 1 のようにその他のコントロールを【HamburgerMenu.Content】内に配置すると HamburgerMenu.DisplayMode の設定値に従って fig. 5 のようにメニューパネルの操作でコントロールの配置エリアが変形するため、HamburgerMenu は他のコントロールを包含するように配置する必要があります。
fig. 5 の通り HamburgerMenu の DisplayMode には大きく分けて【Overlay】と【Inline】の 2 種類があり、Overlay を選択するとメニューパネルは HamburgerMenu.Content の上を覆うように開き、Inline を選択すると HamburgerMenu.Content が開いたメニューパネルに押されるようにレイアウトが縮みます。
残りの【Compact】あり・無しの違いも fig. 5 の通り、Compact 無しを設定するとメニューアイコンバーが非表示になり、スマホのハンバーガーメニューにより近くなります。
どの DislayMode を選ぶかは個人の好みなので、作成するアプリに適した DislayMode を選択すれば良いと思います。
又、HamburgerMenu.IsPaneOpen プロパティを『HamburgerMenu 開閉トグルボタン』にバインドして VM からパネルの開閉を操作できるようにしています。(方法は後述しています)
PackIconFontAwesome コントロール
WPF Prism episode: 20 で『MahApps.Metro では Material Design In XAML Toolkit の PackIcon 程お手軽には扱えない』と書きましたが、MahApps.Metro の SVG アイコン表示コントロールだけパッケージが分かれているとは思ってなかったので嘘を書いてしまいました…
正しくは『MahApps.Metro.IconPacks をインストールすれば Material Design In XAML Toolkit と同じ手軽さで数倍の数のアイコンを扱うことができる』です。
src. 1 の DataTemplate(18 ~ 28 行目)に書いた MahApps.Metro.IconPacks に含まれる PackIcon~(~ にはアイコンパッケージ名が入る)を使用すると enum で定義された Kind プロパティを選択するだけでアイコンを表示でき、加えて MahApps.Metro.IconPacks には Material Design In XAML Toolkit のアイコンも同梱(パッケージ名:Material)されているため、WPF Prism episode: 20 に書いたのとは逆に MahApps.Metro.IconPacks のみを使用する方が良いと思います。
MahApps.Metro.IconPacks に含まれる PackIcon から始まるコントロールは各アイコンパッケージ専用のコントロールなので他パッケージのアイコンは表示できませんが、WPF の場合は MahApps.Metro.PackIconControl を使用すると全パッケージのアイコンを表示することができます。
但し、PackIconControl はパッケージ別 PackIcon のように Kind プロパティを列挙型から選択できないため、src. 2 のようにパッケージ名とアイコン名を組み合わせたリソースキーで指定する必要があります。
1 2 3 | <iconPacks:PackIconControl Kind="{x:Static iconPacks:PackIconBoxIconsKind.LogosAirbnb}" Width="24" Height="24" /> |
HamburgerMenu に設定したその他の外観
HamburgerMenu の外観に関係する項目の内、2 点程補足します。
1 点目は HamburgerMenu.PaneBackground。
WPF Prism episode: 19 に書いた通り Material Design In XAML Toolkit とセットで使用する場合、WPF 標準コントロールは Material Design In XAML Toolkit のスタイルが優先されますが、HamburgerMenu のように MahApps.Metro 独自のコントロールは MahApps.Metro のスタイルが設定されます。
そのため、fig. 5 のように Material Design In XAML Toolkit の ColorZone 等と交わるように配置すると微妙に色が合わなかったので PaneBackground に【{StaticResource MaterialDesignDarkBackground}】を設定して合わせています。
2 点目は HamburgerMenu.OpenPaneLength。
fig. 5 で開けたメニューパネル最下層の『このサンプルアプリについて』の表示幅が結構長く文字切れしてしまうため、OpenPaneLength プロパティを変更して表示幅を調整しています。
引き続きメニュー項目の追加方法を紹介します。
HamburgerMenu のメニュー項目
HamburgerMenu のメニュー項目は ItemsSource にバインドした List の内容が ItemTemplate に設定した DataTemplate に従って描画されます。
要するに HamburgerMenu は ListBox や TreeView と同じく ItemsControl から派生したコントロールなので、WPF Prism episode シリーズの WPF Prism episode: 5 や WPF Prism episode: 14 で紹介したのと全く同じ方法でメニュー項目を設定することができます。
ListBox や TreeView との違いは DataTemplate を HamburgerMenu タグ内に書けないと言う点なので、DataTemplate は src. 1 のように Resources タグ内に定義しています。
このサンプルで表示しているメニュー項目は WPF Prism episode: 5 や episode: 14 で紹介した内容と同じく src. 3 の VM に定義した ObservableCollection<HamburgerMenuItemViewModel> 型の MenuItems プロパティの内容を表示しています。
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 | using System; using System.Collections.ObjectModel; using HalationGhost.WinApps; using MahApps.Metro.Controls; using MahApps.Metro.IconPacks; using MahAppsHamburgerMenu.Menus; using Prism.Regions; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace MahAppsHamburgerMenu { /// <summary>MainWindowのVM</summary> public class MainWindowViewModel : HalationGhostViewModelBase { ~ 略 ~ /// <summary>HamburgerMenuで選択しているメニュー項目を取得・設定します。</summary> public ReactivePropertySlim<HamburgerMenuItemViewModel> SelectedMenu { get; set; } ~ 略 ~ /// <summary>HamburgerMenuで選択しているオプションメニュー項目を取得・設定します。</summary> public ReactivePropertySlim<HamburgerMenuItemViewModel> SelectedOption { get; set; } ~ 略 ~ /// <summary>HamburgerMenuのメニュー項目を取得します。</summary> public ObservableCollection<HamburgerMenuItemViewModel> MenuItems { get; } = new ObservableCollection<HamburgerMenuItemViewModel>(); /// <summary>HamburgerMenuのオプションメニュー項目を取得します。</summary> public ObservableCollection<HamburgerMenuItemViewModel> OptionMenuItems { get; } = new ObservableCollection<HamburgerMenuItemViewModel>(); ~ 略 ~ /// <summary>HamburgerMenuのメニュー項目選択通知イベントハンドラ。</summary> /// <param name="item">選択したメニュー項目を表すHamburgerMenuItemViewModel。</param> private void onSelectedMenu(HamburgerMenuItemViewModel item) { if (item == null) return; if (string.IsNullOrEmpty(item.NavigationPanel)) return; this.regionManager.RequestNavigate("ContentRegion", item.NavigationPanel); } ~ 略 ~ /// <summary>デフォルトコンストラクタ。</summary> /// <param name="regionMan">IRegionManager。</param> /// <param name="winService">IMainWindowService。</param> public MainWindowViewModel(IRegionManager regionMan, IMainWindowService winService) : base(regionMan) { ~ 略 ~ this.initialilzeMenu(); this.SelectedMenu = new ReactivePropertySlim<HamburgerMenuItemViewModel>(null) .AddTo(this.disposable); this.SelectedMenu.Subscribe(i => this.onSelectedMenu(i)); ~ 略 ~ this.SelectedOption = new ReactivePropertySlim<HamburgerMenuItemViewModel>(null) .AddTo(this.disposable); this.SelectedOption.Subscribe(o => this.onSelectedMenu(o)); ~ 略 ~ } /// <summary>HamburgerMenuのメニュー項目を初期化します。</summary> private void initialilzeMenu() { this.MenuItems.Add(new HamburgerMenuItemViewModel(PackIconFontAwesomeKind.BugSolid, "バグ", "BugPanel")); this.MenuItems.Add(new HamburgerMenuItemViewModel(PackIconFontAwesomeKind.UserSolid, "ユーザ", "UserPanel")); this.MenuItems.Add(new HamburgerMenuItemViewModel(PackIconFontAwesomeKind.CoffeeSolid, "珈琲", "CoffeePanel")); this.MenuItems.Add(new HamburgerMenuItemViewModel(PackIconFontAwesomeKind.FontAwesomeBrands, "サイコー!", "AwesomePanel")); this.OptionMenuItems.Add(new HamburgerMenuItemViewModel(PackIconFontAwesomeKind.CogsSolid, "設定", "SettingPanel")); this.OptionMenuItems.Add(new HamburgerMenuItemViewModel(PackIconFontAwesomeKind.InfoCircleSolid, "このサンプルアプリについて", "AboutPanel")); } } } |
WPF Prism episode: 5 や episode: 14 との違いは、メニュー項目を XML ファイル等から読み込むのではなくコンストラクタ内でインスタンスを作成している点と、ItemsSource にバインドしている MenuItems プロパティを ReactiveCollection ではなく ObservableCollection で定義している点です。
メニュー項目にバインドする HamburgerMenuItemViewModel はコンストラクタで渡された値をメニュー項目にバインドしているだけなのでソースコードは GitHub リポジトリ で見てください。
又、HamburgerMenu は通常の ItemsSource に加えてメニューパネル下部に表示される OptionsItemsSource も用意されていて fig. 5 では『設定』、『このサンプルアプリについて』が OptionsItemsSource で上部、下部の項目は個別に設定可能です。
このサンプルアプリは MahApps.Metro の作者である punker76 さん が公開している MahApps.Metro の HamburgerMenu サンプル を管理人なりにアレンジしたものです。
元のサンプル は UserControl の切り替えを独自に実装していますが、このサンプルアプリは Prism のテンプレートから作成しているので WPF Prism episode: 6.5 で紹介した Prism の IRegionManager.RequestNavigate を呼び出せば楽勝!と考えて src. 3 のようにHamburgerMenu.SelectedItem プロパティを Subscribe した onSelectedMenu(32 ~ 40 行)内で RequestNavigate を呼び出しましたが、指定した View(UserControl)が表示されない症状に悩まされました。
調べてみるとお馴染みの stack overflow に同じ症状の質問 が…
どうも HamburgerMenu.Content 上に配置したコントロールは遅延作成されるらしく、Prism の RegionManager は遅延作成された Region を 認識できないため RequestNavigate が空振りする(そんな Region ねーよ)のが原因のようです。
これを回避するには Region を手動で RegionManager に登録する必要があるようなので、MainWindow のコードビハインドに src. 4 の処理を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | using CommonServiceLocator; using MahApps.Metro.Controls; using Prism.Regions; namespace MahAppsHamburgerMenu { /// <summary> /// Interaction logic for MainWindow.xaml /// </summary> public partial class MainWindow : MetroWindow { public MainWindow() { InitializeComponent(); RegionManager.SetRegionName(this.ContentRegion, "ContentRegion"); var regionMan = ServiceLocator.Current.GetInstance<IRegionManager>(); RegionManager.SetRegionManager(this.ContentRegion, regionMan); } } } |
src. 4 の処理を追加するだけで他は何も変更することなく Prism の IRegionManager.RequestNavigate で fig. 1 のように View が切り替わるようになります。
MVVM なのにコードビハインドに書くのはダメだろ!と思う人は居るかもしれませんが、最近の管理人は『UI だけに閉じた処理ならコードビハインドに書いてもOK!』と考えるように変わっています。
Model が絡む処理をコードビハインドに書くのは今でも『アカンやろ』と思っていますが、UI 制御専用の処理で、他プロジェクトへの使い回しもできないし、コードビハインドに書いた方が早いし、明らかに分かりやすい場合はむしろコードビハインド歓迎!なので、src. 4 は正に良い例だと思います。
MVVM だからコードビハインドに処理を絶対書いてはいけないと言うのは間違いで『アプリケーション層やデータ層のクラス(MVVM パターンでの Model)がかかわる処理をコードビハインドに書いてコントロールと密結合になるのは MVVM パターンではない』が正しい表現ではないでしょうか。
src. 4 をコードビハインドに書かない方法を探して悩むより、『VM に実装するのは無理そうだしメリットも無い!』と判断して先に進むのが正解だと思います。
但し、複数人で開発する場合に『コードビハインドへ処理を書いても良い』等と言ってしまうと、個々の理解度やスキルによっては勝手な解釈で Model が絡む処理をコードビハインドにがっつり書いてしまう人が出て来ることも予想できるのは悩ましい所です…
Prism の IRegionManager.RequestNavigate で表示する Module
今回のサンプルは動的に表示する View(UserControl)を格納した Prism Module を少し調子に乗り過ぎて 3 つも作ってしまいました。
- StartUpView
Window Load 直後に表示する View のみを格納。 - ChildViews
ItemsSource にバインドしているメニュー項目を選択した場合に表示する View を格納。 - OptionViews
OptionsItemsSource にバインドしているメニュー項目を選択した場合に表示する View を格納。
StartUpView Module 以外の View は切り替わったことが確認できるようにタイトルとアイコンを置いているだけですが、右下に置いた MahApps.Metro.IconPacks のアイコンをグラデーションブラシで塗ってみたので興味があれば GitHub リポジトリで XAML を見てください。(プロパティウィンドウから値を少し弄るだけでできます)
HamburgerMenu.Content 上の Transition
HamburgerMenu には ContentTransition プロパティがありますが、Prism の RequestNavigate で View を切り替えた場合は何を設定しても動作は変わりませんでした。(どうすれば反応するのかは調べていません)
ですが、View 切り替え時に Transition 効果を追加したい場合は WPF MVVM L@bo #1 でも紹介した MahApps.Metro の TransitioningContentControl を使用すれば可能です。
WPF MVVM L@bo #1 でも紹介した通り標準の ContentControl を TransitioningContentControl に差し替えても Prism の RequestNavigate は問題なく動作しますし、View 切り替え時に Transition 効果も追加されます。
Transition 効果の種類は WPF MVVM L@bo #1 で紹介していないのでここで紹介しておきます。
fig. 7 は TransitioningContentControl.Transition を Normal → Default → Up → Down の順に変更した場合の Transition です。
Startup View 以外はコントロールが右側にしか存在しないので効果が少し分かり辛いかもしれません…
fig. 8 は残りの Transition を Right → Left → Right Replace → Left Replace の順に変更した場合の Transition です。
Replace の有無で何が変わるのかイマイチ分かりませんが、おそらくフェード効果の有無?だと思っています。
後、fig. 7、8 で紹介していない Custom Transition はおそらく StoryBoard が設定できるんだと思いますが、StoryBoard を殆ど理解できていないので試していません。
Prism Module 間(VM 間)のデータ連携
前章で HamburgerMenu 等のプロパティをサンプルアプリ上で変更していました。
サンプルアプリは、HamburgerMenu は Shell アセンブリの Window、ComboBox は Module アセンブリの UserControl に配置していて 異なる XAML、異なるアセンブリに配置したコントロール間で値をやり取りしています。
HamburgerMenu と ComboBox を同一の XAML(同一アセンブリ)に置いた場合は XAML だけでも値を連動することができますが、別々の XAML、別々のアセンブリに分かれている場合は値が連動する仕組みを別途作成する必要があります。
Prism の モジュール間(VM 間)で値をやり取りする方法として Prism には EventAggregator が用意されていますが、管理人個人的には好みではありません。(と言うより動作的にイマイチ納得できない所があります)
好きになれない理由の一つに EventAggregator は Subscribe している箇所に一斉ブロードキャストするんじゃね? と言う予想です。実際に EventAggregator の Subscriber を複数配置して試した訳ではないので間違っているかもしれませんが…
管理人的に好みの方法は Model で値を仲介する方法で、今回のサンプルアプリもその方法を採用しています。
VM 間を Model で仲介する
具体的には src. 5 のような Service と言う名前を付けた Model を仲介して値をやり取りします。
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 | using HalationGhost; using MahApps.Metro.Controls; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace MahAppsHamburgerMenu { /// <summary> /// MainWindowの値を中継するサービスを表します。 /// </summary> public class MainWindowService : BindableModelBase, IMainWindowService { /// <summary>HamburgerMenuのDisplayModeを取得・設定します。</summary> public ReactivePropertySlim<SplitViewDisplayMode> HamburgerMenuDisplayMode { get; set; } /// <summary>HamburgerMenuのIsPaneOpenを取得・設定します。</summary> public ReactivePropertySlim<bool> IsHamburgerMenuPanelOpened { get; set; } /// <summary>TransitioningContentControlのTransitionを取得・設定します。</summary> public ReactivePropertySlim<TransitionType> ContentControlTransition { get; set; } /// <summary> /// コンストラクタ。 /// </summary> public MainWindowService() { this.HamburgerMenuDisplayMode = new ReactivePropertySlim<SplitViewDisplayMode>(SplitViewDisplayMode.CompactOverlay) .AddTo(this.Disposable); this.IsHamburgerMenuPanelOpened = new ReactivePropertySlim<bool>(false) .AddTo(this.Disposable); this.ContentControlTransition = new ReactivePropertySlim<TransitionType>(TransitionType.Default) .AddTo(this.Disposable); } } } |
src. 5 の MainWindowService が継承している IMainWindowService はプロパティを定義しているだけなので、コードを確認したい場合は GitHub リポジトリで直接見てください。
この MainWindowService は Shell や Module から参照されるため別プロジェクトに分けていて、src. 6 のように App.RegisterTypes で DIコンテナ(ここではdryloc)へインスタンスを登録しています。
src. 5 の MainWindowService を含むプロジェクトは Prism Module テンプレートから作成していますが、理由は UI 系の型を使用するには UI 系のプロジェクトテンプレートから作成する方が参照を追加する必要もなく楽だからです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | using System; using System.Reflection; using System.Windows; using MahAppsHamburgerMenu.Options; using Prism.Ioc; using Prism.Modularity; using Prism.Mvvm; namespace MahAppsHamburgerMenu { /// <summary> /// Interaction logic for App.xaml /// </summary> public partial class App { ~ 略 ~ /// <summary>DIコンテナへオブジェクトを登録します。</summary> /// <param name="containerRegistry">DIコンテナ登録用のIContainerRegistry。</param> protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterSingleton<IMainWindowService, MainWindowService>(); } } } |
DI コンテナに RegisterSingleton すると、どの VM にもインジェクションできて static なクラスのようにインスタンスが共有できるので、まず src. 7 のように MainWindow(操作される側)にインジェクションしてプロパティにバインドします。
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 | using System; using System.Collections.ObjectModel; using HalationGhost.WinApps; using MahApps.Metro.Controls; using MahApps.Metro.IconPacks; using MahAppsHamburgerMenu.Menus; using Prism.Regions; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace MahAppsHamburgerMenu { /// <summary>MainWindowのVM</summary> public class MainWindowViewModel : HalationGhostViewModelBase { /// <summary>TransitioningContentControlのTransitionを取得・設定します。</summary> public ReadOnlyReactivePropertySlim<TransitionType> ContentControlTransition { get; } /// <summary>HamburgerMenu.IsPaneOpenを取得・設定します。</summary> public ReactivePropertySlim<bool> IsHamburgerMenuPanelOpened { get; set; } /// <summary>HamburgerMenu.DisplayModeを取得・設定します。</summary> public ReadOnlyReactivePropertySlim<SplitViewDisplayMode> HamburgerMenuDisplayMode { get; } ~ 略 ~ /// <summary>MainWindoサービスを表します。</summary> private IMainWindowService mainWindowService = null; /// <summary>デフォルトコンストラクタ。</summary> /// <param name="regionMan">IRegionManager。</param> /// <param name="winService">IMainWindowService。</param> public MainWindowViewModel(IRegionManager regionMan, IMainWindowService winService) : base(regionMan) { this.mainWindowService = winService; ~ 略 ~ this.ContentControlTransition = this.mainWindowService.ContentControlTransition .ToReadOnlyReactivePropertySlim() .AddTo(this.disposable); this.HamburgerMenuDisplayMode = this.mainWindowService.HamburgerMenuDisplayMode .ToReadOnlyReactivePropertySlim() .AddTo(this.disposable); this.IsHamburgerMenuPanelOpened = this.mainWindowService.IsHamburgerMenuPanelOpened .AddTo(this.disposable); ~ 略 ~ } ~ 略 ~ } } |
これで MainWindowService が HamburgerMenu と TransitioningContentControl の各プロパティにバインドされるので MainWindowService のプロパティを変更すると HamburgerMenu と TransitioningContentControl の各プロパティも変更されるようになります。
又、src. 7 では View の操作を受け取る必要がある IsHamburgerMenuPanelOpened 以外のプロパティは ReadonlyReactiveProperty(読み取り専用)で定義しています。
そして src. 8 のように StartUpPanel 側の VM にも MainWindowService をインジェクションして各プロパティをバインドします。
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 | using HalationGhost.WinApps; using MahApps.Metro.Controls; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace MahAppsHamburgerMenu { /// <summary> /// MainWindowプロパティ操作ViewModelを表します。 /// </summary> public class StartUpPanelViewModel : HalationGhostViewModelBase { /// <summary>TransitioningContentControlのTransitionを取得・設定します。</summary> public ReactivePropertySlim<TransitionType> ContentControlTransition { get; set; } /// <summary>HamburgerMenuのIsPaneOpenを取得・設定します。</summary> public ReactivePropertySlim<bool> IsHamburgerMenuPanelOpened { get; set; } /// <summary>HamburgerMenuのDisplayModeを取得・設定します。</summary> public ReactivePropertySlim<SplitViewDisplayMode> HamburgerMenuDisplayMode { get; set; } /// <summary>MainWindoサービスを表します。</summary> private IMainWindowService mainWindowService = null; /// <summary>コンストラクタ。</summary> /// <param name="winService"></param> public StartUpPanelViewModel(IMainWindowService winService) { this.mainWindowService = winService; this.HamburgerMenuDisplayMode = this.mainWindowService.HamburgerMenuDisplayMode .AddTo(this.disposable); this.IsHamburgerMenuPanelOpened = this.mainWindowService.IsHamburgerMenuPanelOpened .AddTo(this.disposable); this.ContentControlTransition = this.mainWindowService.ContentControlTransition .AddTo(this.disposable); } } } |
内容的には MainWindowViewModel とほとんど変わりませんが、コントロールの操作結果を受け取るため、全てのプロパティを双方向で定義している点が異なります。
EventAggregator を大して使ったことが無いので自信をもって断言できるわけではありませんが、このような VM 間で値をやり取りする場合に EventAggregator を使用するとかなり煩雑な処理になりそうな気がします。
ですが、ReactiveProperty は元々 ReactiveProperty 同士で相互バインディング可能に作られているため、通常の Model とバインドする場合と同じ方法で実装できます。
VM 間の値受け渡しに新たな仕組みや作法を覚える必要もなく src. 7、8 のようにシンプルに記述できます。
この例では中間の Service クラスのプロパティに ReactiveProperty を使用していますが、変更通知プロパティ(INotifyPropertyChanged プロパティ)であればおそらく同じことができるような気はします。
(そもそも管理人は ReactiveProperty 無しで WPF アプリを作ったことが無いので断言できません…)
INotifyPropertyChanged でもできそうですが、Model と相互バインディングしたい場合は ReactiveProperty の導入をお勧めします。
Enum を手軽に ComboBox 等の ItemsControl にバインドする
fig. 5、7、8 では HamburgerMenu.DisplayMode や TransitioningContentControl.Transition を変更するために ComboBox から列挙値を選択できるようにしています。
ComboBox も ListBox や TreeView 等と同じ ItemsControl を継承したコントロールなので、Item 用の VM を作って ObservableCollection<T> 型のプロパティとバインドすれば問題なく項目を追加できますが、項目の表示内容が単なる文字列だけの場合は正直面倒なので、何か方法がないか探してみました。
検索して辿り着いたのは Prism のメンテナーでもお馴染みの Brian Lagunas さんのブログ記事『A Better Way to Data Bind Enums in WPF – BRIAN LAGUNAS』です。
上記サイトで紹介されているコードの丸パクリですが、src. 9 のようなマークアップ拡張クラスを作成すると指定した enum の全列挙子を項目として生成くれるようです。
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 | using System; using System.Windows.Markup; namespace HalationGhost.WinApps { /// <summary>enum値を生成するためのマークアップ拡張。</summary> public class EnumBindingSourceExtension : MarkupExtension { /// <summary>対象のenum型を表します。</summary> private Type _enumType; /// <summary>生成するenumの型を取得・設定します。</summary> public Type EnumType { get { return _enumType; } set { if (value != this._enumType) { if (null != value) { Type enumType = Nullable.GetUnderlyingType(value) ?? value; if (!enumType.IsEnum) throw new ArgumentException("Type must be for an Enum."); } this._enumType = value; } } } /// <summary>生成した値を取得します。</summary> /// <param name="serviceProvider">マークアップ拡張機能のサービスを提供できるサービス プロバイダーのヘルパー。</param> /// <returns>生成した値を表すobject。</returns> public override object ProvideValue(IServiceProvider serviceProvider) { if (null == this._enumType) throw new InvalidOperationException("The EnumType must be specified."); Type actualEnumType = Nullable.GetUnderlyingType(this._enumType) ?? this._enumType; Array enumValues = Enum.GetValues(actualEnumType); if (actualEnumType == this._enumType) return enumValues; Array tempArray = Array.CreateInstance(actualEnumType, enumValues.Length + 1); enumValues.CopyTo(tempArray, 1); return tempArray; } /// <summary>デフォルトコンストラクタ。</summary> public EnumBindingSourceExtension() { } /// <summary>コンストラクタ。</summary> /// <param name="enumType">生成するenumの型を表すType。</param> public EnumBindingSourceExtension(Type enumType) => this.EnumType = enumType; } } |
管理人はマークアップ拡張についてはほとんど理解できていませんが、src. 9 のようなマークアップ拡張クラスを作成して、ItemsControl.ItemsSource に指定すると設定した enum の全メンバを生成してくれます。
src. 9 のマークアップ拡張は enum であれば何でも指定できるので汎用的に使用するため HalationGhostWpfViewModels プロジェクトに置いています。
XAML は src. 10 のように ComboBox の ItemsSource に指定します。
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 | <UserControl x:Class="MahAppsHamburgerMenu.StartUpPanel" ~ 略 ~ xmlns:metro="http://metro.mahapps.com/winfx/xaml/controls" ~ 略 ~ xmlns:hg="clr-namespace:HalationGhost.WinApps;assembly=HalationGhostWpfViewModels" ~ 略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> <Grid> ~ 略 ~ <Grid> ~ 略 ~ <ComboBox Grid.Row="0" ~ 略 ~ ItemsSource="{Binding Source={hg:EnumBindingSource EnumType={x:Type metro:SplitViewDisplayMode}}}" SelectedItem="{Binding HamburgerMenuDisplayMode.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> ~ 略 ~ <Grid> ~ 略 ~ <ComboBox Grid.Row="0" ~ 略 ~ ItemsSource="{Binding Source={hg:EnumBindingSource EnumType={x:Type metro:TransitionType}}}" SelectedItem="{Binding ContentControlTransition.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> </Grid> ~ 略 ~ </Grid> ~ 略 ~ </Grid> </UserControl> |
src. 10 の 14、21 行目のように ItemsSource に enum の型と併せて指定するだけで、fig. 7、8 のように列挙子が ComboBox の項目として表示され、SelectedItem からは指定した enum 型で選択値を取得できるようになるので非常に便利ですし、一度作成しておくとどんなプロジェクトにも使い回すことができます。
尚、上で紹介した A Better Way to Data Bind Enums in WPF – BRIAN LAGUNAS には src. 9 を更に発展させ、属性に指定した列挙子名の一覧も生成できるコード例も紹介されています。
ここではそこまでは紹介しないので、興味があれば上記のサイトを見てください。
予想よりもかなりボリュームが大きくなってしまいましたが、今回はここまでで終了です。
次回も引き続き MahApps.Metro と Material Design In XAML Toolkit のコントロールを取り上げる予定です。
尚、余談ですが、今回のサンプルアプリは DI コンテナにいつもの Unity ではなく Dryloc を使用してみましたが、Prism 内で使用するための実装は Unity と全く同じでした。
(Prism が DI コンテナを抽象化しているので当然ですが…)
いつもの通りここで紹介したソースコードは GitHub に上げていますが、WPF Prism episode、WPF MVVM L@bo シリーズとは別の新規リポジトリ にアップしています。
次回記事「MetroWindow とそのプロパティに連動する Behavior【case: 1-2 WPF UI Gallery】」
src 4. のコード 部分は、Prism 8 へのアップグレードで、「using CommonServiceLocator;」が無くなった為、エラーとなりました。
https://stackoverflow.com/questions/64885458/prism-unity-wpf-problem-unityservicelocatoradapter-is-missing-after-update
Stackoverflow で同様の質問を参考にして、↓の様に変更しました。
var regionMan = (IRegionManager) Prism.Ioc.ContainerLocator.Container.Resolve(typeof(IRegionManager));
RegionManager.SetRegionManager(ContentRegion, regionMan);
確かに CommonServiceLocator が削除されたので Prism 8 だとエラーになりますね…
ただ、この記事は内容が古くて大幅に書き換える予定です。
それに最新版の MahApps.Metro に同梱されている HamburgerMenu を使用すればここに書いた方法を採らなくても Region を設定して RequestNavigate で画面遷移できました。
ご指摘はありがたいんですが、大幅に改変予定なのでとりあえず記事の内容はそのままにさせてください。