備忘録的なSomething

素敵なAnything

【C#,WPF】MVVMで簡単なコードで画面を開いたり閉じたりする

MVVMの目的は、Model(M)、ViewModel(VM)、View(V) を疎結合にすることです。
このため、View は VM を知っていても許すけど、VM が View を知っていることは許さない。という原則があります。

ここで、子画面をモーダルで開くことを考えてみます。すると、途端に困ってしまうのです。

  • 親画面のVMから子画面を開くとき、Owner として自分の画面を渡せない!
  • 子画面のVMから自分を閉じたいとき、自分の画面が閉じられない!

じゃあ、どうするか。
主流なのは、Messenger パターンを使う方法です。
これは Messenger という第三者に、Message をVMからVに伝達してもらう方法です。具体的には、

  • View には、予め各種 Message を受け取ったときの動作を定義しておく。
  • 子画面を開くには、VM から Messenger を介して ShowDialogMessage を送る。
  • 自分の画面を閉じるには、VM から Messenger を介して CloseDialogMessage を送る。

といった感じの手順になります。でも、ただ子画面を開くだけなのに、あまりにも面倒です。
(Livet や MVVM Light Toolkit あたりのMVVMフレームワークを入れれば Messenger を利用できますが、ReactiveProperty 入れてるのに二重に入れるのもなんだかなー。ブラックボックス化しちゃうのもやだし)

ということで、MVVM的にはグレーゾーンな気がしますが、簡単なコードでできる方法を紹介します。
手順は以下です。

  • VM で、デリゲート型のプロパティを用意する。
  • View のコードビハインドで、用意したプロパティに、View を使う動作をセットする。
  • VM でデリゲートを実行すると、View を使う動作が動く。

具体的につくっていきます。まずは簡単な、自分の画面を閉じる方法から。

  • VM にデリゲート型のプロパティpublic Action CloseAction { get; set; }を定義します。
  • View のコードビハインドで、VM の CloseAction に「画面を閉じる処理」をセットします。
        // 画面のコンストラクタ
        public SubWindow(SubWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;

            // 画面を閉じる処理をセット
            viewModel.CloseAction = () =>
            {
                DialogResult = true;
                Close();
            };
        }
  • あとは、VMCloseAction()を呼べば、画面が閉じます。

子画面を開くほうも、デリゲートの型が変わるだけであとはほぼ同じです。

  • VM にデリゲート型のプロパティpublic Func<Window, bool?> ShowDialog { get; set; }を定義します。
    デリゲートの Window 型の引数は子画面を渡すのに、bool? 型の戻り値は結果を受け取るのに使います。
  • View のコードビハインドで、VM の ShowDialog に「指定された画面を開いて、結果を返す処理」をセットします。
        // 画面のコンストラクタ
        public MainWindow(MainWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;

            // 子画面を開く処理をセット
            viewModel.ShowDialog = window =>
            {
                window.Owner = this;
                return window.ShowDialog();
            };
        }
  • あとは、VMShowDialog(子画面のView)を呼べば、子画面を開けます。
                var subWindowVM = new SubWindowViewModel();
                var subWindow = new SubWindow(subWindowVM);
                if (ShowDialog(subWindow) != true)
                {
                    MessageBox.Show("キャンセルボタンまたは右上の閉じるボタンが押されました。");
                    return;
                }

                MessageBox.Show("OKボタンが押されました。");

以上で完了です。

この方法は、子画面を開く・閉じるだけではなく何にでも応用できます。
ただし、例えばFunc<Window>を使って View 自体をVMに公開したりすると、簡単にMVVMが崩壊します。
バインディングやコマンド等の仕組みではどうにもならない場合のみ、用法・用量を守って使うことをおすすめします。

サンプルコード

最後にコード全体を貼っておきます。 これは子画面を作るときのテンプレになります。
(MVVMライブラリとして ReactiveProperty を使用します)

MainWindow.xaml

<Window x:Class="WpfTestApp.Windows.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow"
        SizeToContent="WidthAndHeight"
        ResizeMode="NoResize">
    <StackPanel>
        <Button Content="子画面を開く" Command="{Binding OpenSubWindow}" />
    </StackPanel>
</Window>

MainWindow.xaml.cs

using System.Windows;

namespace WpfTestApp.Windows
{
    public partial class MainWindow : Window
    {
        // 画面のコンストラクタ
        public MainWindow(MainWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;

            // 子画面を開く処理をセット
            viewModel.ShowDialog = window =>
            {
                window.Owner = this;
                return window.ShowDialog();
            };
        }
    }
}

MainWindowViewModel.cs

using Reactive.Bindings;
using System;
using System.ComponentModel;
using System.Windows;

namespace WpfTestApp.Windows
{
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        // 子画面を開く処理
        public Func<Window, bool?> ShowDialog { get; set; }

        // 子画面を開くコマンド
        public ReactiveCommand OpenSubWindow { get; } = new ReactiveCommand();

        // コンストラクタ
        public MainWindowViewModel()
        {
            OpenSubWindow.Subscribe(() =>
            {
                var subWindowVM = new SubWindowViewModel();
                var subWindow = new SubWindow(subWindowVM);
                if (ShowDialog(subWindow) != true)
                {
                    MessageBox.Show("キャンセルボタンまたは右上の閉じるボタンが押されました。");
                    return;
                }

                MessageBox.Show("OKボタンが押されました。");
            });
        }
    }
}

SubWindow.xaml

<Window x:Class="WpfTestApp.Windows.SubWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="SubWindow"
        SizeToContent="WidthAndHeight"
        ResizeMode="NoResize"
        ShowInTaskbar="False">
    <StackPanel>
        <!-- OK・キャンセル -->
        <StackPanel Orientation="Horizontal">
            <Button Content="OK"
                    IsDefault="True"
                    Command="{Binding OKCommand}"/>
            <Button Content="キャンセル"
                    IsCancel="True"/>
        </StackPanel>
    </StackPanel>
</Window>

SubWindow.xaml.cs

using System.Windows;

namespace WpfTestApp.Windows
{
    public partial class SubWindow : Window
    {
        // 画面のコンストラクタ
        public SubWindow(SubWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;

            // 画面を閉じる処理をセット
            viewModel.CloseAction = () =>
            {
                DialogResult = true;
                Close();
            };
        }
    }
}

SubWindowViewModel.cs

using Reactive.Bindings;
using System;
using System.ComponentModel;

namespace WpfTestApp.Windows
{
    public class SubWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        // 自分を閉じる処理
        public Action CloseAction { get; set; }

        // OKボタンのコマンド
        public ReactiveCommand OKCommand { get; } = new ReactiveCommand();

        // コンストラクタ
        public SubWindowViewModel()
        {
            OKCommand.Subscribe(() => CloseAction());
        }
    }
}

実行結果(見やすさのためにMargin等を別途指定しています):




or
※ キャンセルボタンは、単にIsCancel="True"で閉じています。
  これを設定しておくと、Escキー押下時のほか普通にボタンを押したときも画面が閉じ、DialogResult は false になります。

参考: