SourceChord

C#とXAML好きなプログラマの備忘録。最近はWPF系の話題が中心です。

WPFでの入力値検証・その6 ~複数のエラー表示への対応~

以前↓で書いたINotifyDataErrorInfoのベースクラスの実装ですが、一つのプロパティに複数のエラーが同時発生している、という状態を正しく表現できていなかったので修正しました。
WPFでの入力値検証・その5 ~INotifyDataErrorInfo実装のベースクラスを作成~ - SourceChord

複数のエラーが同時に発生するようにVMを修正

前回の実装では、複数のエラーが同時に発生しないので、以下のようにDataAnnotationsのエラー属性を増やしました。
RegularExpression属性で、小文字の英字のみを受け付けるという条件を追加しています。

        private string inputString;
        [Required(ErrorMessage = "何か入力してください")]
        [StringLength(10, ErrorMessage = "10文字以内で入力してください")]
        [RegularExpression("[a-z]+", ErrorMessage = "a-zの文字列を入力してください。")]
        public string InputString
        {
            get { return inputString; }
            set { this.SetProperty(ref this.inputString, value); }
        }

複数エラーの表示に対応した、ErrorTemplate

複数のエラーが同時発生しているときに、すべてのエラー状態を表示できるようにErrorTemplateを修正しました。
以下のような感じ。

        <ControlTemplate x:Key="ValidationTemplate">
            <StackPanel>
                <ItemsControl ItemsSource="{Binding AdornedElement.(Validation.Errors), ElementName=adornedelem}">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Foreground="Red" Text="{Binding ErrorContent}" />
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
                <AdornedElementPlaceholder x:Name="adornedelem" />
            </StackPanel>
        </ControlTemplate>
        <!--中略-->
        <TextBox Width="120"
                 Height="23"
                 Margin="50"
                 HorizontalAlignment="Left"
                 VerticalAlignment="Top"
                 Text="{Binding InputString,
                                UpdateSourceTrigger=PropertyChanged}"
                 Validation.ErrorTemplate="{StaticResource ValidationTemplate}">

Validation.ErrorTemplateに設定するControlTemplate内で、ItemsControlを使い複数のエラーの内容を表示できるようにしています。
複数のエラーが発生した時には、以下のようにエラー表示が上に積み上がっていきます。
f:id:minami_SC:20141020003332p:plain

INotifyDataErrorInfoの実装を修正

前回の実装のバグ

前回作ったValidateableBaseクラスの実装では、複数のエラーが発生した後に、一部のエラーだけ解除されても、エラー状態が適切に解除されていませんでした。

例)

「何か入力してください」というエラーが発生しているときに、テキストブロックに数字を入力し、「a-zの文字列を入力してください。」というエラーを発生させると、
文字が入力されているのに、最初のエラーが解除されず、以下のように二つのエラーが発生したままになってしまいます。
f:id:minami_SC:20141020003342p:plain

前回のコードではValidatePropertyメソッドでのエラー解除方法が間違っていました。
対象のプロパティのエラーがすべてなくなった場合に、そのプロパティのエラーをすべて解除する、という動作をしていたため、一部のエラーが解除されてもそのままエラー状態が続いてしまいます。

修正内容

SetErrors/ClearErrorsというメソッドを作り、対象のプロパティで発生しているエラーを「一度に複数登録/すべてまとめて解除」という動作ができるようにしています。
この辺、Prism.MvvmのErrorsContainerクラスを参考にしてます。

これで、複数のエラーが同時に発生するような場合にも、適切にエラー表示ができるようになります。

        /// <summary>
        /// 引数で指定されたプロパティに、errorsで指定されたエラーをすべて登録します。
        /// </summary>
        /// <param name="propertyName"></param>
        /// <param name="errors"></param>
        protected void SetErrors(string propertyName, IEnumerable<string> errors)
        {
            var hasCurrentError = _currentErrors.ContainsKey(propertyName);
            var hasNewError = errors != null && errors.Count() > 0;

            if (!hasCurrentError && !hasNewError)
                return;

            if (hasNewError)
            {
                _currentErrors[propertyName] = new List<string>(errors);
            }
            else
            {
                _currentErrors.Remove(propertyName);
            }
        }

        /// <summary>
        /// 引数で指定されたプロパティのエラーをすべて解除します。
        /// </summary>
        /// <param name="propertyName"></param>
        protected void ClearErrors(string propertyName)
        {
            if (_currentErrors.ContainsKey(propertyName))
            {
                _currentErrors.Remove(propertyName);
                OnErrorsChanged(propertyName);
            }
        }

サンプルコード

今回のすべてのコードは以下の通りです。

MainWindow.xaml
<Window x:Class="WpfBaseTemplate1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfBaseTemplate1"
        Title="MainWindow"
        Width="525"
        Height="350">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <Window.Resources>
        <ControlTemplate x:Key="ValidationTemplate">
            <StackPanel>
                <ItemsControl ItemsSource="{Binding AdornedElement.(Validation.Errors), ElementName=adornedelem}">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Foreground="Red" Text="{Binding ErrorContent}" />
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
                <AdornedElementPlaceholder x:Name="adornedelem" />
            </StackPanel>
        </ControlTemplate>
    </Window.Resources>
    <Grid>
        <TextBox Width="120"
                 Height="23"
                 Margin="50"
                 HorizontalAlignment="Left"
                 VerticalAlignment="Top"
                 Text="{Binding InputString,
                                UpdateSourceTrigger=PropertyChanged}"
                 Validation.ErrorTemplate="{StaticResource ValidationTemplate}">
        </TextBox>
    </Grid>
</Window>
MainWindowViewModel.cs
    class MainWindowViewModel : ValidateableBase
    {
        private string inputString;
        [Required(ErrorMessage = "何か入力してください")]
        [StringLength(10, ErrorMessage = "10文字以内で入力してください")]
        [RegularExpression("[a-z]+", ErrorMessage = "a-zの文字列を入力してください。")]
        public string InputString
        {
            get { return inputString; }
            set { this.SetProperty(ref this.inputString, value); }
        }

        public MainWindowViewModel()
        {
            this.InputString = string.Empty;
        }
    }
BindableBase.cs
    /// <summary>
    /// モデルを簡略化するための <see cref="INotifyPropertyChanged"/> の実装。
    /// </summary>
    public abstract class BindableBase : INotifyPropertyChanged
    {
        /// <summary>
        /// プロパティの変更を通知するためのマルチキャスト イベント。
        /// </summary>
        public event PropertyChangedEventHandler PropertyChanged;

        /// <summary>
        /// プロパティが既に目的の値と一致しているかどうかを確認します。必要な場合のみ、
        /// プロパティを設定し、リスナーに通知します。
        /// </summary>
        /// <typeparam name="T">プロパティの型。</typeparam>
        /// <param name="storage">get アクセス操作子と set アクセス操作子両方を使用したプロパティへの参照。</param>
        /// <param name="value">プロパティに必要な値。</param>
        /// <param name="propertyName">リスナーに通知するために使用するプロパティの名前。
        /// この値は省略可能で、
        /// CallerMemberName をサポートするコンパイラから呼び出す場合に自動的に指定できます。</param>
        /// <returns>値が変更された場合は true、既存の値が目的の値に一致した場合は
        /// false です。</returns>
        protected virtual bool SetProperty<T>(ref T storage, T value, [CallerMemberName] String propertyName = null)
        {
            if (object.Equals(storage, value)) return false;

            storage = value;
            this.OnPropertyChanged(propertyName);
            return true;
        }

        /// <summary>
        /// プロパティ値が変更されたことをリスナーに通知します。
        /// </summary>
        /// <param name="propertyName">リスナーに通知するために使用するプロパティの名前。
        /// この値は省略可能で、
        /// <see cref="CallerMemberNameAttribute"/> をサポートするコンパイラから呼び出す場合に自動的に指定できます。</param>
        protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            var eventHandler = this.PropertyChanged;
            if (eventHandler != null)
            {
                eventHandler(this, new PropertyChangedEventArgs(propertyName));
            }
        }
    }
ValidatableBase.cs
    public abstract class ValidateableBase : BindableBase, INotifyDataErrorInfo
    {
        /// <summary>
        /// プロパティが既に目的の値と一致しているかどうかを確認します。必要な場合のみ、
        /// プロパティを設定し、リスナーに通知します。
        /// その後、プロパティの入力値検証を行います。
        /// </summary>
        /// <typeparam name="T">プロパティの型。</typeparam>
        /// <param name="storage">get アクセス操作子と set アクセス操作子両方を使用したプロパティへの参照。</param>
        /// <param name="value">プロパティに必要な値。</param>
        /// <param name="propertyName">リスナーに通知するために使用するプロパティの名前。
        /// この値は省略可能で、
        /// CallerMemberName をサポートするコンパイラから呼び出す場合に自動的に指定できます。</param>
        /// <returns>値が変更された場合は true、既存の値が目的の値に一致した場合は
        /// false です。</returns>
        protected override bool SetProperty<T>(ref T storage, T value, [CallerMemberName] String propertyName = null)
        {
            var isChanged = base.SetProperty(ref storage, value, propertyName);
            if (isChanged)
                this.ValidateProperty(value, propertyName);

            return isChanged;
        }

        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))
            {
                var errors = validationErrors.Select(error => error.ErrorMessage);
                SetErrors(propertyName, errors);
            }
            else
            {
                ClearErrors(propertyName);
            }
        }

        #region 発生中のエラーを保持する処理を実装
        readonly Dictionary<string, List<string>> _currentErrors = new Dictionary<string, List<string>>();

        /// <summary>
        /// 引数で指定されたプロパティに、errorsで指定されたエラーをすべて登録します。
        /// </summary>
        /// <param name="propertyName"></param>
        /// <param name="errors"></param>
        protected void SetErrors(string propertyName, IEnumerable<string> errors)
        {
            var hasCurrentError = _currentErrors.ContainsKey(propertyName);
            var hasNewError = errors != null && errors.Count() > 0;

            if (!hasCurrentError && !hasNewError)
                return;

            if (hasNewError)
            {
                _currentErrors[propertyName] = new List<string>(errors);
            }
            else
            {
                _currentErrors.Remove(propertyName);
            }
            OnErrorsChanged(propertyName);
        }

        /// <summary>
        /// 引数で指定されたプロパティのエラーをすべて解除します。
        /// </summary>
        /// <param name="propertyName"></param>
        protected void ClearErrors(string propertyName)
        {
            if (_currentErrors.ContainsKey(propertyName))
            {
                _currentErrors.Remove(propertyName);
                OnErrorsChanged(propertyName);
            }
        }
        #endregion

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

        #region INotifyDataErrorInfoの実装
        public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

        public System.Collections.IEnumerable GetErrors(string propertyName)
        {
            if (string.IsNullOrEmpty(propertyName) ||
                !_currentErrors.ContainsKey(propertyName))
                return null;

            return _currentErrors[propertyName];
        }

        public bool HasErrors
        {
            get { return _currentErrors.Count > 0; }
        }
        #endregion
    }
2015/01/29 追記

※SetErrorsを呼んだ時にエラー更新のイベントを発生させておらず、エラーが更新されない可能性がありました。
ValidatableBaseクラスのSetErrorsメソッド最後に以下の一文を追加しています。

OnErrorsChanged(propertyName);