WPF/MVVM/C#/Prism5.0 エラー通知の少し便利な仕組み -ErrorsContainer-


WPFでMVVMは難しい

残念なことに、WPFでMVVMパターンを適用する際には、.NET標準だけ使うとなると、綺麗でわかりやすく保守が容易なコードが書けません。
書けないような基盤しかないのです。
なので、PrismなどのMVVM基盤ライブラリが必要となります。
https://msdn.microsoft.com/ja-jp/library/gg406140.aspx

他にも様々なライブラリが公開されていますが、MS謹製ということで今回はPrismを利用しようと思います。
Prismを利用することで得られるメリットを公開します。
以下の予定です。

  1. BindableBase/DelegateCommand ~ViewModelの基盤~
  2. ErrorsContaier ~便利なエラー通知~
  3. ViewModelLocationProvider ~ViewとViewModelを自動で関連付け~
  4. Regionってなんなのさ ~Viewの配置をお手軽に~
  5. IModuleとUnity ~UIでDI~
  6. DIPパターンの恩恵 ~MSBuildで並列ビルド~

本稿は上記2の記事になります。

※Visual Studio 2013 Community Editionで実験しています。

UI上でのエラー通知基盤

INotifyDataErrorInfoを利用すると、以下のように、入力した瞬間に値のチェックを行うような仕組みを、実現できます。
(下の例は、範囲0~1000までしか入力できないのに、"a"と入れたためチェックに引っかかった)

.NETには、System.ComponentModel名前空間に
INotifyDataErrorInfo
というインターフェースが元々存在します。

ただ、普通に使うとかなり面倒なのです。コード量も増えます。
それを少し助けてくれるのが、PrismのErrorsContainerです。使ってみます。

前回のCalcViewModelに、INotifyDataErrorInfoを実装します。

CalcViewModel.cs
public class CalcViewModel : BindableBase, INotifyDataErrorInfo
{
        public event EventHandler< DataErrorsChangedEventArgs> ErrorsChanged;

        public System.Collections.IEnumerable GetErrors(string propertyName)
        {
            throw new NotImplementedException();
        }

        public bool HasErrors
        {
            get { throw new NotImplementedException(); }
        }
}

その上で、
private ErrorsContainer <string> _errors;
メンバ変数を追加します。

ViewModelのコンストラクタでnewしてやり、OnErrorChangesをデリゲートします。

CalcViewModel.cs
        private ErrorsContainer <string> _errors;

        public event EventHandler< DataErrorsChangedEventArgs> ErrorsChanged;

        public System.Collections.IEnumerable GetErrors(string propertyName)
        {
            return _errors.GetErrors(propertyName);
        }

        public bool HasErrors
        {
            get { return _errors.HasErrors; }
        }

        public CalcViewModel()
        {
            _errors = new ErrorsContainer <string>(OnErrorsChanged);
        }

        private void OnErrorsChanged([CallerMemberName] string propertyName = null )
        {
            var handler = this .ErrorsChanged;
            if (handler != null )
            {
                handler( this, new DataErrorsChangedEventArgs(propertyName));
            }
        }

うわー、ここまででも結構複雑。でも頑張る。

System.ComponentModel.DataAnnotationsの外部参照を追加します。

プロパティ値チェック用のメソッドを作ります。

CalcViewModel.cs
protected void ValidateProperty(object value, [CallerMemberName] string propertyName = null )
{
    var context = new ValidationContext( this)
    {
        MemberName = propertyName
    };
    var validationErrors = new List< ValidationResult>();
    if (!Validator .TryValidateProperty(value, context, validationErrors))
    {
        this._errors.SetErrors(propertyName, validationErrors.Select(error => error.ErrorMessage));
    }
    else
    {
        this._errors.ClearErrors(propertyName);
    }
}

internal bool ValidateAllObjects()
{
    if (!this .HasErrors)
    {
        var context = new ValidationContext( this);
        var validationErrors = new List< ValidationResult>();
        if (Validator .TryValidateObject(this, context, validationErrors))
        {
            return true ;
        }

        var errors = validationErrors.Where(_ => _.MemberNames.Any()).GroupBy(_ => _.MemberNames.First());
        foreach (var error in errors)
        {
            _errors.SetErrors(error.Key, error.Select(_ => _.ErrorMessage));
        }
    }
    return false ;
}

ValidateAllObjectsは、ICommandのCanExecuteで呼ぶものです。これを呼ばないと、画面表示時の初回判定を行ってくれません。

で、XAMLを以下のようにします。

CalcView.xaml
             <TextBlock Grid.Row ="0" Text="{ Binding ElementName=LeftValue, Path=(Validation.Errors)[ 0].ErrorContent}" Foreground ="Red" Name="LeftValueChecker"   TextAlignment="Center" />
            <TextBox Grid.Row ="1" Text="{ Binding LeftValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Name="LeftValue" VerticalContentAlignment ="Center" TextAlignment="Center" />
            <TextBlock Grid.Row ="2"  Text ="+" HorizontalAlignment="Center" VerticalAlignment="Center"/>
            <TextBlock Grid.Row ="3" Text="{ Binding ElementName=RightValue, Path=(Validation.Errors)[ 0].ErrorContent}" Foreground ="Red" Name="RightValueChecker"   TextAlignment="Center" />
            <TextBox Grid.Row ="4" Text="{ Binding RightValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Name="RightValue" VerticalContentAlignment ="Center" TextAlignment="Center" />
             <Button Grid.Row ="5" Name="CalcButton" Content="=" Command ="{Binding CalcCommand , Mode=OneWay}" />

UpdateSourceTrigger=PropertyChanged
にしないと、値が変わった瞬間即時チェックしてくれません。

ViewModelのプロパティに属性と、ValidatePropertyを呼ぶように変更します。

CalcViewModel.cs
        private string _leftValue;

        [ Required(ErrorMessage = "必須入力です。" )]
        public string LeftValue
        {
            get { return _leftValue; }
            set
            {
                this.SetProperty(ref this._leftValue, value);
                this.ValidateProperty(value );
            }
        }

        private string _rightValue;

        [ Required(ErrorMessage = "必須入力です。" )]
        public string RightValue
        {
            get { return _rightValue; }
            set
            {
                this.SetProperty(ref this._rightValue, value);
                this.ValidateProperty(value );           
            }
        }

あー、疲れた。

何か文字を入力すると、
「必須入力です。」が消えます。

これだと、各ViewModelにこれを実装しないといけないので、かなり面倒です。
BindableBaseを継承した抽象基底クラスを自身で実装したほうが良いでしょうね。

SetPropertyの後のValidatePropertyを書き忘れたらアウトです。
this.SetProperty(ref this._leftValue, value);
this.ValidateProperty(value );

もうちょっとprism側で頑張って欲しいなぁ。

ちなみに、Required属性を真似て、ValidationAttributeを継承したチェック属性を作れます。

例えば、
[Range(0, 1000, ErrorMessage = "Value for {0} must be between {1} and {2}." )]
なんてやると範囲指定チェックができます。

上記にあるAttributeが使えますので、自分で作ることはほぼ無さそうですね。
特に、
RegularExpression
が強力な気がしますね。

やはりErrorsContainerは説明が難しい感がします。
GitHubにプロジェクトを置いておきました。
https://github.com/Koki-Shimizu/PrismSample_ErrorsContainer.git

まとめ

・ErrorsContainerを使うと、ValidationAttributeを用いた即時チェックが可能となる。
・Prism5.0でErrorsContainerを用意してくれているが、自分でコードを結構書く必要がある。
・ValidationAttributeは様々なValidationが.NET標準(System.ComponentModel.DataAnnotations)で用意されている。
・WPFでエラーチェックを行う場合は、この仕組に乗ったほうが良さそう。(乗らなくても、結局同じようなことを実現したいなら、同じようなコードを書く必要がでてくる)

次回少し脱線して、ValidationAttributeを少し実験してみたいと思います。