MVVMパターンでWPFをコーディングしている時、どうしても処理の途中でオペレータの判断を仰いだりしたいこともあるし、
ViewModel内のコマンドから画面を出したいor閉じたい要望が出てくる。
コードビハインドを増やしたくはなく、当然ViewModelから参照するわけにはいかない時、**"メッセンジャー"**という仕組みを用いる
このメッセンジャーという仕組みを使って以下の3つを実現してみる
- ViewModelから内容を指定したメッセージボックスの表示
- ViewModel・Modelから進捗表示を更新できるプログレスバーの表示
- ウィンドウのClose
いろいろと調べてみるとPrismライブラリというものを使えば楽に実装できるようだが、そこは天邪鬼なので使わない。(おい)
ただし、Viewプロジェクトに対してNuGetで下記ライブラリをインストールしている。
- System.Windows.Interactivity.dll
- Microsoft.Expression.Interactions.dll
今回作ったもの
Viewの実装
まずMainViewから。ポイントはSystem.Windows.Interactivityインストールして使用可能になった下記xmlns
xmlns:iy="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:is="http://schemas.microsoft.com/expression/2010/interactions"
これを使用してViewModel側の特定のプロパティが発火した時に紐づけたクラス・メソッドを呼び出せるようにしている。
ボタンとラベルについては特に説明することは無い。
<Window x:Class="WpfTestView.MainView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:iy="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:is="http://schemas.microsoft.com/expression/2010/interactions"
xmlns:local="clr-namespace:WpfTestView"
xmlns:vm="clr-namespace:WpfTestViewModel;assembly=WpfTestViewModel"
mc:Ignorable="d"
Title="MainWindow" Height="200" Width="200">
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
<!-- TriggerにバインドされているのがViewModel側のプロパティ名。中身はイベントキャッチ時の被実行クラス -->
<iy:Interaction.Triggers>
<is:PropertyChangedTrigger Binding="{Binding ShowMessageBoxRequest}">
<local:ShowMessageBox/>
</is:PropertyChangedTrigger>
<is:PropertyChangedTrigger Binding="{Binding ShowProgressBarRequest}">
<local:ShowProgressBar/>
</is:PropertyChangedTrigger>
</iy:Interaction.Triggers>
<Grid>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" >
<Button Content="Execute" Command="{Binding Run}" Height="30" MinWidth="80" Margin="0,0,0,10" />
<Label Content="{Binding ResultString}" Margin="0,10,0,0" HorizontalContentAlignment="Center" />
</StackPanel>
</Grid>
</Window>
次にProgressBar用のViewを見てみる。
<Window x:Class="WpfTestView.ProgressView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:iy="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:is="http://schemas.microsoft.com/expression/2010/interactions"
xmlns:local="clr-namespace:WpfTestView"
mc:Ignorable="d" WindowStyle="None" WindowStartupLocation="CenterOwner"
Title="ProgressView" Height="50" Width="250" >
<iy:Interaction.Triggers>
<is:PropertyChangedTrigger Binding="{Binding CloseWindowRequest}">
<local:CloseWindow/>
</is:PropertyChangedTrigger>
</iy:Interaction.Triggers>
<Grid>
<ProgressBar Value="{Binding ProgressValue}" Minimum="0" Maximum="100" Margin="2" VerticalAlignment="Stretch" />
</Grid>
</Window>
MainView.xamlと同様にイベントを紐づけている。こちらについてはウィンドウを閉じる為の処理。
イベントキャッチ時に実行される肝心の処理については以下の通り。
public class ShowMessageBox : TriggerAction<FrameworkElement>
{
protected override void Invoke(object parameter)
{
if (parameter is DependencyPropertyChangedEventArgs e
&& e.NewValue is ShowMessageBoxRequest msgReq)
{
msgReq.Result = MessageBox.Show(msgReq.Text, msgReq.Title, msgReq.Button, msgReq.Icon, msgReq.DefaultResult, msgReq.Options);
}
}
}
public class ShowProgressBar : TriggerAction<FrameworkElement>
{
protected override void Invoke(object parameter)
{
if (parameter is DependencyPropertyChangedEventArgs e
&& e.NewValue is ShowProgressBarRequest progBarReq)
{
new ProgressView()
{
DataContext = progBarReq.ProgressViewModel,
Owner = Window.GetWindow(AssociatedObject)
}.ShowDialog();
}
}
}
public class CloseWindow : TriggerAction<FrameworkElement>
{
protected override void Invoke(object parameter)
{
if (parameter is DependencyPropertyChangedEventArgs e
&& e.NewValue is CloseWindowRequest)
{
Window.GetWindow(AssociatedObject).Close();
}
}
}
TriggerAction<FrameworkElement>を継承することでイベントの発火をトリガにInvokeメソッドの実行に入るようになっている。
プロパティ変更イベントの為、引数として渡されているparameterにはDependencyPropertyChangedEventArgsが込められている。
そして、変更後プロパティの値をキャストすることでその中身を取り出すという流れ。
ViewModelの実装
まず最初にViewModelが2つあるので、共通のViewModelBaseを書いておく
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = "")
{
if (PropertyChanged == null) return;
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
private ShowMessageBoxRequest _showMessageBoxRequest = null;
public ShowMessageBoxRequest ShowMessageBoxRequest
{
get => _showMessageBoxRequest;
set
{
_showMessageBoxRequest = value;
if (value != null) OnPropertyChanged();
}
}
private ShowProgressBarRequest _showProgressBarRequest = null;
public ShowProgressBarRequest ShowProgressBarRequest
{
get => _showProgressBarRequest;
set
{
_showProgressBarRequest = value;
if (value != null) OnPropertyChanged();
}
}
private CloseWindowRequest _closeWindowRequest = null;
public CloseWindowRequest CloseWindowRequest
{
get => _closeWindowRequest;
set
{
_closeWindowRequest = value;
if (value != null) OnPropertyChanged();
}
}
}
OnPropertyChangedはよくある実装。それ以外のプロパティに関しては上記View側でも見た通り。
その中身については簡単なメンバのみ持つ(あるいは何も持たない)クラス
public class ShowMessageBoxRequest
{
public string Text { get; set; }
public string Title { get; set; } = "";
public MessageBoxButton Button { get; set; } = MessageBoxButton.OK;
public MessageBoxImage Icon { get; set; } = MessageBoxImage.Information;
public MessageBoxResult DefaultResult { get; set; } = MessageBoxResult.Cancel;
public MessageBoxOptions Options { get; set; } = MessageBoxOptions.None;
public MessageBoxResult Result { get; set; } = MessageBoxResult.Cancel;
}
public class ShowProgressBarRequest
{
public ProgressViewModel ProgressViewModel { get; set; }
}
public class CloseWindowRequest
{
}
-
MessageBoxRequest
メッセージボックスに表示する内容やキャプション、アイコンなど定義できるプロパティクラス
-
ShowProgressBarRequest
プログレスバーを表示する為のViewModelのみ持つクラス
-
CloseWindowRequest
ウィンドウ非表示リクエスト用のクラス
これらInteractionRequest.csで定義された各クラスが、先述のInteractionRequestReceiver.csで参照されている。
さて、いよいよViewModelを見てみる
public class MainViewModel : ViewModelBase
{
private string _resultString = "Default";
public string ResultString
{
get => _resultString;
set
{
_resultString = value;
OnPropertyChanged();
}
}
private ICommand _run = null;
public ICommand Run => _run ?? (_run = new RelayCommand(RunCommandExecute));
private void RunCommandExecute(object parameter)
{
ShowMessageBoxRequest = new ShowMessageBoxRequest()
{
Text = "Hello World!",
Title = "Caption",
Button = MessageBoxButton.YesNoCancel,
Icon = MessageBoxImage.Question,
DefaultResult = MessageBoxResult.Cancel
};
ResultString = ShowMessageBoxRequest.Result.ToString();
if (ShowMessageBoxRequest.Result == MessageBoxResult.Yes)
{
ShowProgressBarRequest = new ShowProgressBarRequest()
{
ProgressViewModel = new ProgressViewModel(new SampleSequencer())
};
}
}
}
ボタンからのコマンドにバインドしているRelayCommandはICommandを継承したよくある実装なので割愛。
このコマンドでは上記〇〇Requestクラスをインスタンス化し、ViewModelBaseに記載されているプロパティに代入している。
プロパティへの代入(Setterへのアクセス)時にPropertyChangedイベントが発火し、View側の処理が始まるという仕組み。
MessageBox.Showや、Window.ShowDialogは子ウィンドウの終了を同期待ちする為、閉じるまでこのコマンド内部の処理は止まることになる。
次に、ProgressViewModelを見ていくが、MainViewModel内でProgressViewModelに渡しているSampleSequencerについてはModel部のクラスなので後述
public class ProgressViewModel : ViewModelBase
{
private MonitorableSequencer Sequencer { get; set; }
public ProgressViewModel(MonitorableSequencer sequencer, object parameter = null)
{
Sequencer = sequencer;
Sequencer.Update += (o, e) =>
{
if (o == null || !int.TryParse(o.ToString(), out int value)) return;
if (value < 0 || 100 < value) return;
if (Application.Current.Dispatcher.CheckAccess())
{
ProgressValue = value;
}
else
{
Application.Current.Dispatcher.BeginInvoke((Action)(() =>
{
ProgressValue = value;
}));
}
};
Execute(parameter);
}
private int _progressValue = 0;
public int ProgressValue
{
get => _progressValue;
set
{
_progressValue = value;
OnPropertyChanged();
}
}
private void Execute(object parameter)
{
Task.Factory.StartNew(() =>
{
Sequencer.SequenceStart(parameter);
CloseWindowRequest = new CloseWindowRequest();
});
}
}
ProgressBarを表示する部分に関してはコンストラクタの最後にExecuteメソッドを呼び、その中でタスクを開始しているだけ。
コンストラクタの中でいろいろ書かれているのはModel側でシーケンス進行時に進捗更新イベントを受け取る為の初期化。
必要なシーケンスを実行したのち、CloseWindowRequestのプロパティを更新することでウィンドウを閉じる処理を開始させている
Modelの実装
最後に、Model側の実装。ViewModel側でいろいろ書かれていたが、Model側の実装は少ない
public abstract class MonitorableSequencer
{
public event EventHandler<EventArgs> Update;
public abstract bool SequenceStart(object parameter);
public void UpdateProgress(int value) => Update?.Invoke(value, null);
}
public class SampleSequencer : MonitorableSequencer
{
public override bool SequenceStart(object parameter)
{
for (int i = 0; i < 10; i++)
{
UpdateProgress(i * 10);
System.Threading.Thread.Sleep(500);
}
return true;
}
}
わざわざ抽象クラスを作る必要もなかったかもしれないが、一応そこは汎用性の為作ってみた。
これによりMainViewModel側でProgressViewMdoelインスタンス化の際に、MonitorableSequencerクラスを継承した複数のSequencerを別個に定義することができ、
それら各Sequencerの中身でUpdateProgressを呼び出すことでProgressBarの表示が更新されるという仕組み。
ちなみに、ProgressValueについては0~100固定にしていて、ProgressViewModel側で値のチェックはしているものの、
進捗度更新の際に0~100までの値を注意して入れなければならないのは少し気になる。もう少しいい方法があるかもしれない。
(少なくともMin/Maxは定義時に動的に変えてもいいかもしれない)
長ったらしく書いたが、自分なりに嬉しいのは*.xaml.csに全く手を加えていないこと。
View->ViewModel->Modelの依存関係は全く崩さずにViewModel->Viewへの流れが作れるのはとても嬉しい