如何添加验证以查看模型属性或如何实现 INotifyDataErrorInfo

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

我有一个类型为 ObservableCollection 的数据集合(比如实例为 myClassTypes)。在一些用户操作之后,这个 myClassTypes 填充了 ViewModel 中的值。在视图中,有一个 TextBox,用户可以在其中输入文本。我需要根据 myClassTypes 值验证文本框数据。因此,如果 myClassTypes 包含用户在文本框中插入的文本,则验证通过,否则将失败。 我的代码片段是: 视图模型:

public ObservableCollection < MyClassType > ViewModelClassTypes {
    get {

        return _myClassTypes;
    }
    set {
        _myClassTypes = value;
        NotifyOfPropertyChange(() = >MyClassTypes);
    }
}

public class TestValidationRule: ValidationRule {
    public ObservableCollection < MyClassType > MyClassTypes {
        get = >(ObservableCollection < MyClassType > ) GetValue(MyClassTypesProperty);
        set = >SetValue(MyClassTypesProperty, value);
    }
}

仅供参考:MyClassTypesProperty 是一个依赖项 属性

我的 View.xaml 是:

<TextBox>
    <TextBox.Text>
        <Binding UpdateSourceTrigger="PropertyChanged">
            <Binding.ValidationRules>
                <validationRules:TestValidationRule MyClassTypes="{Binding ViewModelClassTypes}"/>
            </Binding.ValidationRules>
        </Binding>
    </TextBox.Text>
</TextBox>

我无法在 MyClassTypes 中获取 ViewModelClassTypes 填充值。谁能告诉我我做错了什么?

自 .Net 4.5 以来实现数据验证的首选方法是让您的视图模型实现 INotifyDataErrorInfo (example from Technet, example from MSDN (Silverlight)).

注意:INotifyDataErrorInfo 替换了过时的 IDataErrorInfo。 与 INotifyDataErrorInfo 接口相关的新框架基础设施提供了许多优势,例如

  • 支持每个 属性
  • 多个错误
  • 自定义错误对象和视觉错误反馈的自定义(例如,使视觉提示适应自定义错误对象)
  • 异步验证使用async/await

INotifyDataErrorInfo 的工作原理

BindingValidatesOnNotifyDataErrors 属性 设置为 true 时,绑定引擎将在绑定源上搜索 INotifyDataErrorInfo 实现并订阅 INotifyDataErrorInfo.ErrorsChanged 活动。

如果引发绑定源的 ErrorsChanged 事件并且 INotifyDataErrorInfo.HasErrors 求值为 true,绑定引擎将为实际源调用 INotifyDataErrorInfo.GetErrors(propertyName) 方法 属性 检索相应的错误消息,然后将可自定义的验证错误模板应用到目标控件以可视化验证错误。
默认情况下,在验证失败的元素周围绘制红色边框。

如果出现错误,即 INotifyDataErrorInfo.HasErrors returns true,绑定引擎还将在绑定目标上设置附加的 Validation 属性,例如 Validation.HasErrorValidation.ErrorTemplate.
要自定义视觉错误反馈,我们可以覆盖绑定引擎提供的默认模板,方法是覆盖附加的 Validation.ErrorTemplate 属性 的值(参见下面的示例)。

仅当特定数据绑定的 Binding.ValidatesOnNotifyDataErrors 设置为 trueBinding.Mode 设置为 BindingMode.TwoWay 或 [=40 时,才会执行所描述的验证过程=].

如何实现INotifyDataErrorInfo

以下示例显示了使用

进行 属性 验证的三种变体
  1. a ValidationRule(class封装实际的数据验证实现)
  2. lambda 表达式(或委托)
  3. 验证属性(用于装饰已验证的属性)。

当然,您可以组合所有三种变体以提供最大的灵活性。

代码未经测试。这些片段应该都可以工作,但可能由于输入错误而无法编译。此代码旨在提供有关如何实现 INotifyDataErrorInfo 接口的简单示例。


正在准备视图

MainWindow.xaml

要启用视觉数据验证反馈,Binding.ValidatesOnNotifyDataErrors 属性 必须在每个相关 Binding 上设置为 true,即 [=22] 的来源=] 是经过验证的 属性。然后 WPF 框架将显示控件的默认错误反馈。

注意: 要使此工作正常,Binding.Mode 必须是 OneWayToSourceTwoWay(这是 TextBox.Text 属性 的默认值):

<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>

以下是自定义验证错误模板的示例。
默认的视觉错误反馈是验证元素周围的简单红色边框。如果您想自定义视觉反馈,例如,允许向用户显示错误消息,您可以定义自定义 ControlTemplate 并将其分配给经过验证的元素(在本例中为 TextBox)附上 属性 Validation.ErrorTemplate(见上)。
以下 ControlTemplate 可以显示与已验证 属性:
关联的错误消息列表

<ControlTemplate x:Key="ValidationErrorTemplate">
  <StackPanel>
    <Border BorderBrush="Red" 
            BorderThickness="1">
      
      <!-- Placeholder for the DataGridTextColumn itself -->
      <AdornedElementPlaceholder x:Name="AdornedElement"  />
    </Border>

    <Border Background="White" 
            BorderBrush="Red" 
            Padding="4"
            BorderThickness="1,0,1,1" 
            HorizontalAlignment="Left">
      <ItemsControl ItemsSource="{Binding}" HorizontalAlignment="Left">
        <ItemsControl.ItemTemplate>
          <DataTemplate>
            <TextBlock Text="{Binding ErrorContent}" 
                       Foreground="Red"/>
          </DataTemplate>
        </ItemsControl.ItemTemplate>
      </ItemsControl>
    </Border>
  </StackPanel>
</ControlTemplate>

视图模型负责验证其自身的属性以确保模型的数据完整性。
我建议将 INotifyDataErrorInfo 的实现与 INotifyPropertyChanged 实现一起移动到基础 class(例如抽象 ViewModel class)中,并让所有视图模型继承它。这使得验证逻辑可重用并保持您的视图模型 classes 干净。

您可以更改 INotifyDataErrorInfo 示例的实现细节以满足要求。

1 使用 ValidationRule

进行数据验证

ViewModel.cs
使用 ValidationRule 时,关键是要为每个 属性 或规则提供单独的 ValidationRule 实现。

扩展 ValidationRule 是可选的。我选择扩展 ValidationRule 因为它已经提供了完整的验证 API 并且因为如果需要可以通过绑定验证重用这些实现。
基本上,属性 验证的结果应该是 bool 以指示验证失败或成功,并且可以向用户显示一条消息以帮助他修复输入。

如果出现验证错误,我们所要做的就是生成一条错误消息,将其添加到私有字符串集合中,以允许我们的 INotifyDataErrorInfo.GetErrors(propertyName) 实现 return 来自的正确错误消息此集合并引发 INotifyDataErrorInfo.ErrorChanged 事件以通知 WPF 绑定引擎有关错误:

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
      bool isValueValid = IsPropertyValid(value);

      // Optionally reject value if validation has failed
      if (isValueValid)
      {
        this.userInput = value; 
        OnPropertyChanged();
      }
    }
  }

  // Constructor
  public ViewModel()
  {
    this.Errors = new Dictionary<string, IList<object>>();
    this.ValidationRules = new Dictionary<string, IList<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 IsPropertyValid<TValue>(TValue propertyValue, [CallerMemberName] string propertyName = null)  
  {  
    // Clear previous errors of the current property to be validated 
    _ = ClearErrors(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            
      IEnumerable<string> errorMessages = propertyValidationRules
        .Select(validationRule => validationRule.Validate(propertyValue, CultureInfo.CurrentCulture))
        .Where(result => !result.IsValid)
        .Select(invalidResult => invalidResult.ErrorContent);
      AddErrorRange(propertyName, errorMessages);

      return !errorMessages.Any();
    }

    // No rules found for the current property
    return true;
  }   

  // Adds the specified errors 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.
  private void AddErrorRange(string propertyName, IEnumerable<object> newErrors, bool isWarning = false)
  {
    if (!newErrors.Any())
    {
      return;
    }

    if (!this.Errors.TryGetValue(propertyName, out IList<object> propertyErrors))
    {
      propertyErrors = new List<object>();
      this.Errors.Add(propertyName, propertyErrors);
    }

    if (isWarning)
    {
      foreach (object error in newErrors)
      {
        propertyErrors.Add(error);
      }
    }
    else
    {
      foreach (object error in newErrors)
      {
        propertyErrors.Insert(0, error);
      }
    }

    OnErrorsChanged(propertyName);
  }

  // Removes all errors of the specified property. 
  // Raises the ErrorsChanged event if the Errors collection changes. 
  public bool ClearErrors(string propertyName)
  {
    this.ValidatedAttributedProperties.Remove(propertyName);
    if (this.Errors.Remove(propertyName))
    { 
      OnErrorsChanged(propertyName); 
      return true;
    }
    return false;
  }

  // Optional method to check if a certain property has validation errors
  public bool PropertyHasErrors(string propertyName) => this.Errors.TryGetValue(propertyName, out IList<object> propertyErrors) && propertyErrors.Any();

  #region INotifyDataErrorInfo implementation

  // The WPF binding engine will listen to this event
  public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

  // This implementation of GetErrors returns all errors of the specified property. 
  // If the argument is 'null' instead of the property's name, 
  // then the method will return all errors of all properties.
  // This method is called by the WPF binding engine when ErrorsChanged event was raised and HasErrors retirn true
  public System.Collections.IEnumerable GetErrors(string propertyName) 
    => string.IsNullOrWhiteSpace(propertyName) 
      ? this.Errors.SelectMany(entry => entry.Value) 
      : this.Errors.TryGetValue(propertyName, out IList<object> errors) 
        ? (IEnumerable<object>)errors 
        : new List<object>();

  // Returns 'true' 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, IList<object>> Errors { get; }

  // Maps a property name to a list of ValidationRules that belong to this property
  private Dictionary<string, IList<ValidationRule>> ValidationRules { get; }
}

UserInputValidationRule.cs

此示例验证规则扩展 ValidationRule 并检查输入是否以“@”字符开头。如果不是,它 return 是一个无效的ValidationResult 带有可以显示给用户以帮助他修正输入的错误消息。

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;
  }
}

2 使用 lambda 表达式和委托进行数据验证

作为替代方法,可以将 ValidationRule 替换(或组合)为委托以启用 Lambda 表达式或方法组的使用。
此示例中的验证表达式 return 一个包含布尔值的元组,用于指示验证状态和 string 实际消息的错误对象集合。由于所有与错误对象相关的属性都是 object 类型,因此表达式可以 return 任何自定义数据类型,以防您需要更高级的错误反馈并且 string 不是足够的错误对象。在这种情况下,我们还必须调整验证错误模板,使其能够处理数据类型。

ViewModel.cs

public class ViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{  
  private string userInput;
  public string UserInput
  { 
    get => this.userInput; 
    set 
    { 
      // Validate the new property value.
      bool isValueValid = IsPropertyValid(value, 
        newValue => newValue.StartsWith("@") 
          ? (true, Enumerable.Empty<object>()) 
          : (false, new[] { "Value must start with '@'." }));

      // Optionally reject value if validation has failed 
      if (isValueValid)
      {
        // 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 (IsPropertyValid(value, AlternativeValidation))
      {
        this.userInputAlternativeValidation = value; 
        OnPropertyChanged();
      }
    }
  }

  // Constructor
  public ViewModel()
  {
    this.Errors = new Dictionary<string, IList<object>>();
  }

  // The validation handler
  private (bool IsValid, IEnumerable<object> ErrorMessages) AlternativeValidation(string value)
  {
    return value.StartsWith("@") 
      ? (true, Enumerable.Empty<object>()) 
      : (false, new[] { "Value must start with '@'." });
  }

  // Example uses System.ValueTuple
  public bool IsPropertyValid<TValue>(
    TValue value, 
    Func<TValue, (bool IsValid, IEnumerable<object> ErrorMessages)> validationDelegate, 
    [CallerMemberName] string propertyName = null)  
  {  
    // Clear previous errors of the current property to be validated 
    _ = ClearErrors(propertyName); 

    // Validate using the delegate
    (bool IsValid, IEnumerable<object> ErrorMessages) validationResult = validationDelegate?.Invoke(value) ?? (true, Enumerable.Empty<object>());

    if (!validationResult.IsValid)
    {
      AddErrorRange(propertyName, validationResult.ErrorMessages);
    } 

    return validationResult.IsValid;
  }  

  // Adds the specified errors 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.
  private void AddErrorRange(string propertyName, IEnumerable<object> newErrors, bool isWarning = false)
  {
    if (!newErrors.Any())
    {
      return;
    }

    if (!this.Errors.TryGetValue(propertyName, out IList<object> propertyErrors))
    {
      propertyErrors = new List<object>();
      this.Errors.Add(propertyName, propertyErrors);
    }

    if (isWarning)
    {
      foreach (object error in newErrors)
      {
        propertyErrors.Add(error);
      }
    }
    else
    {
      foreach (object error in newErrors)
      {
        propertyErrors.Insert(0, error);
      }
    }

    OnErrorsChanged(propertyName);
  }

  // Removes all errors of the specified property. 
  // Raises the ErrorsChanged event if the Errors collection changes. 
  public bool ClearErrors(string propertyName)
  {
    this.ValidatedAttributedProperties.Remove(propertyName);
    if (this.Errors.Remove(propertyName))
    { 
      OnErrorsChanged(propertyName); 
      return true;
    }
    return false;
  }

  // Optional method to check if a certain property has validation errors
  public bool PropertyHasErrors(string propertyName) => this.Errors.TryGetValue(propertyName, out IList<object> propertyErrors) && propertyErrors.Any();

  #region INotifyDataErrorInfo implementation

  // The WPF binding engine will listen to this event
  public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

  // This implementation of GetErrors returns all errors of the specified property. 
  // If the argument is 'null' instead of the property's name, 
  // then the method will return all errors of all properties.
  // This method is called by the WPF binding engine when ErrorsChanged event was raised and HasErrors retirn true
  public System.Collections.IEnumerable GetErrors(string propertyName) 
    => string.IsNullOrWhiteSpace(propertyName) 
      ? this.Errors.SelectMany(entry => entry.Value) 
      : this.Errors.TryGetValue(propertyName, out IList<object> errors) 
        ? (IEnumerable<object>)errors 
        : new List<object>();

  // Returns 'true' 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, IList<object>> Errors { get; }
}

3 使用 ValidationAttribute

进行数据验证

这是 INotifyDataErrorInfoValidationAttribute support e.g., MaxLengthAttribute 的示例实现。该解决方案结合了以前的 Lambda 版本,以额外支持同时使用 Lambda expression/delegate 进行验证。 虽然必须通过调用属性 setter 中的 TryValidateProperty 方法来显式调用使用 lambda 表达式或委托的验证,但属性验证是从 OnPropertyChanged 事件调用器隐式执行的(一旦属性 用验证属性装饰):

ViewModel.cs

public class ViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{    
  private string userInputAttributeValidation;
 
  // Validate property using validation attributes
  [MaxLength(Length = 5, ErrorMessage = "Only five characters allowed.")]
  public string UserInputAttributeValidation
  { 
    get => this.userInputAttributeValidation; 
    set 
    {           
      // Optional call to 'IsPropertyValid' to combine attribute validation
      // with a delegate
      bool isValueValid = IsPropertyValid(value, newValue => newValue.StartsWith("@") 
        ? (true, Enumerable.Empty<object>()) 
        : (false, new[] { "Value must start with '@'." }));

      // Optionally reject value if validation h as failed
      if (isValueValid)
      {
        this.userInputAttributeValidation = value; 
      }

      // Triggers checking for validation attributes and their validation, 
      // if any validation attributes were found (like 'MaxLength' in this example)
      OnPropertyChanged();
    }
  }

  // Constructor
  public ViewModel()
  {
    this.Errors = new Dictionary<string, IList<object>>();
    this.ValidatedAttributedProperties = new HashSet<string>();
  }      

  // Validate property using decorating attributes. 
  // Is invoked by 'OnPropertyChanged' (see below).
  private bool IsAttributedPropertyValid<TValue>(TValue value, string propertyName)  
  {  
    this.ValidatedAttributedProperties.Add(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;
        AddErrorRange(validationResults.Select(attributeValidationResult => attributeValidationResult.ErrorMessage));
      }
    }

    return isValueValid;
  }       

  // Example uses System.ValueTuple
  public bool IsPropertyValid<TValue>(
    TValue value, 
    Func<TValue, (bool IsValid, IEnumerable<object> ErrorMessages)> validationDelegate = null, 
    [CallerMemberName] string propertyName = null)  
  {  
    // Clear previous errors of the current property to be validated 
    ClearErrors(propertyName); 

    // Validate using the delegate
    (bool IsValid, IEnumerable<object> ErrorMessages) validationResult = validationDelegate?.Invoke(value) ?? (true, Enumerable.Empty<object>());

    if (!validationResult.IsValid)
    {
      // Store the error messages of the failed validation
      AddErrorRange(validationResult.ErrorMessages);
    } 

    bool isAttributedPropertyValid = IsAttributedPropertyValid(value, propertyName);

    return isAttributedPropertyValid && validationResult.IsValid;
  }      

  // Adds the specified errors 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.
  private void AddErrorRange(string propertyName, IEnumerable<object> newErrors, bool isWarning = false)
  {
    if (!newErrors.Any())
    {
      return;
    }

    if (!this.Errors.TryGetValue(propertyName, out IList<object> propertyErrors))
    {
      propertyErrors = new List<object>();
      this.Errors.Add(propertyName, propertyErrors);
    }

    if (isWarning)
    {
      foreach (object error in newErrors)
      {
        propertyErrors.Add(error);
      }
    }
    else
    {
      foreach (object error in newErrors)
      {
        propertyErrors.Insert(0, error);
      }
    }

    OnErrorsChanged(propertyName);
  }

  // Removes all errors of the specified property. 
  // Raises the ErrorsChanged event if the Errors collection changes. 
  public bool ClearErrors(string propertyName)
  {
    this.ValidatedAttributedProperties.Remove(propertyName);
    if (this.Errors.Remove(propertyName))
    { 
      OnErrorsChanged(propertyName); 
      return true;
    }
    return false;
  }

  public bool PropertyHasErrors(string propertyName) => this.Errors.TryGetValue(propertyName, out IList<object> 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 IList<object> errors) 
        ? (IEnuemrable<object>)errors 
        : new List<object>();

  // 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)
  {
    // Check if IsAttributedPropertyValid Property was already called by 'IsValueValid'.
    if (!this.ValidatedAttributedProperties.Contains(propertyName))
    {
      _ = IsAttributedPropertyValid(value, propertyName);
    }

    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, IList<object>> Errors { get; }    


  // Track attribute validation calls
  private HashSet<string> ValidatedAttributedProperties { get; } 
}