다음을 통해 공유


WPF 4.5: Validating Data in Using the INotifyDataErrorInfo Interface

Introduction

You can validate data in a WPF application using the System.ComponentModel.INotifyDataErrorInfo interface that was introduced in the .NET Framework 4.5 (the same interface has been present in Silverlight since version 4).

It is a common requirement for any user interface application that accepts user input to validate the entered information to ensure that it has the expected format and type. Since .NET Framework 3.5, you have been able to use the IDataErrorInfo interface to validate properties of a view model or model that is bound to some element in the view. While this interface basically only provides the capability to return a string that specifies what is wrong with a single given property, the new INotifyDataErrorInfo interface gives you a lot more flexibility and should, in general, be used when implementing new classes.

  • It enables you to perform server-side validations asynchronously and then notify the view by raising an ErrorsChanged event once the validations are completed.
  • It makes it possible to invalidate a property when setting another property.
  • It supports setting multiple errors per property.
  • It supports custom error objects of some other type than System.String (string).
/* The built-in System.ComponentModel.INotifyDataErrorInfo interface */
public interface  INotifyDataErrorInfo
{     
 bool HasErrors { get; }
 event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
 IEnumerable GetErrors(string propertyName); 
}

 

Multiple errors per property

The GetErrors method of the interface returns an IEnumerable that contains validation errors for the specified property or for the entire entity. You should always raise the ErrorsChanged event whenever the collection returned by the GetErrors method changes. If the source of a two-way data binding implements the INotifyDataErrorInfo interface and the ValidatesOnNotifyDataErrors property of the binding is set to true (which it is by default), the WPF 4.5 binding engine automatically monitors the ErrorsChanged event and calls the GetErrors method to retrieve the updated errors once the event is raised from the source object, provided that the HasErrors property returns true.

Below is an example of a simple service with a single method that validates a username by first querying a database to determine whether it is already in use or not and then checks the length of it and finally determines whether it contains any illegal characters by using a regular expression. The method returns true or false depending on whether the validation succeeded or not and it also returns a collection of error messages as an out parameter. Declaring an argument as out is useful when you want a method in C# to return multiple values.

public interface  IService
{
    bool ValidateUsername(string username, out ICollection<string> validationErrors);
}
  
public class  Service : IService
{
    public bool  ValidateUsername(string username, out ICollection<string> validationErrors)
    {
        validationErrors = new  List<string>();
        int count = 0;
        using (SqlConnection conn = new SqlConnection(ConfigurationManager.ConnectionStrings[0].ConnectionString))
        {
            SqlCommand cmd = new  SqlCommand("SELECT COUNT(*) FROM [Users] WHERE Username = @Username", conn);
            cmd.Parameters.Add("@Username", SqlDbType.VarChar);
            cmd.Parameters["@Username"].Value = username;
            conn.Open();
            count = (int)cmd.ExecuteScalar();
        }
  
        if (count > 0)
            validationErrors.Add("The supplied username is already in use. Please choose another one.");
  
        /* Verifying that length of username */
        if (username.Length > 10 || username.Length < 4)
            validationErrors.Add("The username must be between 4 and 10 characters long.");
  
        /* Verifying that the username contains only letters */
        if (!Regex.IsMatch(username, @"^[a-zA-Z]+$"))
            validationErrors.Add("The username must only contain letters (a-z, A-Z).");
  
        return validationErrors.Count == 0;
    }
}

Asynchronous validation

The following view model implementation of the INotifyDataErrorInfo interface then uses this service to perform the validation asynchronously. Besides a reference to the service itself, it has a Dictionary<string, ICollection<string>> where the key represents a name of a property and the value represents a collection of validation errors for the corresponding property.

public class  ViewModel : INotifyDataErrorInfo
{
    private readonly  IService _service;
    private readonly  Dictionary<string, ICollection<string>>
        _validationErrors = new  Dictionary<string, ICollection<string>>();
  
    public ViewModel(IService service)
    {
        _service = service;
    }
  
    ...
  
    #region INotifyDataErrorInfo members
    public event  EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    private void  RaiseErrorsChanged(string propertyName)
    {
        if (ErrorsChanged != null)
            ErrorsChanged(this, new  DataErrorsChangedEventArgs(propertyName));
    }
  
    public System.Collections.IEnumerable GetErrors(string propertyName)
    {
        if (string.IsNullOrEmpty(propertyName)
            || !_validationErrors.ContainsKey(propertyName))
            return null;
  
        return _validationErrors[propertyName];
    }
  
    public bool  HasErrors
    {
        get { return _validationErrors.Count > 0; }
    }
    #endregion
}

The setter of a Username property of the view model is then using a private method to call the service method asynchronously using the async and await keywords - these were added to introduce a simplified approach to asynchronous programming in the .NET Framework 4.5 and the Windows Runtime (WinRT) - and update the dictionary based on the result of the validation:

private string  _username;
public string  Username 
{
    get { return _username; }
    set
    { 
        _username = value;
        ValidateUsername(_username);
    }
}
  
private async void ValidateUsername(string username)
{
    const string  propertyKey = "Username";
    ICollection<string> validationErrors = null;
    /* Call service asynchronously */
    bool isValid = await Task<bool>.Run(() => 
    { 
        return _service.ValidateUsername(username, out validationErrors); 
    })
    .ConfigureAwait(false);
  
    if (!isValid)
    {
        /* Update the collection in the dictionary returned by the GetErrors method */
        _validationErrors[propertyKey] = validationErrors;
        /* Raise event to tell WPF to execute the GetErrors method */
        RaiseErrorsChanged(propertyKey);
    }
    else if(_validationErrors.ContainsKey(propertyKey))
    {
        /* Remove all errors for this property */
        _validationErrors.Remove(propertyKey);
        /* Raise event to tell WPF to execute the GetErrors method */
        RaiseErrorsChanged(propertyKey);
    }
}

 

Visual feedback

If a user enters an invalid username and the validation fails, a validation error will occur and a visual feedback will be provided to the user to indicate this. By default you will see a red border around the UI element when this happens:

http://magnusmontin.files.wordpress.com/2013/08/defaulterrortemplate.png

The actual message that is describing the error is stored in the ErrorContent property of a System.Windows.Controls.ValidationError object that is added to the Validation.Errors collection of the bound element by the binding engine at runtime. When the attached property Validation.Errors have ValidationError objects in it, another attached property named Validation.HasError returns true.

To be able to see the error messages in the view you can replace the default control template that draws the red border around the element with your own custom template by setting the Validation.ErrorTemplate attached property of the control. You typically use an ItemsControl present a collection of items in XAML:

<TextBox Text="{Binding Username, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}">
    <Validation.ErrorTemplate>
        <ControlTemplate>
            <StackPanel>
                <!-- Placeholder for the TextBox itself -->
                <AdornedElementPlaceholder x:Name="textBox"/>
                <ItemsControl ItemsSource="{Binding}">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding ErrorContent}" Foreground="Red"/>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </StackPanel>
        </ControlTemplate>
    </Validation.ErrorTemplate>
</TextBox>

http://magnusmontin.files.wordpress.com/2013/08/customerrortemplate.png

Note that the Validation.ErrorTemplate will be displayed on the adorner layer. Elements in the adorner layer are rendered on top of the rest of the visual elements and they will not be considered when the layout system is measuring and arranging the controls on the adorned element layer. The adorned element, in this case, is the TextBox control itself and you include an AdornedElementPlaceholder in the control template where you want to leave space for it. The template above will cause any validation error messages to be displayed below the TextBox. The TextBlocks containing the validation error messages rendered by the ItemsControl will appear on top of any elements that are located right below the TextBox as adorners are always visually on top.

Custom error objects

As mentioned, the INotifyErrorDataError interface also makes it possible to return error objects of any type from the GetErrors method and this can be very useful when you want to present some custom error reporting in the view. Consider the following sample type that has a string property that describes the validation error and an additional property of enumeration type that specifies the severity of the error:

public class  CustomErrorType
{
    public CustomErrorType(string validationMessage, Severity severity)
    {
        this.ValidationMessage = validationMessage;
        this.Severity = severity;
    }
  
    public string  ValidationMessage { get; private  set; }
    public Severity Severity { get; private  set; }
}
  
public enum  Severity
{
    WARNING,
    ERROR
}
  
public class  Service : IService
{
    /* The service method modifed to return objects of type CustomErrorType instead of System.String */
    public bool  ValidateUsername(string username, out ICollection<CustomErrorType> validationErrors)
    {
        validationErrors = new  List<CustomErrorType>();
        int count = 0;
        /* query database as before */
    ...
        if (count > 0)
            validationErrors.Add(new CustomErrorType("The supplied username is already in use. Please choose another one.", Severity.ERROR));
  
        /* Verifying that length of username */
        if (username.Length > 10 || username.Length < 4)
            validationErrors.Add(new CustomErrorType("The username should be between 4 and 10 characters long.", Severity.WARNING));
  
        /* Verifying that the username contains only letters */
        if (!Regex.IsMatch(username, @"^[a-zA-Z]+$"))
            validationErrors.Add(new CustomErrorType("The username must only contain letters (a-z, A-Z).", Severity.ERROR));
  
        return validationErrors.Count == 0;
    }
}

If you use the same ErrorTemplate as shown above to present validation errors of the above type, you will see the ToString() representation of it when an error has been detected. You can choose to override the ToString() method of the custom type to return an error message or simply adjust the template to fit the custom type. Below is for example how you could change the color of a validation error message based on the Severity property of the CustomErrorType object returned by the ErrorContent property of a ValidationError object in the Validation.Errors collection:

<Validation.ErrorTemplate>
     <ControlTemplate xmlns:local="clr-namespace:WpfApplication1">
         <StackPanel>
             <!-- Placeholder for the TextBox itself -->
             <AdornedElementPlaceholder x:Name="textBox"/>
             <ItemsControl ItemsSource="{Binding}">
                 <ItemsControl.ItemTemplate>
                     <DataTemplate>
                         <TextBlock Text="{Binding ErrorContent.ValidationMessage}">
                             <TextBlock.Style>
                                 <Style TargetType="{x:Type TextBlock}">
                                     <Setter Property="Foreground" Value="Red"/>
                                     <Style.Triggers>
                                         <DataTrigger Binding="{Binding ErrorContent.Severity}"
                                                                  Value="{x:Static local:Severity.WARNING}">
                                             <Setter Property="Foreground" Value="Orange"/>
                                         </DataTrigger>
                                     </Style.Triggers>
                                 </Style>
                             </TextBlock.Style>
                         </TextBlock>
                     </DataTemplate>
                 </ItemsControl.ItemTemplate>
             </ItemsControl>
         </StackPanel>
     </ControlTemplate>
 </Validation.ErrorTemplate>

http://magnusmontin.files.wordpress.com/2013/08/customerrortemplate2.png

Cross-property errors

As the GetErrors method returns a collection of validation errors for a given property, you can also easily perform cross-property validation - in cases where a change to a property value may cause an error in another property - by adding appropriate errors to the dictionary, or whatever collection you are using to store the validation error objects, and then tell the binding engine to re-call this method by raising the ErrorsChanged event.

This is illustrated in the below sample code where the Interest property is only mandatory when the Type property has a certain value and the validation of the Interest property occurs whenever either of the properties are set.

public class  ViewModel : INotifyDataErrorInfo
{
    private readonly  Dictionary<string, ICollection<string>>
        _validationErrors = new  Dictionary<string, ICollection<string>>();
  
    private Int16 _type;
    public Int16 Type
    {
        get { return _type; }
        set
        {
            _type = value;
            ValidateInterestRate();
        }
    }
  
    private decimal? _interestRate;
    public decimal? InterestRate
    {
        get { return _interestRate; }
        set
        {
            _interestRate = value;
            ValidateInterestRate();
        }
    }
  
    private const  string dictionaryKey = "InterestRate";
    private const  string validationMessage = "You must enter an interest rate.";
     
    /* The InterestRate property must have a value only if the Type property is set to 1 */
    private void  ValidateInterestRate()
    {
        if (_type.Equals(1) && !_interestRate.HasValue)
        {
            if (_validationErrors.ContainsKey(dictionaryKey))
                _validationErrors[dictionaryKey].Add(validationMessage);
            else
                _validationErrors[dictionaryKey] = new  List<string> { validationMessage };
            RaiseErrorsChanged("InterestRate");
        }
        else if  (_validationErrors.ContainsKey(dictionaryKey))
        {
            _validationErrors.Remove(dictionaryKey);
            RaiseErrorsChanged("InterestRate");
        }
    }
  
    #region INotifyDataErrorInfo members
    ...
    #endregion
}

Return to Top