かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

M-V-VMパターンでViewModelのコマンド実行後に何か処理したいよね

M-V-VMで書いてると、ViewはViewModelのプロパティにバインドして、ViewModelのプロパティの内容を表示するのが主になります。
View上でのボタンクリックとかもViewModelが提供するCommand型のプロパティとバインドして、完全にViewModelに処理をお任せするのが一般的だと思います。
そうなってくると、ちょっと不便なこととして、ボタンクリックの結果をViewで表示したいとか、コマンド実行前に確認画面を出したいということをしようとすると、どうしようか悩んでしまいます。

ViewModelでMessageBoxを出すコードを書くと、せっかくViewModelとViewを分離しているありがたみがなくなってしまうし、ユニットテストしにくくなってしまいます。一番素直なのは、Viewでボタンのクリックイベントを処理して、確認画面を出した後にViewModelのインスタンスをDataContextから取得して、メソッドを呼ぶというのも1つのやりかたですが、この方法も、バインド以外でViewModelの意識をするという点でイマイチな感じがします。
まぁ個人的には、ここらへんは妥協してもいいかな〜と思ってるところではありますが・・・。

さて、前置きが長くなりましたが、そういう悩みを解決する1つの方法として、ViewとViewModel間でCommandをバインドするときに間に1枚噛ませる方法はどうだろうと思ったので実装してみました。コードのネタ元は、Model View ViewModelツールキットのテンプレートの中にあるCommandReferenceになります。
こいつは、ICommandを実装するFreezableを継承したクラスで、KeyBindingのCommandParameterあたりがDependencyPropertyじゃないからバインドできない!という問題を解決するために入れられてるものです。(多分)
例えば以下のようなコードがかけないという問題ですね。

<KeyBinding Command="{Binding Hogehoge}" CommandParameter="ここにバインド出来ない!!" />

まぁ色々文章かくのも疲れるんで、さくっとコードにいきます。DelegateCommandは、適当にでっちあげてるものとします。(Model View ViewModel Toolkitの作ってくれる奴がいいです)そして、CommandReferenceをさくっと作っていきます。コード全体は以下のようになります。Executeメソッドあたり以外は、特に特筆することは無いと思います。

using System;
using System.Windows;
using System.Windows.Input;
using System.ComponentModel;

namespace Okazuki.MVVM.Commands
{
    /// <summary>
    /// コマンド完了後に発生するイベントの引数
    /// </summary>
    public class CommandExecutedEventArgs : EventArgs
    {
        /// <summary>
        /// コマンドで発生したエラー。
        /// </summary>
        public Exception Error { get; private set; }
        /// <summary>
        /// エラーを処理した場合はtrueに設定する
        /// </summary>
        public bool ErrorHandled { get; set; }


        public CommandExecutedEventArgs()
        {
        }
        public CommandExecutedEventArgs (Exception error)
	    {
            this.Error = error;
	    }
    }

    /// <summary>
    /// WindowとかのResourcesに登録して複数個所から参照されるのでFreezableを継承する。
    /// </summary>
    public class CommandReference : Freezable, ICommand
    {
        #region Execute実行前と実行後イベント
        public event EventHandler<CancelEventArgs> CommandExecuting;
        public event EventHandler<CommandExecutedEventArgs> CommandExecuted;
        #endregion

        public CommandReference()
        {
            // いちいちnullチェックするのがだるいので空ハンドラ登録しておく
            CommandExecuting += (a, b) => { };
            CommandExecuted += (a, b) => { };
        }

        // ViewModelのCommandとバインドするCommandプロパティ
        public static readonly DependencyProperty CommandProperty = DependencyProperty.Register("Command", typeof(ICommand), typeof(CommandReference), new PropertyMetadata(new PropertyChangedCallback(OnCommandChanged)));
        public ICommand Command
        {
            get { return (ICommand)GetValue(CommandProperty); }
            set { SetValue(CommandProperty, value); }
        }

        #region ICommand Members

        // ICommandの実装メソッド。実装内容は、タダ単に委譲しているだけ。
        public bool CanExecute(object parameter)
        {
            if (Command != null)
            {
                return Command.CanExecute(parameter);
            }
            return false;
        }
        public void Execute(object parameter)
        {
            // コマンド実行前に実行できるかどうかイベントで確認する。
            var executingEventArgs = new CancelEventArgs();
            CommandExecuting(this, executingEventArgs);
            if (executingEventArgs.Cancel)
            {
                return;
            }
            try
            {
                // コマンド実行
                Command.Execute(parameter);
                // コマンド実行後イベント発行
                CommandExecuted(this, new CommandExecutedEventArgs());
            }
            catch (Exception ex)
            {
                // エラーがおきたときもコマンド実行後イベント発行
                var executedEventArgs = new CommandExecutedEventArgs(ex);
                CommandExecuted(this, executedEventArgs);
                // エラーが処理されてないようなら例外を再スロー
                if (!executedEventArgs.ErrorHandled)
                {
                    throw;
                }
            }
        }

        public event EventHandler CanExecuteChanged;


        // Commandプロパティが変ったタイミングで、イベントの登録先を入れ替える
        private static void OnCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            CommandReference commandReference = d as CommandReference;
            ICommand oldCommand = e.OldValue as ICommand;
            ICommand newCommand = e.NewValue as ICommand;

            if (oldCommand != null)
            {
                oldCommand.CanExecuteChanged -= commandReference.CanExecuteChanged;
            }
            if (newCommand != null)
            {
                newCommand.CanExecuteChanged += commandReference.CanExecuteChanged;
            }
        }

        #endregion

        #region Freezable

        // 特にサポートしない
        protected override Freezable CreateInstanceCore()
        {
            throw new NotSupportedException();
        }

        #endregion
    }
}

大体説明は、コメントにかいてるので割愛します。

こいつを実際使ってみると・・・

適当に実装したWindow1ViewModel

using System.Windows.Input;

namespace Okazuki.MVVM.Commands
{
    public class Window1ViewModel
    {
        private ICommand _greet;
        public ICommand Greet
        {
            get
            {
                return _greet = _greet == null ?
                    new DelegateCommand(GreetExecute) : _greet;
            }
        }
        private void GreetExecute()
        {
            // 何かの実装
        }
    }
}

こいつは、コマンドを1つ公開してるだけのシンプル実装です。こいつをWindow1のDataContextに設定します。

Window1.xaml.cs

using System.Windows;

namespace Okazuki.MVVM.Commands
{
    public partial class Window1 : Window
    {
        public Window1()
        {
            InitializeComponent();
            // とりあえずハードコーディング
            DataContext = new Window1ViewModel();
        }
    }
}

とりあえずDataContextとViewModelの紐付けはハードコーディングしてます。今回の主題ではないので。

Window1.xaml

<Window x:Class="Okazuki.MVVM.Commands.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:commands="clr-namespace:Okazuki.MVVM.Commands"
    Title="Window1" Height="300" Width="300">
    <Window.Resources>
        <!-- ViewModelのGreetコマンドの間に割ってはいるCommandReference -->
        <commands:CommandReference 
            x:Key="GreetCommandReference"
            Command="{Binding Greet}"
            CommandExecuting="CommandReference_CommandExecuting"
            CommandExecuted="CommandReference_CommandExecuted" />
    </Window.Resources>
    <Grid>
        <!-- 直接ViewModelのコマンドにバインドするのではなく、CommandReferenceを参照するようにする -->
        <Button Content="Greet!!"
                Command="{StaticResource GreetCommandReference}" />
    </Grid>
</Window>

そして、XAMLではCommandReferenceをResourcesに定義してボタンなどからはStaticResourceで、このCommandReferenceをCommandプロパティに設定しています。

Window1.xaml.csふたたび

using System.Windows;

namespace Okazuki.MVVM.Commands
{
    public partial class Window1 : Window
    {
        public Window1()
        {
            InitializeComponent();
            // とりあえずハードコーディング
            DataContext = new Window1ViewModel();
        }

        private void CommandReference_CommandExecuting(object sender, System.ComponentModel.CancelEventArgs e)
        {
            var result = MessageBox.Show("いいの?",
                "確認",
                MessageBoxButton.OKCancel);
            // キャンセルするならキャンセルするフラグをたてる
            e.Cancel = result == MessageBoxResult.Cancel;
        }

        private void CommandReference_CommandExecuted(object sender, CommandExecutedEventArgs e)
        {
            // 実行完了のときの処理
            MessageBox.Show("挨拶しましたね!");
        }
    }
}

CommandExecutingイベントハンドラとCommandExecutedイベントハンドラの実装は、上記のような感じになります。
これでViewModelのことは気にせずにView内だけで閉じた判断ロジックとか、コマンド実行後のイベント内容を

// DataContextの内容を受け継いだ新しいウィンドウを表示する
var window = new 新しいWindow { DataContext = this.DataContext };
window.Show();

のようにすれば、新しい画面も出せちゃいます。
どうだろう?普通に使ってみようかなこれ。