2021年12月11日土曜日

WPFのMenuを動的に生成する(もちろんMVVM)

今の時代メニューなんて流行らないんですかね。まあそういう私もRibbon UIでソフトを作ることが多いのであまりメニューを使うことも無いのですが、たまに使った際に動的なMenuを生成しようとしてググったところあまり良い記事が無かったのでここでまとめておきます。

動的なメニュー

こういうやつです。

今回はNIC(Network Interface Card)を列挙してメニュー項目として登録しています。当然NICはパソコンによって搭載しているものが違うため、ソフトウェアを実行するまで名前はわかりません。よって、ソフト実行中に(=動的に)メニュー項目を追加する必要が出てくるのです。

もちろんNICじゃなくても構いません。COMポートでも良いし、「最近使ったファイル」でも良いです。そういうところで動的なメニューが欲しくなることはしばしばあります。 

ItemsSource

さて、もはやこれが答えですが、MenuItemクラスにはItemsSourceプロパティがあります。勘のいいひとは気づくと思いますが、このプロパティに子アイテムのObservableCollectionをバインディングしてあげれば終わりです。基本的な作戦はこれになります。

<DockPanel>
	<Menu DockPanel.Dock="Top">
		<MenuItem Header="File" >
			<MenuItem Header="Open NIC" ItemsSource="{Binding NICs}" />
			<MenuItem Header="Close NIC" />
		</MenuItem>
	</Menu>
	<Grid />
</DockPanel>
public class MainWindowViewModel : ViewModel
{
	MainWindowModel model;
	public MainWindowViewModel()
	{
		model = MainWindowModel.GetInstance();
		NICs = model.NICs;
	}

	public void Initialize()
	{
		model.LoadNICList();
	}

	public ReadOnlyReactiveCollection<string> NICs { get; }

}
public class MainWindowModel
{
	#region Singleton

	static MainWindowModel? instance = null;
	public static MainWindowModel GetInstance()
	{
		if(instance == null)
			instance = new MainWindowModel();
		return instance;
	}

	#endregion

	MainWindowModel()
	{
		_NICs = new ReactiveCollection<string>();
		NICs = _NICs.ToReadOnlyReactiveCollection();
	}

	public void LoadNICList()
	{
		foreach (var capture in LibPcapLiveDeviceList.Instance.OrderBy(p => p.Interface.FriendlyName))
			_NICs.Add($"{capture.Interface.FriendlyName} ({capture.Description})");
	}

	readonly ReactiveCollection<string> _NICs;
	public ReadOnlyReactiveCollection<string> NICs { get; }
}

通知可能コレクションは私はReactivePropertyのReactiveCollection派なのでこんなコードになっていますが、そこは好みに合わせてなんでもいいです。ちなみにMVVMインフラはLivetを使っていて、NICの列挙にはSharpPcapを使っています。

一応コードの解説をしておくと、Windowロード時にViewModelのInitializeメソッドが呼ばれるため、そこでModelにNIC名の取得を指示しています。ModelではNICs通知可能コレクションにNIC名を追加し、ViewModelを介してMenuItemのItemsSourceにバインディングされます。これだけでもう最初に示した画像のような動的なメニューは完成です。

MVVM化

さて、stringのコレクションをItemsSourceにバインドすることで動的にメニューを生成することができましたが、すでに気づいている人もいるかもしれませんが、このままではそれらのメニューを押した時のイベントに対応することができません。さすがにそれではメニューとして使い物にならないでしょう。ほかにも、動的に生成したメニューを無効化したりチェックマークを入れたりしたくなることもあるかと思います。

そこで、MenuItem一つにつき一つのViewModelを持たせる方法で実装をします。MenuItemに限らず、MVVMではMainWindowViewModelで書ききれないもの、個数が変化する要素などがその典型ですが、別のViewModelを作ってバインディングしてしまうのがベストプラクティスです。

public class NICMenuItemViewModel : ViewModel
{
	NICMenuItemModel model;

	public NICMenuItemViewModel(NICMenuItemModel model)
	{
		this.model = model;

		MenuHeader = model.MenuHeader;
		NICName = model.NICName;
		
		MenuCommand = new ReactiveCommand();
		MenuCommand.Subscribe(() => model.Invoke());
	}

	public string MenuHeader { get; }
	public string NICName { get; }

	public ReactiveCommand MenuCommand { get; }
}
public class NICMenuItemModel
{
	Action<string> menuclicked;

	public NICMenuItemModel(string header, string name, Action<string> menuclicked)
	{
		MenuHeader = header;
		NICName = name;
		this.menuclicked = menuclicked;
	}

	public string MenuHeader { get; }
	public string NICName { get; }

	public void Invoke()
	{
		menuclicked?.Invoke(NICName);
	}
}

まずこれがMenuItemに対応するViewModelとModelです。MenuItemに表示されるテキストを表すMenuHeaderプロパティと、その項目に対応するNICNameプロパティを用意しています。通常は通知可能プロパティにすべきですが、不変で困らないのでそこはサボっています。

ViewModelにはさらにMenuCommandというプロパティを用意しており、メニューがクリックされたときはこのコマンドが発動するようにしています。それが押されるとModelのInvokeメソッドが呼ばれ、Model生成時にコンストラクタで渡していたActionが発動するようにしています。この辺りはEventで実装するなどアレンジは自由かと思います。

public class MainWindowModel
{
	#region Singleton

	static MainWindowModel? instance = null;
	public static MainWindowModel GetInstance()
	{
		if(instance == null)
			instance = new MainWindowModel();
		return instance;
	}

	#endregion

	MainWindowModel()
	{
		_NICs = new ReactiveCollection<NICMenuItemModel>();
		NICs = _NICs.ToReadOnlyReactiveCollection();
	}

	public void LoadNICList()
	{
		foreach (var capture in LibPcapLiveDeviceList.Instance.OrderBy(p => p.Interface.FriendlyName))
			_NICs.Add(new NICMenuItemModel($"{capture.Interface.FriendlyName} ({capture.Description})", capture.Name, name => OpenNIC(name)));
	}

	public void OpenNIC(string name)
	{
		//throw new NotImplementedException();
	}

	readonly ReactiveCollection<NICMenuItemModel> _NICs;
	public ReadOnlyReactiveCollection<NICMenuItemModel> NICs { get; }
}
public class MainWindowViewModel : ViewModel
{
	MainWindowModel model;
	public MainWindowViewModel()
	{
		model = MainWindowModel.GetInstance();
		NICs = model.NICs.ToReadOnlyReactiveCollection(p => new NICMenuItemViewModel(p));
	}

	public void Initialize()
	{
		model.LoadNICList();
	}

	public ReadOnlyReactiveCollection<NICMenuItemViewModel> NICs { get; }
}

MainWindowModelではNICロード時にNICMenuItemModelインスタンスを生成しています。MainWindowViewModelではModelのNICsプロパティの中身をModelからViewModelに変換してViewModelのNICsとして保持しています。この辺りはMVVMのお決まりパターンです。

最後にXAMLです。

<DockPanel>
	<Menu DockPanel.Dock="Top">
		<MenuItem Header="File" >
			<MenuItem Header="Open NIC" ItemsSource="{Binding NICs}" >
				<MenuItem.ItemContainerStyle>
					<Style TargetType="MenuItem">
						<Setter Property="Header" Value="{Binding MenuHeader}" />
						<Setter Property="Command" Value="{Binding MenuCommand}" />
					</Style>
				</MenuItem.ItemContainerStyle>
			</MenuItem>
			<MenuItem Header="Close NIC" />
		</MenuItem>
	</Menu>
	<Grid />
</DockPanel>

NICsプロパティをItemsSourceにバインディングしただけではメニューの表示項目はNICMenuItemViewModel.ToString()の実行結果になってしまいます。そこで、ItemContainerStyleを使い、各子要素のHeaderプロパティをViewModelのMenuHeaderプロパティ、CommandプロパティをViewModelのMenuCommandプロパティにバインディングしています。

これで意図したとおり動的に生成したメニューをクリックしたらいろいろ伝わって最終的にMainWindowModelのOpenNICメソッドが呼ばれるようになります。ここで煮るなり焼くなり好きにすれば良いでしょう。

まとめ

今回はMVVMを保ちながらWPFのMenuを動的に生成する方法を見てきました。なんだかついでにMVVMの実装実例を紹介する記事にもなってしまいましたね。

MVVMでは数が減ったり増えたりするものにはItemsSourceに要素用のViewModelを作ってバインディング、これさえ覚えておけばメニュー以外でもいろいろと応用できそうです。