困っていたこと
MVVM(Model-View-ViewModel)パターンに則ってアプリを作成していると、ダイアログの処理をどのように実装すればよいか困ることがあります。
例えばWPFにおいてファイル保存ダイアログ(SaveFileDialog)を表示してからファイルを保存したい場合、
- Viewのコードビハインド(~.xaml.cs)にクリック時のイベントを実装しその中で呼び出す
- ViewModelのコマンド内でSaveFileDialogを呼び出す
などが考えられます。
最も簡単なのは前者ですが、SaveFileDialogを呼び出したいためだけにViewにクリックイベントを実装するのは好ましくありません。
だからといって後者のようにViewModelにダイアログの処理を持ってくるのはMVVMパターンに反してしまいます。
Viewにダイアログの処理を記述し、ViewModelはその入出力だけを利用するような方法はないでしょうか?
解決策: Interactionを使ったダイアログ処理
.NETのMVVMフレームワークライブラリであるReactiveUIにはInteractionという機能を使うことによって解決できることがわかりました。
実装例
例題としてMVVMパターンに則ったWPFアプリでSaveFileDialogを使う場合を考えます。
プログラムの詳細はこちらで公開しています。
Model
SaveFileDialogの例題なのでModelにはファイルを保存する機能を持たせます。
全体のコード
using System;
using System.IO;
namespace MvvmDialog.Models
{
public class SaveText
{
public SaveTextResponse Save(SaveTextRequest request)
{
SaveTextResponse response = null;
try
{
using (StreamWriter sw = new StreamWriter(request.FilePath, false))
{
sw.Write(request.InputText);
}
response = new SaveTextResponse()
{
Succeed = true,
Message = "File saved successfully."
};
}
catch (Exception ex)
{
response = new SaveTextResponse()
{
Succeed = false,
Message = ex.Message
};
}
return response;
}
}
public class SaveTextRequest
{
public string InputText { get; set; }
public string FilePath { get; set; }
}
public class SaveTextResponse
{
public bool Succeed { get; set; }
public string Message { get; set; }
}
}
ViewModel
本題のSaveFileDialogのプロパティとしてInteractionを定義します。
public Interaction<string, string> SaveFileDialog { get; set; }
Inputには起動時に表示されるファイル名が入力されるとし、またOutputとしてはファイルのフルパスを出力すると決めておきます。
ViewModel内でInteractionSaveFileDialog
は次のように呼び出すことができます。
IObservable<string> output = SaveFileDialog.Handle(input);
このInteractionを保存ボタン押下コマンド内に記述することで、ボタン押下→ダイアログ表示→保存処理を実現することができます。
全体のコード
using MvvmDialog.Models;
using ReactiveUI;
using ReactiveUI.Fody.Helpers;
using System;
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
namespace MvvmDialog.ViewModels
{
public class MainViewModel : ReactiveObject, IActivatableViewModel
{
[Reactive]
public string InputText { get; set; }
[Reactive]
public string FileName { get; set; }
[Reactive]
public string ResultMessage { get; set; }
public ReactiveCommand<Unit, SaveTextRequest> SaveFileCommand { get; private set; }
public Interaction<string, string> SaveFileDialog { get; set; }
public ViewModelActivator Activator { get; }
public MainViewModel()
{
Activator = new ViewModelActivator();
this.WhenActivated(d =>
{
HandleViewModelBound(d);
});
}
void HandleViewModelBound(CompositeDisposable d)
{
SaveFileDialog = new Interaction<string, string>();
SaveFileCommand = ReactiveCommand.CreateFromObservable(() => SaveFileDialog.Handle(FileName).Select(x => new SaveTextRequest() { FilePath = x, InputText = this.InputText }));
SaveFileCommand.Select(x => new SaveText().Save(x)).Subscribe(x => ResultMessage = x.Message).DisposeWith(d);
}
}
}
View
ViewではViewModelで定義したSaveFileDialogプロパティに具体的なInteractionの処理をバインドしていきます。
this.BindInteraction(
ViewModel,
vm => vm.SaveFileDialog,
async interaction =>
{
var result = await Task.Run(() =>
{
var dialog = new SaveFileDialog()
{
FileName = interaction.Input,
AddExtension = true,
DefaultExt = "txt"
};
if(dialog.ShowDialog()?? false)
{
return dialog.FileName;
}
else
{
return null;
}
});
interaction.SetOutput(result);
})
.DisposeWith(d);
全体のコード
using Microsoft.Win32;
using MvvmDialog.ViewModels;
using ReactiveUI;
using System.Reactive.Disposables;
using System.Threading.Tasks;
namespace MvvmDialog.Views
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : ReactiveWindow<MainViewModel>
{
public MainWindow()
{
InitializeComponent();
ViewModel = new MainViewModel();
this.WhenActivated(d =>
{
HandleViewModelBound(d);
});
}
protected void HandleViewModelBound(CompositeDisposable d)
{
this.BindInteraction(
ViewModel,
vm => vm.SaveFileDialog,
async interaction =>
{
var result = await Task.Run(() =>
{
var dialog = new SaveFileDialog()
{
FileName = interaction.Input,
AddExtension = true,
DefaultExt = "txt"
};
if(dialog.ShowDialog()?? false)
{
return dialog.FileName;
}
else
{
return null;
}
});
interaction.SetOutput(result);
})
.DisposeWith(d);
this.OneWayBind(ViewModel, vm => vm.ResultMessage, v => v.ResultTextBlock.Text).DisposeWith(d);
this.Bind(ViewModel, vm => vm.FileName, v => v.FileNameTextBox.Text).DisposeWith(d);
this.Bind(ViewModel, vm => vm.InputText, v => v.InputTextBox.Text).DisposeWith(d);
this.BindCommand(ViewModel, vm => vm.SaveFileCommand, v => v.SaveFileButton).DisposeWith(d);
}
}
}