[C#/WPF/MVVM/ReactiveUI] MVVMでダイアログ処理を行う(Interaction)

##  困っていたこと
MVVM（Model-View-ViewModel）パターンに則ってアプリを作成していると、ダイアログの処理をどのように実装すればよいか困ることがあります。
例えばWPFにおいてファイル保存ダイアログ(SaveFileDialog)を表示してからファイルを保存したい場合、

* Viewのコードビハインド(~.xaml.cs)にクリック時のイベントを実装しその中で呼び出す
* ViewModelのコマンド内でSaveFileDialogを呼び出す

などが考えられます。
最も簡単なのは前者ですが、SaveFileDialogを呼び出したいためだけにViewにクリックイベントを実装するのは好ましくありません。
だからといって後者のようにViewModelにダイアログの処理を持ってくるのはMVVMパターンに反してしまいます。

**Viewにダイアログの処理を記述し、ViewModelはその入出力だけを利用するような方法はないでしょうか？**

## 解決策: Interactionを使ったダイアログ処理

.NETのMVVMフレームワークライブラリである[ReactiveUI](https://www.reactiveui.net/)には[Interaction](https://www.reactiveui.net/docs/handbook/interactions/)という機能を使うことによって解決できることがわかりました。


## 実装例

例題としてMVVMパターンに則ったWPFアプリでSaveFileDialogを使う場合を考えます。
プログラムの詳細は[こちら](https://github.com/delicioustuna/MvvmDialog)で公開しています。


### Model

SaveFileDialogの例題なのでModelにはファイルを保存する機能を持たせます。

<details><summary>全体のコード</summary><div>

```cs: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; }
    }
}
```
</div></details>

## ViewModel

本題のSaveFileDialogのプロパティとしてInteraction<TInput, TOutput>を定義します。

```csharp
public Interaction<string, string> SaveFileDialog { get; set; }
```

Inputには起動時に表示されるファイル名が入力されるとし、またOutputとしてはファイルのフルパスを出力すると決めておきます。
ViewModel内でInteraction```SaveFileDialog```は次のように呼び出すことができます。

```csharp
IObservable<string> output = SaveFileDialog.Handle(input);
```
このInteractionを保存ボタン押下コマンド内に記述することで、ボタン押下→ダイアログ表示→保存処理を実現することができます。

<details><summary>全体のコード</summary><div>

```cs:ViewModel
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);
        }
    }
}
```
</div></details>

## View

ViewではViewModelで定義したSaveFileDialogプロパティに具体的なInteractionの処理をバインドしていきます。

```csharp
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);
```

<details><summary>全体のコード</summary><div>

```cs:View
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);
        }
    }
}
```

</div></details>