How to add validation to view model properties or how to implement INotifyDataErrorInfo

后端 未结 1 1513
轮回少年
轮回少年 2020-12-01 15:40

I have a data collection of type ObservableCollection (say instance as myClassTypes). After some user operation, this myClassTypes populated with values in ViewModel. In vie

相关标签:
1条回答
  • 2020-12-01 16:15

    The preferred way since .Net 4.5 to implement data validation is to let your view model implement INotifyDataErrorInfo (example from Technet, example from MSDN (Silverlight)).

    Note: INotifyDataErrorInfo replaces the obsolete IDataErrorInfo.


    How INotifyDataErrorInfo works

    When the ValidatesOnNotifyDataErrors property of Binding is set to true, the binding engine will search for an INotifyDataErrorInfo implementation on the binding source and subscribe to the INotifyDataErrorInfo.ErrorsChanged event.

    If the ErrorsChanged event of the binding source is raised and INotifyDataErrorInfo.HasErrors evaluates to true, the binding engine will invoke the INotifyDataErrorInfo.GetErrors() method for the actual source property to retrieve the corresponding error message and then apply the customizable validation error template to the target control to visualize the validation error.
    By default a red border is drawn around the element that has failed to validate.

    This validation feedback visualization procedure only executes when Binding.ValidatesOnNotifyDataErrors is set to true on the particular data binding and the Binding.Mode is set to either BindingMode.TwoWay or BindingMode.OneWayToSource.

    How to implement INotifyDataErrorInfo

    The following examples show default validation using ValidationRule (to encapsulate the actual data validation implementation) and Lambdas (or delegates). The last example shows how to implement data validation using validation attributes.

    The code is not tested. The snippets should all work, but may not compile due to typing errors. This code is intended to provide a simple example on how the INotifyDataErrorInfo interface could be implemented.


    ViewModel.cs

    The view model is responsible for validating its properties to ensure the data integrity of the model.
    Since .NET 4.5, the recommended way is to let the view model implement the INotifyDataErrorInfo interface.
    The key is to have separate ValidationRule implementations for each property or rule:

    public class ViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
    {
      // Example property, which validates its value before applying it
      private string userInput;
      public string UserInput
      { 
        get => this.userInput; 
        set 
        { 
          // Validate the value
          if (ValidateProperty(value))
          {
            // Accept the valid value
            this.userInput = value; 
            OnPropertyChanged();
          }
        }
      }
    
      // Constructor
      public ViewModel()
      {
        this.Errors = new Dictionary<string, List<string>>();
        this.ValidationRules = new Dictionary<string, List<ValidationRule>>();
    
        // Create a Dictionary of validation rules for fast lookup. 
        // Each property name of a validated property maps to one or more ValidationRule.
        this.ValidationRules.Add(nameof(this.UserInput), new List<ValidationRule>() {new UserInputValidationRule()});
      }
    
      // Validation method. 
      // Is called from each property which needs to validate its value.
      // Because the parameter 'propertyName' is decorated with the 'CallerMemberName' attribute.
      // this parameter is automatically generated by the compiler. 
      // The caller only needs to pass in the 'propertyValue', if the caller is the target property's set method.
      public bool ValidateProperty<TValue>(TValue propertyValue, [CallerMemberName] string propertyName = null)  
      {  
        // Clear previous errors of the current property to be validated 
        this.Errors.Remove(propertyName); 
        OnErrorsChanged(propertyName); 
    
        if (this.ValidationRules.TryGetValue(propertyName, out List<ValidationRule> propertyValidationRules))
        {
          // Apply all the rules that are associated with the current property and validate the property's value
          propertyValidationRules.ForEach(
            (validationRule) => 
            {
              ValidationResult result = validationRule.Validate(propertyValue, CultuteInfo.CurrentCulture);
              if (!result.IsValid)
              {
                // Store the error message of the validated property
                AddError(propertyName, result.ErrorContent);
              } 
            });  
    
          return PropertyHasErrors(propertyName);
        }
    
        // No rules found for the current property
        return true;
      }   
    
      // Adds the specified error to the errors collection if it is not 
      // already present, inserting it in the first position if 'isWarning' is 
      // false. Raises the ErrorsChanged event if the Errors collection changes. 
      // A property can have multiple errors.
      public void AddError(string propertyName, string errorMessage, bool isWarning = false)
      {
        if (!this.Errors.TryGetValue(propertyName, out List<string> propertyErrors))
        {
          propertyErrors = new List<string>();
          this.Errors[propertyName] = propertyErrors;
        }
    
        if (!propertyErrors.Contains(errorMessage))
        {
          if (isWarning) 
          {
            // Move warnings to the end
            propertyErrors.Add(errorMessage);
          }
          else 
          {
            propertyErrors.Insert(0, errorMessage);
          }
          OnErrorsChanged(propertyName);
        } 
      }
    
      public bool PropertyHasErrors(string propertyName) => this.Errors.TryGetValue(propertyName, out List<string> propertyErrors) && propertyErrors.Any();
    
      #region INotifyDataErrorInfo implementation
    
      public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    
      // Returns all errors of a property. If the argument is 'null' instead of the property's name, 
      // then the method will return all errors of all properties.
      public System.Collections.IEnumerable GetErrors(string propertyName) 
        => string.IsNullOrWhiteSpace(propertyName) 
          ? this.Errors.SelectMany(entry => entry.Value) 
          : this.Errors.TryGetValue(propertyName, out IEnumerable<string> errors) 
            ? errors 
            : new List<string>();
    
      // Returns if the view model has any invalid property
      public bool HasErrors => this.Errors.Any(); 
    
      #endregion
    
      #region INotifyPropertyChanged implementation
    
      public event PropertyChangedEventHandler PropertyChanged;
    
      #endregion
    
      protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
      {
        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
      }
    
      protected virtual void OnErrorsChanged(string propertyName)
      {
        this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
      }
    
      // Maps a property name to a list of errors that belong to this property
      private Dictionary<String, List<String>> Errors { get; }
    
      // Maps a property name to a list of ValidationRules that belong to this property
      private Dictionary<String, List<ValidationRule>> ValidationRules { get; }
    }
    

    UserInputValidationRule.cs

    This example validation rule extends ValidationRule and checks if the input starts with the '@' character. If not, it returns an invalid ValidationResult with an error message:

    public class UserInputValidationRule : ValidationRule
    {        
      public override ValidationResult Validate(object value, CultureInfo cultureInfo)
      {
        if (!(value is string userInput))
        {
          return new ValidationResult(false, "Value must be of type string.");    
        }
    
        if (!userInput.StartsWith("@"))
        {
          return new ValidationResult(false, "Input must start with '@'.");    
        }
    
        return ValidationResult.ValidResult;
      }
    }
    

    MainWindow.xaml

    To enable the visual data validation feedback, the Binding.ValidatesOnNotifyDataErrors property must be set to true on each relevant Binding. The WPF framework will then show the control's default error feedback. Note to make this work the Binding.Mode must be either OneWayToSource or TwoWay (which is the default for the TextBox.Text property):

    <Window>
        <Window.DataContext>
            <ViewModel />       
        </Window.DataContext>
        
        <!-- Important: set ValidatesOnNotifyDataErrors to true to enable visual feedback -->
        <TextBox Text="{Binding UserInput, ValidatesOnNotifyDataErrors=True}" 
                 Validation.ErrorTemplate="{DynamicResource ValidationErrorTemplate}" />  
    </Window>
    

    The following is the validation error template, in case you like to customize the visual representation (optional). It is set on the validated element (in this case the TextBox) via the attached property Validation.ErrorTemplate (see above):

    <ControlTemplate x:Key=ValidationErrorTemplate>
        <StackPanel>
            <!-- Placeholder for the DataGridTextColumn itself -->
            <AdornedElementPlaceholder />
            <ItemsControl ItemsSource="{Binding}">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <TextBlock Text="{Binding ErrorContent}" Foreground="Red"/>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </StackPanel>
    </ControlTemplate>
    </Validation.ErrorTemplate>
    

    Beside the links I've provided, you'll find many examples on the web.

    I recommend moving the implementation of INotifyDataErrorInfo into a base class (e.g. BaseViewModel) together with INotifyPropertyChanged` and let all your view models inherit it. This makes the validation logic reusable and keeps your view model classes clean.

    You can change the implementation details of INotifyDataErrorInfo to meet requirements.

    Remarks

    As an alternative approach the ValidationRule can be replaced with delegates to enable Lambda expressions or Method Groups instead:

    // Example uses System.ValueTuple
    public bool ValidateProperty<TValue>(
      TValue value, 
      Func<TValue, (bool IsValid, IEnumerable<string> ErrorMessages)> validationDelegate, 
      [CallerMemberName] string propertyName = null)  
    {  
      // Clear previous errors of the current property to be validated 
      this.Errors.Remove(propertyName); 
      OnErrorsChanged(propertyName); 
    
      // Validate using the delegate
      (bool IsValid, IEnumerable<string> ErrorMessages) validationResult = validationDelegate?.Invoke(value) ?? (true, string.Empty);
    
      if (!validationResult.IsValid)
      {
        // Store the error messages of the failed validation
        foreach (string errorMessage in validationResult.ErrorMessages)
        {
          // See previous example for implementation of AddError(string,string):void
          AddError(propertyName, errorMessage);
        }
      } 
    
      return validationResult.IsValid;
    }   
    
    
    private string userInput;
    public string UserInput
    { 
      get => this.userInput; 
      set 
      { 
        // Validate the new property value before it is accepted
        if (ValidateProperty(value, 
          newValue => newValue.StartsWith("@") 
            ? (true, new List<string>()) 
            : (false, new List<string> {"Value must start with '@'."})))
        {
          // Accept the valid value
          this.userInput = value; 
          OnPropertyChanged();
        }
      }
    }
    
    // Alternative usage example property which validates its value 
    // before applying it using a Method group.
    // Example uses System.ValueTuple.
    private string userInputAlternativeValidation;
    public string UserInputAlternativeValidation
    { 
      get => this.userInputAlternativeValidation; 
      set 
      { 
        // Use Method group
        if (ValidateProperty(value, AlternativeValidation))
        {
          this.userInputAlternativeValidation = value; 
          OnPropertyChanged();
        }
      }
    }
    
    private (bool IsValid, string ErrorMessage) AlternativeValidation(string value)
    {
      return value.StartsWith("@") 
        ? (true, string.Empty) 
        : (false, "Value must start with '@'.");
    }
    

    Data validation using ValidationAttribute

    This is an example implementation of INotifyDataErrorInfo with ValidationAttribute support e.g., MaxLengthAttribute. This solution combines the previous Lamda version to additionally support validation using a Lambda expression/delegate simultaneously:

    public class ViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
    {    
      private string userInputAttributeValidation;
     
      [Required(ErrorMessage = "Value is required.")]
      public string UserInputAttributeValidation
      { 
        get => this.userInputAttributeValidation; 
        set 
        { 
          // Use only the attribute (can be combined with a Lambda or Method group)
          if (ValidateProperty(value))
          {
            this.userInputAttributeValidation = value; 
            OnPropertyChanged();
          }
        }
      }
    
      // Constructor
      public ViewModel()
      {
        this.Errors = new Dictionary<string, List<string>>();
      }
    
      // Validate properties using decorated attributes and/or a validation delegate. 
      // The validation delegate is optional.
      public bool ValidateProperty<TValue>(
        TValue value, 
        Func<TValue, (bool IsValid, IEnumerable<string> ErrorMessages)> validationDelegate = null, 
        [CallerMemberName] string propertyName = null)  
      {  
        // Clear previous errors of the current property to be validated 
        this.Errors.Remove(propertyName); 
        OnErrorsChanged(propertyName); 
    
        bool isValueValid = ValidatePropertyUsingAttributes(value, propertyName);
        if (validationDelegate != null)
        {
          isValueValid |= ValidatePropertyUsingDelegate(value, validationDelegate, propertyName);
        }
    
        return isValueValid;
      }     
    
      // Validate properties using decorated attributes. 
      public bool ValidatePropertyUsingAttributes<TValue>(TValue value, string propertyName)  
      {  
        // The result flag
        bool isValueValid = true;
    
        // Check if property is decorated with validation attributes
        // using reflection
        IEnumerable<Attribute> validationAttributes = GetType()
          .GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
          ?.GetCustomAttributes(typeof(ValidationAttribute)) ?? new List<Attribute>();
    
        // Validate using attributes if present
        if (validationAttributes.Any())
        {
          var validationContext = new ValidationContext(this, null, null) { MemberName = propertyName };
          var validationResults = new List<ValidationResult>();
          if (!Validator.TryValidateProperty(value, validationContext, validationResults))
          {           
            isValueValid = false;
    
            foreach (ValidationResult attributeValidationResult in validationResults)
            {
              AddError(propertyName, attributeValidationResult.ErrorMessage);
            }
          }
        }
    
        return isValueValid;
      }       
    
      // Validate properties using the delegate. 
      public bool ValidatePropertyUsingDelegate<TValue>(
        TValue value, 
        Func<TValue, (bool IsValid, IEnumerable<string> ErrorMessages)> validationDelegate, 
        string propertyName) 
      {  
        // The result flag
        bool isValueValid = true;
    
        // Validate using the delegate
        (bool IsValid, IEnumerable<string> ErrorMessages) validationResult = validationDelegate.Invoke(value);
    
        if (!validationResult.IsValid)
        {
          isValueValid = false;
    
          // Store the error messages of the failed validation
          foreach (string errorMessage in validationResult.ErrorMessages)
          {
            AddError(propertyName, errorMessage);
          }
        } 
    
        return isValueValid;
      }       
    
      // Adds the specified error to the errors collection if it is not 
      // already present, inserting it in the first position if 'isWarning' is 
      // false. Raises the ErrorsChanged event if the Errors collection changes. 
      // A property can have multiple errors.
      public void AddError(string propertyName, string errorMessage, bool isWarning = false)
      {
        if (!this.Errors.TryGetValue(propertyName, out List<string> propertyErrors))
        {
          propertyErrors = new List<string>();
          this.Errors[propertyName] = propertyErrors;
        }
    
        if (!propertyErrors.Contains(errorMessage))
        {
          if (isWarning) 
          {
            // Move warnings to the end
            propertyErrors.Add(errorMessage);
          }
          else 
          {
            propertyErrors.Insert(0, errorMessage);
          }
          OnErrorsChanged(propertyName);
        } 
      }
    
      public bool PropertyHasErrors(string propertyName) => this.Errors.TryGetValue(propertyName, out List<string> propertyErrors) && propertyErrors.Any();
    
      #region INotifyDataErrorInfo implementation
    
      public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    
      // Returns all errors of a property. If the argument is 'null' instead of the property's name, 
      // then the method will return all errors of all properties.
      public System.Collections.IEnumerable GetErrors(string propertyName) 
        => string.IsNullOrWhiteSpace(propertyName) 
          ? this.Errors.SelectMany(entry => entry.Value) 
          : this.Errors.TryGetValue(propertyName, out IEnumerable<string> errors) 
            ? errors 
            : new List<string>();
    
      // Returns if the view model has any invalid property
      public bool HasErrors => this.Errors.Any(); 
    
      #endregion
    
      #region INotifyPropertyChanged implementation
    
      public event PropertyChangedEventHandler PropertyChanged;
    
      #endregion
    
      protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
      {
        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
      }
    
      protected virtual void OnErrorsChanged(string propertyName)
      {
        this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
      }
    
      // Maps a property name to a list of errors that belong to this property
      private Dictionary<String, List<String>> Errors { get; }    
    }
    
    0 讨论(0)
提交回复
热议问题