かずきのBlog@hatena

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

MVVMにおけるView層での入力値エラーの有無をViewModelで知る方法

MSDNフォーラムで以下の質問があったので考えてみました。

シナリオとしてはこんな感じ。

  • ViewModelではint型のプロパティを公開している
  • View側でTextBoxにバインドしている
  • ValidatesOnExceptions=trueとValidation.ErrorTemplateでView側でエラー表示してる

上記のやり方をしてるときに、ViewModel側でViewでエラーが起きてるかどうかを知りたい!ということみたいです。

ということでまず、土台を作ります。ViewModelの基本クラスを定義するのがめんどくさかったのでPrismの基本クラスを使用しています。まず、MainWindowViewModelを定義します。

namespace WpfApplication25
{
    using Microsoft.Practices.Prism.Commands;
    using Microsoft.Practices.Prism.ViewModel;

    public class MainWindowViewModel : NotificationObject
    {
        private int input;
        private int input2;

        public MainWindowViewModel()
        {
            this.SampleCommand = new DelegateCommand(
                // Execute
                () =>
                {
                },
                // CanExecute
                () =>
                {
                    // 入力エラーがあったらfalseを返したいよね
                    return true;
                });
        }

        public DelegateCommand SampleCommand { get; private set; }

        public int Input
        {
            get
            {
                return this.input;
            }

            set
            {
                this.input = value;
                base.RaisePropertyChanged(() => Input);
            }
        }

        public int Input2
        {
            get
            {
                return this.input2;
            }

            set
            {
                this.input2 = value;
                base.RaisePropertyChanged(() => Input2);
            }
        }
    }
}

とりあえず、2つの入力値を受け取るプロパティと1つのコマンドを持つようにしました。次にViewにバインドします。

<Window
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:l="clr-namespace:WpfApplication25"
        x:Class="WpfApplication25.MainWindow"
        Title="MainWindow" Height="350" Width="525">
    <Window.DataContext>
        <l:MainWindowViewModel />
    </Window.DataContext>
    <StackPanel>
        <TextBox Margin="5" Text="{Binding Input, ValidatesOnExceptions=True, UpdateSourceTrigger=PropertyChanged}">
            <Validation.ErrorTemplate>
                <ControlTemplate>
                    <Border BorderBrush="Red" BorderThickness="3">
                        <AdornedElementPlaceholder />
                    </Border>
                </ControlTemplate>
            </Validation.ErrorTemplate>
        </TextBox>
        <TextBox Margin="5" Text="{Binding Input2, ValidatesOnExceptions=True, UpdateSourceTrigger=PropertyChanged}">
            <Validation.ErrorTemplate>
                <ControlTemplate>
                    <Border BorderBrush="Red" BorderThickness="3">
                        <AdornedElementPlaceholder />
                    </Border>
                </ControlTemplate>
            </Validation.ErrorTemplate>
        </TextBox>
        <Button Content="OK" Command="{Binding Path=SampleCommand}" />
    </StackPanel>
</Window>

バリデーションエラーで赤枠を表示するようにしたTextBox2つとCommandをバインドするためのボタンを置いてるだけのシンプルな画面にしました。テキストボックスの変更は、すぐにViewModelへ通知するようにしています。
とりあえず、この時点で実行すると以下のような動きになります。

実行直後

数字以外を入力したとき

この時の入力エラーは、完全にView側で閉じてるため、ViewModelでは検知することが出来ません。なので、View側でのエラーの有無を通知してやるBehaviorを用意してやることにしました。
とりあえずさっくり以下のように実装してみました。(Expression Blend 4 SDKのSystem.Windows.Interactivityが必要です)

namespace WpfApplication25
{
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Interactivity;
    using System.Windows.Input;

    public class ValidationErrorBehavior : Behavior<DependencyObject>
    {
        // View内でのエラーの数
        private int errroCount;

        // View内でのエラーの有無
        public bool HasViewError
        {
            get { return (bool)GetValue(HasViewErrorProperty); }
            set { SetValue(HasViewErrorProperty, value); }
        }

        public static readonly DependencyProperty HasViewErrorProperty =
            DependencyProperty.Register("HasViewError", typeof(bool), typeof(ValidationErrorBehavior), new UIPropertyMetadata(false));

        protected override void OnAttached()
        {
            base.OnAttached();
            // エラーがあったときのイベントハンドラ登録
            Validation.AddErrorHandler(this.AssociatedObject, this.ErrorHandler);
        }

        protected override void OnDetaching()
        {
            // イベントハンドラ登録解除
            Validation.RemoveErrorHandler(this.AssociatedObject, this.ErrorHandler);
            base.OnDetaching();
        }

        private void ErrorHandler(object sender, ValidationErrorEventArgs e)
        {
            if (e.Action == ValidationErrorEventAction.Added)
            {
                // Actionを見て、エラーが追加の時はカウントアップ
                errroCount++;
            }
            else if (e.Action == ValidationErrorEventAction.Removed)
            {
                // エラーが消えたときはカウントダウン
                errroCount--;
            }
            // エラーの有無をHasViewErrorにセット
            this.HasViewError = this.errroCount != 0;
        }
    }
}

Validationエラーのイベントをつかまえて、単純にカウントアップ、カウントダウンしてるだけです。使い方としては、このBehaviorのHasViewErrorをOneWayToSourceでViewModel側にプッシュしてやるつもりです。
ということでViewModel側に、この値を受け取るプロパティなど追加してみましょう。

namespace WpfApplication25
{
    using Microsoft.Practices.Prism.Commands;
    using Microsoft.Practices.Prism.ViewModel;

    public class MainWindowViewModel : NotificationObject
    {
        private int input;

        private int input2;

        private bool hasViewError;

        public bool HasViewError 
        { 
            get
            {
                return this.hasViewError;
            }

            set 
            {
                this.hasViewError = value;
                base.RaisePropertyChanged(() => HasViewError);

                // ここでやるのか悩んだけどコマンドの状態変更があったことを通知
                this.SampleCommand.RaiseCanExecuteChanged();
            }
        }

        public MainWindowViewModel()
        {
            this.SampleCommand = new DelegateCommand(
                // Execute
                () =>
                {
                },
                // CanExecute
                () =>
                {
                    // 入力エラーがあったら押せないようにする
                    return !this.HasViewError;
                });
        }

        public DelegateCommand SampleCommand { get; private set; }

        public int Input
        {
            get
            {
                return this.input;
            }

            set
            {
                this.input = value;
                base.RaisePropertyChanged(() => Input);
            }
        }

        public int Input2
        {
            get
            {
                return this.input2;
            }

            set
            {
                this.input2 = value;
                base.RaisePropertyChanged(() => Input2);
            }
        }
    }
}

そして、さっき追加したBehaviorをViewに追加します。

<Window
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:l="clr-namespace:WpfApplication25"
        x:Class="WpfApplication25.MainWindow"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" 
        Title="MainWindow" Height="350" Width="525">
    <Window.DataContext>
        <l:MainWindowViewModel />
    </Window.DataContext>
    <i:Interaction.Behaviors>
        <l:ValidationErrorBehavior HasViewError="{Binding HasViewError, Mode=OneWayToSource}"/>
    </i:Interaction.Behaviors>
    <StackPanel>
        <TextBox Margin="5" Text="{Binding Input, ValidatesOnExceptions=True, UpdateSourceTrigger=PropertyChanged, NotifyOnValidationError=True}">
            <Validation.ErrorTemplate>
                <ControlTemplate>
                    <Border BorderBrush="Red" BorderThickness="3">
                        <AdornedElementPlaceholder />
                    </Border>
                </ControlTemplate>
            </Validation.ErrorTemplate>
        </TextBox>
        <TextBox Margin="5" Text="{Binding Input2, ValidatesOnExceptions=True, UpdateSourceTrigger=PropertyChanged, NotifyOnValidationError=True}">
            <Validation.ErrorTemplate>
                <ControlTemplate>
                    <Border BorderBrush="Red" BorderThickness="3">
                        <AdornedElementPlaceholder />
                    </Border>
                </ControlTemplate>
            </Validation.ErrorTemplate>
        </TextBox>
        <Button Content="OK" Command="{Binding Path=SampleCommand}" />
    </StackPanel>
</Window>

ポイントはさっきも言ったようにBehaviorのHasViewErrorをOneWayToSourceでバインドしてることです。あとBehaviorでValidationのErrorHandlerイベントを捕まえてるのでBindingでNotifyOnValidationError=Trueを設定するのもポイントです。これを忘れるとうまく動きません!!
ということで動かしてみましょう。

エラーがあるとちゃんとボタンが押せなくなってます。

エラーが無くなるとボタンが押せるようになります。

とりあえず、こんな感じでどうだろう。