MVVM でウィンドウ クローズ

MVVM パターンでのウィンドウクローズで利用できるアクションです。
クローズボタンのクリックイベントまたはビューモデルにバインドしたプロパティの変更をトリガーにウィンドウを閉じるアクションと、Window.Closing イベントをトリガーにユーザーへの問い合わせを行うアクションの2つになります。

(2014年1月23日追記)
ウィンドウの遷移や複数ウィンドウの表示を行うことができる TransitionViewModelBase の記事を投稿しました。

実装は次の方針で行っています。

  • ウィンドウを閉じる際のユーザーへの問い合わせは、Window.Closing イベントをトリガーとするアクションで行う(ウィンドウの非クライアント領域の「閉じるボタン(×)」のクリックによるウィンドウ クローズへの対処)。
  • 問い合わせは 閉じる/閉じない の二者択一とし、保存していないデータの扱いなどを含める三択問い合わせ(保存する/保存しない/キャンセル)は行わない(Window.Closing イベントのハンドラで保存を行おうとすると、IO 操作の非同期化で問題が生じるため(await でタスクが返り、UI スレッドが Window.Closing イベントハンドラから抜けると、ウィンドウが閉じてしまう))。
  • ビューモデルの状態取得は、依存関係プロパティ経由ではなく、インターフェイスを介したプロパティとメソッドによるものとする(Window.Closing イベント発生時点での状態を取得する必要があることから。ビューモデル側で状態変更が発生する都度プロパティ変更通知を行う方法も考えられるが、ビューモデル側の実装が煩雑になると判断(本質的に都度通知する必要はない))。

動作概要は次のとおりです。

  • ビューのクライアント領域に設置される「閉じる」ボタンのクリック・イベントにより WindowCloseAction を実行する(WindowCloseAction はウィドウを閉じるだけの機能)。また、ViewModel からのウィンドウ クローズ要求を受けてウィンドウを閉じる機能も持つ。
    WindowCloseAction は次のプロパティを持つ。
    • WindowClose: True になるとウィンドウを閉じます。
  • Window.Closing イベントにより WindowClosingAction を実行する。
    WindowClosingAction は次のプロパティを持つ。
    • IsInquiry: ユーザーへの問い合わせを行うかを設定します。
    • IsVmCheck: ViewModel の状態確認を行うかを設定します。
    • Caption: 問い合わせウィンドウの表題を設定します。
  • ViewModel の状態確認を行う場合、ViewModel は IStateQueryableViewModel インターフェイスを実装していること。
    • string InquiryMessage { get; }: ウィンドウが閉じる際にユーザーへ問い合わせるメッセージを取得(GetCondition メソッドから false が返される時に利用する)
    • bool GetCondition(): ウィンドウが Close 出来る状態か否かを返す。
    • void ResetUnresolvedState(): 未解決状態をリセット(未保存のデータ等を破棄するために何か必要な操作があれば、ViewModel 側のこのメソッドに記述する)
  • WindowClosingAction は次に示す動作を行う。
    1. IsInquiry プロパティが true であれば 2 へ、false であれば 4 へ
    2. IsVmCheck プロパティが true であれば 3 へ、False であれば B へ
    3. ViewModel への問い合わせ結果が false であれば A へ、true であれば B へ
      1. IStateQueryableViewModel.InquiryMessageを使ってユーザーへの問い合わせを行う (Yes/No)
        Yes: IStateQueryableViewModel.ResetUnresolvedState メソッドを実行し 4 へ
        No : CancelEventArgs.Cancel プロパティに true を設定しリターン
      2. 「ウィンドウを閉じてよろしいですか(Yes/No)」とユーザーへの問い合わせを行う
        Yes: 4 へ
        No : CancelEventArgs.Cancel プロパティに true を設定しリターン
    4. ViewModel が IDisposable インターフェイスを実装していれば Dispose メソッドを実行する。

終了ボタンのクリックイベントをトリガーにして、「ウィンドウを閉じてよろしいですか(Yes/No)」と問い合わせを行うだけの指定だと、次のようになります。

<Window 詳細は省略>
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Closing">
            <b:WindowClosingAction
                IsInquiry="True" Caption="ウィンドウ クローズ" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
    
    <StackPanel>
        <TextBlock Text="ウィンドウクローズのテスト" FontSize="18" HorizontalAlignment="Center" Margin="10" />
        <Button Content="閉じる" FontSize="14" Padding="10 4">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="Click">
                    <b:WindowCloseAction WindowClose="True" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Button>
    </StackPanel>
</Window>

上記のものとは別に利用例として作成したウィンドウで動作している画面です。
ビューモデルへの問い合わせを行わない場合及びビューモデル側の状態がウィンドウを閉じても問題ない場合。
問い合わせ動作その1

ビューモデル側の状態がウィンドウを閉じると支障がある場合(変更データが保存されていないなど)。
問い合わせ動作その2

縦に3つ並んでいる閉じるボタンは、上から「ボタンのクリックイベントで起動」「ビューモデル側のプロパティチェンジイベントで起動」「ビューモデル側のプロパティチェンジイベントで起動させ、閉じる際の問い合わせを行わないようにする」ように設定しています(最後のボタンは、ウィンドウの終了前処理(データ保存やユーザーとの対話)をコマンド中ですべて行うことで、Window.Closing イベント時点では問い合わせが不要になることを想定したもの)。

利用例も含めたソースは次のとおりです。
まずは WPF のプロジェクトを作成します(WindowCloseAction としています)。

ビヘイビアの作成ということで、Expression Blend SDK(有料のExpression Blend とは別もの) が必要です。入れていない方は、かずきさんのブログ記事が詳しいので、ご覧ください。

まず最初に DLL への参照設定です。「参照設定」を右クリックして「参照の追加」を選択し、次の2つの DLL への参照を設定します。

  • System.Windows.Interactivity.dll
  • Microsoft.Expression.Interactions.dll

最初に IStateQueryableViewModel インターフェイスから。
プロジェクトに Behaviors フォルダを作成し、Behaviors フォルダに Interfaces フォルダを作成します。作成した Interfaces フォルダに IStateQueryableViewModel インターフェイスを作成します。

namespace WindowCloseAction.Behaviors.Interfaces
{
    /// <summary>
    /// ウィンドウを閉じる処理の際にビューモデルへ未解決状態の問い合わせを行うインターフェイスです。
    /// </summary>
    interface IStateQueryableViewModel
    {
        /// <summary>
        /// ウィンドウが閉じる際にユーザーへ問い合わせるメッセージを取得します。
        /// </summary>
        string InquiryMessage { get; }

        /// <summary>
        /// ウィンドウが Close 出来る状態か否かを返します。
        /// </summary>
        /// <returns></returns>
        bool GetCondition();

        /// <summary>
        /// 未解決状態をリセットします(未保存のデータは破棄されます)。
        /// 未保存のデータ等を破棄するために何か必要な操作があれば、ViewModel 側のこのメソッドに記述してください。
        /// </summary>
        void ResetUnresolvedState();
    }
}

次に、WindowCloseAction クラスです。Behaviors フォルダに作成します。

using System.Windows;
using System.Windows.Interactivity;

namespace WindowCloseAction.Behaviors
{
    /// <summary>
    /// モードレスウィンドウを閉じるアクション
    /// WindowClose プロパティが True になるとウィンドウを閉じます。
    /// </summary>
    class WindowCloseAction : TriggerAction<FrameworkElement>
    {
        public static readonly DependencyProperty WindowCloseProperty = DependencyProperty.Register(
            "WindowClose", typeof(bool), typeof(WindowCloseAction), new UIPropertyMetadata
            {
                DefaultValue = false
            });

        public bool WindowClose
        {
            get { return (bool)GetValue(WindowCloseProperty); }
            set { SetValue(WindowCloseProperty, value); }
        }

        protected override void Invoke(object parameter)
        {
            if (WindowClose == false) return;

            var window = Window.GetWindow(AssociatedObject);
            window.Close();
        }
    }
}

次に WindowClosingAction クラスです。

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

using WindowCloseAction.Behaviors.Interfaces;

namespace WindowCloseAction.Behaviors
{
    /// <summary>
    /// モードレスウィンドウのクロージング イベントのアクション
    /// </summary>
    class WindowClosingAction : TriggerAction<Window>
    {
        public static readonly DependencyProperty IsInquiryProperty = DependencyProperty.Register(
            "IsInquiry", typeof(bool), typeof(WindowClosingAction), new UIPropertyMetadata
            {
                DefaultValue = false
            });

        public bool IsInquiry
        {
            get { return (bool)GetValue(IsInquiryProperty); }
            set { SetValue(IsInquiryProperty, value); }
        }

        public static readonly DependencyProperty IsCheckVmProperty = DependencyProperty.Register(
            "IsCheckVm", typeof(bool), typeof(WindowClosingAction), new UIPropertyMetadata
            {
                DefaultValue = false
            });

        public bool IsCheckVm
        {
            get { return (bool)GetValue(IsCheckVmProperty); }
            set { SetValue(IsCheckVmProperty, value); }
        }

        public static readonly DependencyProperty CaptionProperty = DependencyProperty.Register(
            "Caption", typeof(string), typeof(WindowClosingAction), new UIPropertyMetadata
            {
                DefaultValue = "Close Window"
            });

        public string Caption
        {
            get { return (string)GetValue(CaptionProperty); }
            set { SetValue(CaptionProperty, value); }
        }

        protected override void Invoke(object parameter)
        {
            var window = Window.GetWindow(AssociatedObject);

            if (IsInquiry)
            {
                var cancelEventArgs = parameter as CancelEventArgs;
                if (cancelEventArgs == null)
                    throw new InvalidOperationException(
                        "WindowClosingAction は EventTrigger の EventName で Closing を指定してください。");

                var inquiryViewModel = window.DataContext as IStateQueryableViewModel;
                if (IsCheckVm && inquiryViewModel == null)
                {
                    throw new InvalidOperationException(
                        "WindowClose の際に ViewModel の状態確認が選択されていますが、ViewModel が IInquiryViewModel インターフェイスを実装していません。");
                }
                if (IsCheckVm && !inquiryViewModel.GetCondition())
                {   // ViewModel へのクローズ可否の問い合わせで否応答の場合
                    var message = inquiryViewModel.InquiryMessage;
                    var current = window.Cursor;
                    window.Cursor = Cursors.Wait;
                    switch (MessageBox.Show(message, Caption, MessageBoxButton.YesNo))
                    {
                        case MessageBoxResult.Yes:
                            inquiryViewModel.ResetUnresolvedState();
                            break;
                        case MessageBoxResult.No:
                            cancelEventArgs.Cancel = true;  // ウィンドウを閉じる操作をキャンセル
                            window.Cursor = current;
                            return;
                    }
                    window.Cursor = current;
                }
                else
                {
                    var message = "ウィンドウを閉じてよろしいですか(Yes/No)";
                    if (MessageBox.Show(message, Caption, MessageBoxButton.YesNo) == MessageBoxResult.No)
                    {
                        cancelEventArgs.Cancel = true;  // ウィンドウを閉じる操作をキャンセル
                        return;
                    }
                }

                var disposableViewModel = window.DataContext as IDisposable;
                if (disposableViewModel != null)
                    disposableViewModel.Dispose();
            }
        }
    }
}

次にビューモデルです。プロジェクトに ViewModels フォルダを作成し、そこに MainWindowViewModel クラスを作成します。
MVVM パターンなので、お決まりのコマンド処理とプロパティチェンジイベントの発火を提供する ViewModelBase クラスは別記事の弱いイベントパターンを用いたリスナー登録機能を持つビューモデルベースで書いたものを使っています(この記事以前の ViewModelBase もあるんですが、この記事の ViewModelBase は IDisposable インターフェイスを実装しているので、こちらを使います)。別プロジェクトにして作成した DLL を参照設定で追加するなり、プロジェクト内にソースを取り入れるなりしてください(次のコードは DLL を参照する前提で書いています)。
なお、閉じるボタンがクリックされた後、ボタンがクリックできないようにするなどの細かいことは行っていません :mrgreen:

using System.Threading.Tasks;
using System.Windows.Input;

using MakCraft.ViewModels;

using WindowCloseAction.Behaviors.Interfaces;

namespace WindowCloseAction.ViewModels
{
    class MainWindowViewModel : ViewModelBase, IStateQueryableViewModel
    {
        private bool _isInquiry = true;
        /// <summary>
        /// WindowClosingAction で問い合わせを行うか否かを設定
        /// </summary>
        public bool IsInquiry
        {
            get { return _isInquiry; }
            set
            {
                _isInquiry = value;
                base.RaisePropertyChanged(() => IsInquiry);
                base.RaisePropertyChanged(() => InquiryConditionText);
            }
        }

        private bool _isCheckVm = true;
        /// <summary>
        /// WindowClosingAction で ViewModel 状態の確認を行うか否かを設定
        /// </summary>
        public bool IsCheckVm
        {
            get { return _isCheckVm; }
            set
            {
                _isCheckVm = value;
                base.RaisePropertyChanged(() => IsCheckVm);
                base.RaisePropertyChanged(() => CheckVmConditionText);
            }
        }

        public string InquiryConditionText
        {
            get
            {
                if (_isInquiry)
                    return "問い合わせを行います";
                else
                    return "問い合わせを行いません";
            }
        }

        public string CheckVmConditionText
        {
            get
            {
                if (_isCheckVm)
                    return "ビューモデルのチェックを行います";
                else
                    return "ビューモデルのチェックを行いません";
            }
        }

        private bool _isPending;
        public string ConditionText
        {
            get
            {
                if (_isPending)
                    return "未保存データあり";
                else
                    return "未保存データなし";
            }
        }

        private bool _windowClose = false;
        /// <summary>
        /// プロパティ・チェンジ イベントと WindowCloseAction にバインドし、値が true になるとウィンドウを閉じる
        /// </summary>
        public bool WindowClose
        {
            get { return _windowClose; }
            set
            {
                _windowClose = value;
                base.RaisePropertyChanged(() => WindowClose);
            }
        }

        private Cursor _cursor = Cursors.Arrow;
        /// <summary>
        /// ウィンドウのマウスカーソルの取得・設定を行います。
        /// </summary>
        public Cursor Cursor
        {
            get { return _cursor; }
            set
            {
                _cursor = value;
                base.RaisePropertyChanged(() => Cursor);
            }
        }

        private void closeExecute()
        {
            WindowClose = true;
        }
        private ICommand _closeCommand;
        public ICommand CloseCommand
        {
            get
            {
                if (_closeCommand == null)
                    _closeCommand = new RelayCommand(
                        param => closeExecute()
                        );
                return _closeCommand;
            }
        }

        private void changeInqiryModeExecute()
        {
            IsInquiry = !IsInquiry;
        }
        private ICommand _changeInqiryModeCommand;
        public ICommand ChangeInqiryModeCommand
        {
            get
            {
                if (_changeInqiryModeCommand == null)
                    _changeInqiryModeCommand = new RelayCommand(
                        param => changeInqiryModeExecute()
                        );
                return _changeInqiryModeCommand;
            }
        }

        private void changeCheckVmModeExecute()
        {
            IsCheckVm = !IsCheckVm;
        }
        private ICommand _changeCheckVmModeCommand;
        public ICommand ChangeCheckVmModeCommand
        {
            get
            {
                if (_changeCheckVmModeCommand == null)
                    _changeCheckVmModeCommand = new RelayCommand(
                        param => changeCheckVmModeExecute()
                        );
                return _changeCheckVmModeCommand;
            }
        }

        private void changeCloseModeExecute()
        {
            _isPending = !_isPending;
            base.RaisePropertyChanged(() => ConditionText);
        }
        private ICommand _changeCloseModeCommand;
        public ICommand ChangeCloseModeCommand
        {
            get
            {
                if (_changeCloseModeCommand == null)
                    _changeCloseModeCommand = new RelayCommand(
                        param => changeCloseModeExecute()
                        );
                return _changeCloseModeCommand;
            }
        }

        private async void closeWithNonInquiryExecute()
        {
            var current = Cursor;
            Cursor = Cursors.Wait;
            await Task.Factory.StartNew(() => System.Threading.Thread.Sleep(2000));
            Cursor = current;
            IsInquiry = false;
            WindowClose = true;
        }
        private ICommand _closeWithNonInquiryCommand;
        public ICommand CloseWithNonInquiryCommand
        {
            get
            {
                if (_closeWithNonInquiryCommand == null)
                    _closeWithNonInquiryCommand = new RelayCommand(
                        param => closeWithNonInquiryExecute()
                        );
                return _closeWithNonInquiryCommand;
            }
        }

        # region IStateQueryableViewModel

        private string _inquiryMessage;
        public string InquiryMessage
        {
            get { return _inquiryMessage; }
        }

        public bool GetCondition()
        {
            if (_isPending)
                _inquiryMessage = "未保存のデータがあります。このままウィンドウを閉じますか?";
            else
                _inquiryMessage = "";
            return !_isPending;
        }

        public void ResetUnresolvedState()
        {
            System.Threading.Thread.Sleep(1000);
        }

        #endregion IStateQueryableViewModel

        protected override void Dispose(bool disposing)
        {
            System.Diagnostics.Debug.WriteLine("-- Dispose: MainWindowViewModel");

            base.Dispose(disposing);
        }
    }
}

最後に MainWindow.xaml です。

<Window x:Class="WindowCloseAction.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:b="clr-namespace:WindowCloseAction.Behaviors"
        xmlns:vm="clr-namespace:WindowCloseAction.ViewModels"
        Cursor="{Binding Cursor,Mode=TwoWay}"
        Title="MainWindow" Height="350" Width="525">
    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <i:Interaction.Triggers>
        <ei:PropertyChangedTrigger Binding="{Binding WindowClose}">
            <b:WindowCloseAction WindowClose="{Binding WindowClose}" />
        </ei:PropertyChangedTrigger>
        
        <i:EventTrigger EventName="Closing">
            <b:WindowClosingAction
                IsInquiry="{Binding IsInquiry}" Caption="ウィンドウ クローズ" IsCheckVm="{Binding IsCheckVm}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>

    <StackPanel>
        <TextBlock Text="ウィンドウクローズのテスト" FontSize="18" HorizontalAlignment="Center" Margin="10" />

        <DockPanel Margin="0 6">
            <Button DockPanel.Dock="Right" Content="状態変更" Padding="10 4" Command="{Binding ChangeInqiryModeCommand}" />
            <Border DockPanel.Dock="Right" BorderBrush="Gray" BorderThickness="2" CornerRadius="3">
                <TextBlock Text="{Binding InquiryConditionText}" VerticalAlignment="Center" Padding="4 0" />
            </Border>
            <TextBlock DockPanel.Dock="Right" Text="ウィンドウを閉じる際の問い合わせ状態:" VerticalAlignment="Center" />
            <TextBlock Text=" " />
        </DockPanel>

        <DockPanel Margin="0 6">
            <Button DockPanel.Dock="Right" Content="状態変更" Padding="10 4" Command="{Binding ChangeCheckVmModeCommand}" />
            <Border DockPanel.Dock="Right" BorderBrush="Gray" BorderThickness="2" CornerRadius="3">
                <TextBlock Text="{Binding CheckVmConditionText}" VerticalAlignment="Center" Padding="4 0" />
            </Border>
            <TextBlock DockPanel.Dock="Right" Text="ViewModel への問い合わせ状態:" VerticalAlignment="Center" />
            <TextBlock Text=" " />
        </DockPanel>

        <DockPanel Margin="0 6">
            <Button DockPanel.Dock="Right" Content="状態変更" Padding="10 4" Command="{Binding ChangeCloseModeCommand}" />
            <Border DockPanel.Dock="Right" BorderBrush="Gray" BorderThickness="2" CornerRadius="3">
                <TextBlock Text="{Binding ConditionText}" VerticalAlignment="Center" Padding="4 0" />
            </Border>
            <TextBlock DockPanel.Dock="Right" Text="ViewModel の問い合わせ返答状態:" VerticalAlignment="Center" />
            <TextBlock Text=" " />
        </DockPanel>

        <Button Content="閉じる(From Click Event)" FontSize="14" Padding="10 4">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="Click">
                    <b:WindowCloseAction WindowClose="True" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Button>

        <Button
            Content="閉じる(From ViewModel)" Command="{Binding CloseCommand}" FontSize="14" Padding="10 4" />

        <Button
            Content="閉じる(From ViewModel &amp; 問い合わせなし)" Command="{Binding CloseWithNonInquiryCommand}"
            FontSize="14" Padding="10 4" />
    </StackPanel>
</Window>

以上で利用例が動きます。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です