2015年11月10日火曜日

WPFにおけるINotifyDataErrorInfoを使った値の検証(Validation)

2017/2/20追記: 続編はこちら WPFにおけるINotifyDataErrorInfoを使った値の検証 属性版

今回はこってこてのWPFの話をしたいと思います。

WPFには値の検証機能があります。


このように、認められた入力以外の入力があった場合、赤枠などにしてエラーを伝えてくれる機能です。ちょっといじればツールチップでエラーメッセージを表示したりすることもできますし、非常に明確にエラーの意を伝えられるので重宝しています。

この値の検証ですが、実装の方法と言えばValudationRule抽象クラスを継承したクラスを作り、それをバインディングのValidationRulesプロパティに指定してあげるという方法が私の頭の中にありましたし、それ以外知りませんでした。WPFの値の検証で調べるとこれに関する記事はたくさん出てきますしね。

しかし、この方法にはいろいろと問題点がありました。

Viewで簡単に値の検証をできるのはいいのですが、ValidationRule.Validate()メソッドに渡されるのはそのコントロールにバインディングされている現在の値(とCultureInfo)だけなので、その現在の値のみを使った検証以外はできません。上記の画像のように「テキストボックスに数字のみが入力されたかをチェックする」などといった検証では問題がありませんが、例えば別のテキストボックスで入力された値も使って検証をするような場合はこの方法ではできません。
また、値の検証はそのコントロール内でのみ完結してしまいます。 例えば、ダイアログボックスなどを実装する場合は、「正しい値が入力されていないうちにはOKボタンを押せなくする」などといった対応が必要になりますが、そもそも値の検証結果をViewModelで知る由もありません。コントロールのValidation.Errors添付プロパティに検証結果が入りますが、これをViewModelで読みだすのは至難の業でしょう。かといって、OKボタンにバインディングしているコマンドにValidationRule.Validate()メソッドで実装したものと同じ検証ロジックをコピペするのも良い実装とは言えません。

このように、 ValidationRule抽象クラスを継承したクラスを作る方法は、作り方こそシンプルで、IValueConverterと似た方式で作ることができるという意味で分かりやすいものの、実用上に様々な課題があります。WPFではこれらの問題をクリアした値の検証システムが望まれるわけです。

それが、INotifyDataErrorInfoインターフェースを実装したViewModelです。
ViewModelです。
ViewModelです。
ViewModelです。

はい。 ViewModelにエラー通知機能を実装するのです。そうです、それこそ、INotifyPropertyChangedインターフェースを実装したViewModelが、ViewModelのプロパティが変化したときにプロパティ変更イベントを発生させるように、INotifyDataErrorInfoインターフェースを実装したViewModelが、ViewModelのプロパティにエラーが発生したときにそれを通知することができるのです。ViewModelがエラー管理をするので、必要に応じて他のプロパティを参照できますし、OKボタンの制御も一緒にできてしまいます。とても素直で良い方法です。

さて、ここでINotifyDataErrorInfoインターフェースで実装しなければいけないメソッド等を見ていきましょう。

HasErrorsプロパティ

bool HasErrors { get; }
これはとてもシンプルなプロパティです。エラーがあるプロパティがあればTrue、なければFalseを返すプロパティです。すなわち、これを反転させれば、それがそっくりそのままOKボタンのIsEnabledになるわけですね。

GetErrorsメソッド

IEnumerable GetErrors(string propertyName)
これはプロパティ名を渡すとエラーを返してくれるメソッドです。WPFの都合上か、非ジェネリックのIEnumerableが返却値になっています。1つのプロパティに複数のエラーがある場合があるので、このような実装になっています。通常はエラーテキストの集まりであるIEnumerable<string>でも返しておけば大丈夫です。
なお、propertyNameが空かnullの場合はエンティティレベルでのエラーを返す必要があります。

ErrorChangedイベント

event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged
このプロパティはエラーの状態が変わったとき(エラーが発生した、エラー内容が変わった、消えた)ときに発火するイベントです。DataErrorsChangedEventArgsクラスにはエラー状態が変わったプロパティ名が入っています。


…とまあ、こんな程度です。これを実装したViewModelを作ってしまえばこっちのものなわけです。
例えばこんな感じの実装でどうでしょう。

public abstract class NotifyDataErrorInfoViewModel : ViewModel, INotifyDataErrorInfo
{
    private Dictionary<string, HashSet<string>> Errors = new Dictionary<string, HashSet<string>>();

    /// <summary>
    /// エラーを追加するメソッド
    /// </summary>
    /// <param name="PropertyName">プロパティ名</param>
    /// <param name="ErrorText">エラー文</param>
    /// <returns>同じエラー文が無ければtrue、あればfalse</returns>
    protected bool AddError(string PropertyName, string ErrorText)
    {
        if(string.IsNullOrEmpty(PropertyName))
            throw new ArgumentException(nameof(PropertyName));
        if(string.IsNullOrEmpty(ErrorText))
            throw new ArgumentException(nameof(ErrorText));

        if(!Errors.ContainsKey(PropertyName))
            Errors[PropertyName] = new HashSet<string>();

        bool ret = Errors[PropertyName].Add(ErrorText);
        if(ret)
            RaiseErrorsChanged(PropertyName);
        return ret;
    }

    /// <summary>
    /// エラーを消去するメソッド
    /// </summary>
    /// <param name="PropertyName">プロパティ名</param>
    /// <returns>削除できればtrue、元から無ければfalse</returns>
    protected bool ResetError(string PropertyName)
    {
        if(string.IsNullOrEmpty(PropertyName))
            throw new ArgumentException(nameof(PropertyName));

        bool ret = Errors.Remove(PropertyName);
        if(ret)
            RaiseErrorsChanged(PropertyName);
        return ret;
    }

    /// <summary>
    /// エラーがあるかを取得するメソッド
    /// </summary>
    /// <param name="propertyName">プロパティ名</param>
    /// <returns>エラー内容</returns>
    public System.Collections.IEnumerable GetErrors(string propertyName)
    {
        return GetErrorsG(propertyName);
    }

    /// <summary>
    /// GetErrorsのジェネリック版
    /// </summary>
    /// <param name="propertyName">プロパティ名</param>
    /// <returns>エラー内容</returns>
    public IEnumerable<string> GetErrorsG(string propertyName)
    {
        if(string.IsNullOrEmpty(propertyName))
            return Errors.Values.SelectMany(p => p).ToList().AsReadOnly();    //エンティティレベルでのエラー
        else if(Errors.ContainsKey(propertyName))
            return Errors[propertyName].ToList().AsReadOnly();
        else
            return Enumerable.Empty<string>();
    }

    /// <summary>
    /// エラーがあるかどうか
    /// </summary>
    public bool HasErrors => Errors.Any();

    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

    /// <summary>
    /// エラー変更を発行するメソッド
    /// </summary>
    /// <param name="propertyName">プロパティ名</param>
    void RaiseErrorsChanged(string propertyName)
    {
        if(ErrorsChanged != null)
            ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
    }
}


C#6.0はnameof演算子があるので式木などを用いた手の込んだプロパティ名渡し用のオーバーロードなどはもう作らなくていいでしょう。また、HasErrorsプロパティはexpression-bodiedプロパティにしてみました。

さて、実際のViewModelはこんな感じになります。

public class MainWindowViewModel : NotifyDataErrorInfoViewModel
{
    public void Initialize()
    {
        CheckDigitText();
        CheckNumberText();
    }

    void CheckNumberText()
    {
        if(!GetErrorsG(nameof(DigitText)).Any() && !string.IsNullOrEmpty(NumberText) &&
           NumberText.All(p => char.IsNumber(p)) && NumberText.Count() == int.Parse(DigitText))
            ResetError(nameof(NumberText));
        else
            AddError(nameof(NumberText), "This is not a correct number.");
    }

    void CheckDigitText()
    {
        if(!string.IsNullOrEmpty(DigitText) && DigitText.All(p => char.IsNumber(p)))
            ResetError(nameof(DigitText));
        else
            AddError(nameof(DigitText), "This is not a number.");
    }

    #region NumberText変更通知プロパティ

    public string NumberText
    {
        get { return _NumberText; }
        set
        {
            if(_NumberText == value)
                return;
            _NumberText = value;
            RaisePropertyChanged(nameof(NumberText));

            CheckNumberText();
        }
    }
    private string _NumberText;

    #endregion

    #region DigitText変更通知プロパティ

    public string DigitText
    {
        get { return _DigitText; }
        set
        {
            if(_DigitText == value)
                return;
            _DigitText = value;
            RaisePropertyChanged(nameof(DigitText));

            CheckDigitText();
            CheckNumberText();
        }
    }
    private string _DigitText;

    #endregion
}

NumberTextとDigitTextはそれぞれ値とその桁数を入力させるテキストボックスにバインディングするstring型のプロパティです。桁数が変わると値の正当性も変わってくるので、桁数が変更になったときはCheckNumberText()のほうも呼ぶようにしています。


これは5桁を指定しているので値のほうの正当性も認められたということがわかる画面です。このように、複数のプロパティをまたいだ値の検証がとても簡単にできる仕組みです。

これから、値検証が必要なViewを作るときは、どんどんこのINotifyDataErrorInfoを使っていきたいと思います。

0 件のコメント:

コメントを投稿