ラベル ReactiveProperty の投稿を表示しています。 すべての投稿を表示
ラベル ReactiveProperty の投稿を表示しています。 すべての投稿を表示

2024年10月13日日曜日

WPFでReactivePropertyを使う際のお作法

今回はWPFでReactivePropertyを使う際のお作法を備忘録的に紹介していこうと思います。

ReactivePropertyとはReactive Extensions (Rx)ベースで作られたプロパティを提供するライブラリです。INotifyPropertyChangedを実装しているためViewModelにそのまま使用することができ、また、LINQを使って値の伝搬を表現することができるため、Statefulなアプリを簡単に実装することができる、非常に強力なライブラリです。

1. 導入

Nugetからダウンロードできます。

ReactiveProperty.WPFは入れなくても一応使えないことは無いのですが、あとでハマることになりますので、WPFアプリなら思考停止でとりあえず入れてしまいましょう。

2. UIスレッドへの転送設定

WPFには単一のスレッドからしかUI要素にアクセスできないという仕様があります。ですので、ViewModelにてイベントをそのスレッドに転送してやらねばならないのですが、ReactivePropertyには便利なことに特定のスレッドでイベントを発生させる機能があります。

さて、転送するにあたって、当然ながらどのスレッドがUIスレッドかということをライブラリに教え込まなければなりません。それは、App.xaml/App.xaml.csに以下のコードを追加することで行います。

<Application x:Class="WpfTest.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfTest"
             StartupUri="MainWindow.xaml"
             Startup="Application_Startup">
    
    <Application.Resources>
    </Application.Resources>
</Application>
public partial class App : Application
{        
    private void Application_Startup(object sender, StartupEventArgs e)
    {
        ReactivePropertyScheduler.SetDefault(new ReactivePropertyWpfScheduler(Dispatcher));
    }
}

アプリのスタートアップ時に、Application.DispatcherをReactivePropertySchedulerとして登録するという作業になります。ReactivePropertyWpfSchedulerはReactiveProperty.WPFに入っているクラスで、WPF向けのスケジューラーです。

3. ReactivePropertyとReactivePropertySlim

さて、これで準備が整いましたので、実際に使用していきます。

ReactiveProperty(ライブラリ)には、ReactiveProperty(クラス)とReactivePropertySlim(クラス)があります。ReactivePropertyは色々な機能を持っているが故にパフォーマンスが悪めなのですが、ReactivePropertySlimは機能を絞ってパフォーマンスをかなり改善しています。そのReactivePropertySlimで削られた機能として、主に以下の2つがあります。

  • UIスレッドへの自動転送機能
  • 入力値のバリデーション機能

いずれも、ViewModelとしては必要な機能です。ですので、基本的にViewModelではReactivePropertyModelではReactivePropertySlimを使用すると良いでしょう。

public class MainWindowViewModel : BindableBase
{
    readonly IMainWindowModel model;
    public MainWindowViewModel(IMainWindowModel model)
    {
        this.model = model;

        Text = model.Text.ToReactivePropertyAsSynchronized(p => p.Value);
    }

    public ReactiveProperty<string> Text { get; }
}
public interface IMainWindowModel
{
    IReactiveProperty<string> Text { get; }
}
public class MainWindowModel : IMainWindowModel
{
    public MainWindowModel()
    {
        Text = new ReactivePropertySlim<string>();
        Text.Subscribe(p => System.Diagnostics.Debug.WriteLine(p));
    }

    public ReactivePropertySlim<string> Text { get; }
    IReactiveProperty<string> IMainWindowModel.Text => Text;
}

このコードでは、Prism前提でModelとViewModelを疎結合にするため、間にIMainWindowModelを挟んでいます。そこまでの抽象化が必要ない場合は適宜コードを読み替えてください。

ReactivePropertyとReactivePropertySlimを双方向に同期させるためにはToReactivePropertyAsSynchronized()を使います。片方向(Model→ViewModel)の場合はToReadOnlyReactiveProperty()で良いでしょう。

--------------

以上です。その他の細かい使い方は公式ドキュメントを参照ください。

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を作ってバインディング、これさえ覚えておけばメニュー以外でもいろいろと応用できそうです。

2015年11月18日水曜日

ListViewItemのイベントをMVVMスタイルのプログラムで受信する

WPFでListViewを使うときというのは、たいてい、複数のプロパティを持つようなデータが複数あるときですよね。それゆえに、たいていItemsSourceに各々のデータに対応するViewModelをバインディングして、ListView.ViewにDisplayMemberBindingなどを設定したGridViewColumnを設定したGridViewを設定して使うことになると思います。
内部的には、そのデータに対応するViewはListViewItemになっていて、ItemsSourceで指定したVMがそのDataContextに指定されています。そして、ListViewItemのプロパティならば、ListView.ItemContainerStyleを使ってスタイルを指定することでいじることができます。例えばこんな感じです。

<ListView ItemsSource="{Binding Data}" >
    <!-- 中略 -->
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem" >
            <Setter Property="ContextMenu" >
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Open" Command="{Binding OpenCommand}" />
                        <MenuItem Header="Close" Command="{Binding CloseCommand}" />
                    </ContextMenu>
                </Setter.Value>                            
            </Setter>
        </Style>
    </ListView.ItemContainerStyle>
</ListView>

このXAMLは、リストビューで表示している各項目に対してコンテキストメニューを表示させるようにしています。このスタイルはListViewItemに対して適用されているので、メニューにバインディングしたコマンドなど(上記の例ではOpenCommandやCloseCommand)は各アイテムのViewModelにバインドされます(上記の例ではData)。非常に素直な設計だと思います。データに対する操作を各データのVMが受け取るわけですからね。
このほかにもListView.ContextMenuのほうにコンテキストメニューを登録し、SelectedItemなどをうまいこと使ってデータ処理をすることもできますが、この場合はヘッダーを右クリックしてもメニューが出てきてしまう点に問題があります(ListViewコントロール全域に対してコンテストメニューが登録されているわけですからね)。その観点からも、やはり上記のような実装が素直だと言えます。

さて、ここまでは非常に王道なのですが、ListViewItemが発行するイベントを受信しようとすると、とたんに問題が難しくなります。
前述の通り、ListViewItemは直接いじれないので、スタイルを介してプロパティ等をいじることになります。そして、プロパティと同様にEventSetterというセッターがあり、これを通すことでイベントの受信も可能になります。
しかし、EventSetterは、そのイベントをコードビハインドに用意したイベントハンドラに飛ばします。コードビハインドで受信をしてしまうと、それをViewModelにきれいに飛ばすのがなかなか難しくなります。コードビハインドでDataContextを適当なViewModel型にキャストしメソッドを呼び出せば確かにViewからViewModelにイベントの発生を伝えることができますが、XAMLで表現したバインディング以外にデータの流れる経路ができてしまうことには非常に抵抗感があります。
もちろんですが、スタイルにはi:Interaction.TriggersにEventTriggerを入れて使う、なんてこともできません。それができれば何も苦労はしないんですがねえ。

というわけで、今回はいろいろ試行錯誤した結果、いかにListViewItemで発生したイベントを受信し、それをViewModelに伝えるかということを綴っていきたいと思います。

結論から言いますが、ReactivePropertyを使いました。別にReactivePropertyがこれ用の機能を持っているというわけでもないんですが、一番使いやすかったので使いました。まずは、XAMLのほうから紹介します。

<ListView ItemsSource="{Binding Data}" >
    <ListView.View>
        <GridView>
            <!-- 中略 -->
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem" >
            <!-- 中略 -->
        </Style>
    </ListView.ItemContainerStyle>
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="MouseDoubleClick">
            <rp:EventToReactiveProperty ReactiveProperty="{Binding ListViewDoubleClicked}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</ListView>

ListViewのほうにEventTriggerを指定し、ダブルクリックのイベントを受信しています。EventTriggerは多くのMVVMスタイルのXAMLで見かけるものなので特段説明はいらないかと思います。この時点では、たとえばヘッダー領域などを含んだ、ListView全体のダブルクリックを拾ってしまう点に注意する必要があります。
さて、EventTriggerで指定したEventToReactivePropertyですが、これがReactivePropertyでサポートしている機能の1つで、イベントを直接ViewModelのReactiveProperty(IObservable<T>とINotifyPropertyChangedを実装したプロパティ)に飛ばすことができます。飛んでくるデータソースはRoutedEventArgs(もちろんイベントによってはそれを継承したEventArgsだったりする)で、ViewModelにEventArgsの処理を書くのは行儀が悪いので、作者の方のブログではReactiveConverter<T, U>を継承したクラスを作り、イベントをより実用的な型に変換しておりました。
しかし、どうもReactiveConverter<T, U>を挟むと、イベントの送り主がAssosiateObject (この場合はListView)になってしまうようです。挟まなかった場合、ListViewItemのさらにいくつか子要素(TextBlockだったり、Borderだったりします)が送り主になるようです。ListViewItemの子要素ならば、DataContextはListViewItemのものになるはずですから、これをいいことに、DataContextに関連付けられているViewModelにダブルクリックのイベントを伝えることにしました。

なお、上記のXAMLではちゃんと{Binding ListViewDoubleClicked}という形でイベントの受け取り先のReactivePropertyがバインディングされており、コードビハインドとViewModelの間につながりを持たせるということは行わずに済んでいます。

public MainWindowViewModel()
{
    ListViewDoubleClicked
        .Select(p => new { EventArgs = p, ViewModel = (p?.Source as System.Windows.FrameworkElement)?.DataContext as DataElementViewModel })
        .Where(p => p.ViewModel != null)
        .Do(p => p.EventArgs.Handled = true)    //HandledをtrueにするためにDoを挟む
        .Select(p => p.ViewModel)
        .Subscribe(p => p.DoubleClicked());
}

public ReactiveProperty<System.Windows.Input.MouseButtonEventArgs> ListViewDoubleClicked { get; } = new ReactiveProperty<System.Windows.Input.MouseButtonEventArgs>();

つづいてViewModelの一部です。上記の通り、素のままではListViewが発行したすべてのダブルクリックイベントを受信してしまうので、Reactive Extensionsを使ってそれをフィルタリングしています。Reactive ExtensionsはLINQを使って時間的に流れてくるデータをフィルタリングや変換できる、とても強力なライブラリです。
まずはSelectで受信したRoutedEventArgsと、そのViewModelを合成した匿名クラスを作っています。null条件演算子をふんだんに使い、指定のViewModelの型にできなかったときはnullになるように作っています。そして、次にWhereでそのnullになったものを弾き、生き残ったものに対してDoを挟んでEventArgsのHandledをtrueにする作業をしています。最後にViewModelをSelectし、SubscribeでViewModelにダブルクリックが行われたときに呼ぶべきメソッドを呼ぶようにしています。

ViewModelでWPFのRoutedEventの処理をやっている時点で気持ち悪いって言えば気持ち悪いですが、うーん、コードビハインドからViewModelのメソッドを呼ぶのと比べてどっちが気持ち悪いんだろうな…。