9.实施响应式数据验证

数据验证与数据输入表单密切相关,对于推广干净、可用的数据至关重要。 虽然 WPF 中的 UI 控件可以自动确认输入的值与其数据绑定属性的类型匹配这一事实,但它们无法验证输入数据的正确性。

例如,数据绑定到整数的 TextBox 控件可能会在用户输入非数字值时突出显示错误,但它不会验证输入的数字具有正确的位数,或者第一个 四位数适用于指定的信用卡类型。

为了在使用 MVVM 时验证这些类型的数据的正确性,我们需要实现 .NET 验证接口之一。 在本章中,我们将详细研究可用的接口,查看一些实现并探索 WPF 为我们提供的其他与验证相关的功能。 让我们从验证系统开始。

在 WPF 中,验证系统在很大程度上围绕着静态验证类。这个类有几个支持数据验证的附加属性、方法和一个附加事件。 每个绑定实例都有一个 ValidationRules 集合,该集合可以包含 ValidationRule 元素。

WPF 提供了三个内置规则:

  • ExceptionValidationRule 对象检查绑定源属性更新时引发的任何异常。
  • DataErrorValidationRule 类检查实现 IDataErrorInfo 接口的类可能引发的错误。
  • NotifyDataErrorValidationRule 类检查实现 INotifyDataErrorInfo 接口的类引发的错误。

每次尝试更新数据源属性时,绑定引擎首先清除 Validation.Errors 集合,然后检查绑定的 ValidationRules 集合以查看它是否包含任何 ValidationRule 元素。 如果是这样,它会依次调用每个规则的 Validate 方法,直到它们都通过,或者返回错误。

当数据绑定值不符合 ValidationRule 元素的 Validation 方法中的条件时,绑定引擎会将新的 ValidationError 对象添加到数据绑定目标控件的 Validation.Errors 集合中。

反过来,这会将元素的 Validation.HasError Attached 属性设置为 true,如果绑定的 NotifyOnValidationError 属性设置为 true,则绑定引擎还将在数据绑定目标上引发 Validation.Error Attached 事件。

使用验证规则——做还是不做?

在 WPF 中,有两种不同的方法来处理数据验证。 一方面,我们有基于 UI 的 ValidationRule 类、Validation.Error Attached Event、Binding.NotifyOnValidationError 和 UpdateSourceExceptionFilter 属性,另一方面,我们有两个基于代码的验证接口。

虽然 ValidationRule 类及其相关的验证方法运行良好,但它们是在 XAML 中指定的,因此与 UI 相关联。 此外,当使用 ValidationRule 类时,我们有效地将验证逻辑与它们正在验证的数据模型分离,并将其存储在完全不同的程序集中。

在使用 MVVM 方法开发 WPF 应用程序时,我们使用数据而不是 UI 元素,因此我们倾向于回避直接使用 ValidationRule 类及其相关的验证策略。

此外,Binding 类的 NotifyOnValidationError 和 UpdateSourceExceptionFilter 属性也分别需要事件或委托处理程序,并且正如我们所发现的,我们更愿意在使用 MVVM 时避免这样做。 因此,我们不会在本书中关注这种基于 UI 的验证方法,而是关注两个基于代码的验证接口。

掌握验证界面

在 WPF 中,我们可以访问两个主要的验证接口; 原来的接口是 IDataErrorInfo 接口,在 .NET 4.5 中,添加了 INotifyDataErrorInfo 接口。 在本节中,我们将首先研究原始验证接口及其缺点,并了解如何使其更有用,然后再研究后者。

实现 IDataErrorInfo 接口

IDataErrorInfo 接口是一个非常简单的事情,只需实现两个必需的属性。 Error 属性返回描述验证错误的错误消息,Item[string] 索引器返回指定属性的错误消息。

当然看起来很简单,所以让我们看一下这个接口的基本实现。 让我们创建另一个基类来实现它,现在,省略所有其他不相关的基类成员,以便我们可以专注于这个接口:

using System.ComponentModel;
using System.Runtime.CompilerServices;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels
{
   
    public abstract class BaseValidationModel : INotifyPropertyChanged,
    IDataErrorInfo
    {
   
        protected string error = string.Empty;
        #region IDataErrorInfo Members
            public string Error => error;
        public virtual string this[string propertyName] => error;
        #endregion
            #region INotifyPropertyChanged Members
            ...
            #endregion
    }
}

在这个最简单的实现中,我们声明了一个受保护的错误字段,派生类可以访问该字段。 请注意,返回它的 Error 属性使用 C# 6.0 表达式主体属性语法。 此语法是方法、属性、索引器、构造函数和析构函数的简写符号,其中成员主体由内联表达式替换。

我们已将类索引器(this 属性)声明为虚拟,以便我们可以在派生类中覆盖它。 另一种选择是将其声明为抽象,以便派生类被迫覆盖它。 您更喜欢使用虚拟还是抽象将取决于您的特定情况,例如您是否希望每个派生类都需要验证。

让我们看一个派生自我们的新基类的类的示例:

using System;
namespace CompanyName.ApplicationName.DataModels
{
   
    public class Product : BaseValidationModel
    {
   
        private Guid id = Guid.Empty;
        private string name = string.Empty;
        private decimal price = 0;
        public Guid Id
        {
   
            get {
    return id; }
            set {
    if (id != value) {
    id = value; NotifyPropertyChanged(); } }
        }
        public string Name
        {
   
            get {
    return name; }
            set {
    if (name != value) {
    name = value; NotifyPropertyChanged(); } }
        }
        public decimal Price
        {
   
            get {
    return price; }
            set {
    if (price != value) {
    price = value;
                                       NotifyPropertyChanged(); } }
        }
        public override string this[string propertyName]
        {
   
            get
            {
   
                error = string.Empty;
                if (propertyName == nameof(Name))
                {
   
                    if (string.IsNullOrEmpty(Name))
                        error = "Please enter the product name.";
                    else if (Name.Length > 25) error = "The product name cannot be
                        longer than twenty-five characters.";
                        }
                else if (propertyName == nameof(Price) && Price == 0)
                    error = "Please enter a valid price for the product.";
                return error;
            }
        }
    }
}

在这里,我们有一个基本的 Product 类,它扩展了我们的新基类。 每个想要参与验证过程的派生类需要做的唯一工作是覆盖类索引器并提供有关其相关验证逻辑的详细信息。

在索引器中,我们首先将错误字段设置为空字符串。 请注意,这是此实现的重要组成部分,因为没有它,任何触发的验证错误都将永远不会被清除。 有很多方法可以实现这个方法,有几种不同的 抽象是可能的。 但是,所有实现都需要在调用此属性时运行验证逻辑。

在我们的特定示例中,我们只是使用 if 语句来检查每个属性中的错误,尽管 switch 语句在这里也可以正常工作。 第一个条件检查 propertyName 输入参数的值,而每个属性的多个验证规则可以使用内部 if 语句处理。

如果 propertyName 输入参数等于 Name,那么我们首先检查以确保它具有某个值并在失败的情况下提供错误消息。 如果属性值不为 null 或为空,则第二个验证条件检查长度是否不超过 25 个字符,这模拟了我们可能拥有的特定数据库约束。

如果 propertyName 输入参数等于 Price,那么我们只需检查是否输入了有效的正值,并在失败时提供另一条错误消息。 如果我们在这个类中有更多的属性,那么我们只需添加更多的 if 条件,检查它们的属性名称,以及进一步的相关验证检查。

现在我们有了可验证的类,让我们在连接两者的 App.xaml 文件中添加一个新的 View 和 View Model 以及 DataTemplate,以演示我们还需要做些什么来让我们的验证逻辑连接到 UI 中的数据 . 我们先来看一下 ProductViewModel 类:

using CompanyName.ApplicationName.DataModels;
namespace CompanyName.ApplicationName.ViewModels
{
   
    public class ProductViewModel : BaseViewModel
    {
   
        private Product product = new Product();
        public Product Product
        {
   
            get {
    return product; }
            set {
    if (product != value) {
    product = value;
                                         NotifyPropertyChanged(); } }
        }
    }
}

ProductViewModel 类只定义了一个 Product 对象并通过 Product 属性公开它。 现在让我们将一些基本样式添加到应用程序资源文件中,我们将在相关视图中使用它们:

<Style x:Key="LabelStyle" TargetType="{x:Type TextBlock}">
    <Setter Property="HorizontalAlignment" Value="Right" />
    <Setter Property="VerticalAlignment" Value="Center" />
    <Setter Property="Margin" Value="0,0,10,10" />
</Style>
<Style x:Key="FieldStyle" TargetType="{x:Type TextBox}">
    <Setter Property="SnapsToDevicePixels" Value="True" />
    <Setter Property="VerticalAlignment" Value="Center" />
    <Setter Property="Margin" Value="0,0,0,10" />
    <Setter Property="Padding" Value="1.5,2" />
</Style>

现在,让我们看看视图:

<UserControl x:Class="CompanyName.ApplicationName.Views.ProductView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             Width="320" FontSize="14">
    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <TextBlock Text="Name" Style="{StaticResource LabelStyle}" />
        <TextBox Grid.Column="1" Text="{Binding Product.Name,
                                       UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
                 Style="{StaticResource FieldStyle}" />
        <TextBlock Grid.Row="1" Text="Price"
                   Style="{StaticResource LabelStyle}" />
        <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Product.Price,
                                                    UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
                 Style="{StaticResource FieldStyle}" />
    </Grid>
</UserControl>

在 XAML 中,我们有一个典型的两列网格面板,有两行。 两个 TextBlock 标签应用了 LabelStyle 样式,两个 TextBox 输入控件应用了 FieldStyle 样式。 应用于每个 TextBox.Text 属性的绑定设置了两个重要的属性。

第一个是 UpdateSourceTrigger 属性,它控制何时更新数据源,因此也控制何时进行验证。 如果您还记得的话,PropertyChanged 的值会在数据绑定的属性值更改时立即进行更新。 另一个值是 LostFocus,它会在 UI 控件失去焦点时进行更新,例如,在切换到下一个控件时。

这里的另一个重要属性是 ValidatesOnDataErrors 属性,没有它我们当前的示例将无法工作。 在绑定上将此属性设置为 True 会导致内置 DataErrorValidationRule 元素被隐式添加到 Binding.ValidationRules 集合中。

随着数据绑定值的更改,此元素将检查 IDataErrorInfo 接口引发的错误。 它通过在我们的数据模型中调用索引器来执行此操作,每次更新数据源时使用数据绑定属性的名称。 因此,在这个基本示例中,开发人员将负责在每个绑定上将此属性设置为 True 以使验证工作。

在 .NET 4.5 中,当 UpdateSourceTrigger 绑定设置为 PropertyChanged 时,Microsoft 对在 TextBox 控件中输入数字数据的方式进行了重大更改。 他们的更改阻止用户输入数字分隔符。 请参阅本章后面的“与旧行为保持同步”部分,以了解解决此问题的原因和方法。

当对 UpdateSourceTrigger 属性使用 PropertyChanged 值时,以及每次属性更改时我们都会验证这一事实,我们可以立即更新错误。 但是,这种验证方法以先发制人的方式工作,所有验证错误都会在用户有机会输入任何数据之前显示出来。 这可能会让用户有些反感,所以让我们在第一次启动时快速看一下我们的示例:

在这里插入图片描述

如您所见,很明显存在一些问题,但尚不清楚它们是什么。到目前为止,我们的错误消息没有输出。 我们可以使用的一种常见输出是各种表单控件的工具提示。

我们可以为我们的 FieldStyle 样式添加一个触发器,它监听 Validation.HasError Attached Property 并将 TextBox 控件的工具提示设置为错误的 ErrorContent 属性(只要存在)。 这就是微软传统上在他们的网站上展示如何做到这一点的方式:

<Style.Triggers>
    <Trigger Property="Validation.HasError" Value="True">
        <Setter Property="ToolTip" Value="{Binding (Validation.Errors)[0].
                                          ErrorContent, RelativeSource={RelativeSource Self}}" />
    </Trigger>
</Style.Triggers>

请注意,我们在 Validation.Errors 集合的绑定路径中使用方括号,因为它是一个附加属性,并且我们使用 RelativeSource.Self 实例,因为我们想要定位 TextBox 控件本身的 Errors 集合。 另请注意,此示例仅显示 Errors 集合中的第一个 ValidationError 对象。在我们的数据绑定 TextBox 控件上使用此样式有助于在用户将鼠标光标置于相关控件上时为用户提供更多信息:

在这里插入图片描述

但是,当没有要显示的验证错误时,将在 Visual Studio 的输出窗口中看到一个错误,因为我们试图从 Validation.Errors 附加属性集合中查看第一个错误,但不存在:

System.Windows.Data Error: 17 : Cannot get 'Item[]' value (type
'ValidationError') from '(Validation.Errors)' (type
'ReadOnlyObservableCollection`1'). BindingExpression:
Path=(Validation.Errors)[0].ErrorContent; DataItem='TextBox' (Name='');
target element is 'TextBox' (Name=''); target property is 'ToolTip' (type
'Object') ArgumentOutOfRangeException: 'System.ArgumentOutOfRangeException:
Specified argument was out of the range of valid values.
Parameter name: index'

有很多方法可以避免这个错误,比如简单地显示整个集合,我们将在本章后面看到一个例子。 但是,最简单的方法是利用 ICollectionView 对象的 CurrentItem 属性,该属性隐式用于包装 IEnumerable 数据集合,这些数据集合是绑定到 ItemsControl 元素的数据。

这类似于 ListBox 将我们的数据绑定数据项隐式包装在 ListBoxItem 元素中的方式。 包装我们的数据集合的 ICollectionView 接口的实现主要用于启用数据的排序、过滤和分组,而不影响实际数据,但它的 CurrentItem 属性在这种情况下是一个奖励。

有了这个,我们可以在没有验证错误的情况下替换导致我们出现问题的索引器。 现在,当没有错误时,CurrentItem 属性将返回 null,而不是抛出异常,因此,尽管 Microsoft 自己的示例显示了索引器的使用,但这是一个更好的解决方案:

<Setter Property="ToolTip" Value="{Binding (Validation.Errors).
                                  CurrentItem.ErrorContent, RelativeSource={RelativeSource Self}}" />

然而,如果最终用户不知道必须将鼠标光标放在控件上才能看到工具提示,那么情况仍然没有得到改善。 因此,这个初步实现仍有改进的余地。 这个接口的另一个缺点是它被设计成原子的,所以它一次只处理每个属性的一个错误。

在我们的 Product 类示例中,我们想要验证 Name 属性不仅被输入,而且还具有有效长度这一事实。 按照我们声明该属性的两个验证条件的顺序,当 UI 中的字段为空时会引发第一个错误,如果输入的值太长则会引发第二个错误。 由于输入的值不能同时既不存在又太长,因此在此特定示例中一次只报告一个错误不是问题。

但是,如果我们有一个具有多个验证条件的属性,例如最大长度和特定格式,那么使用通常的 IDataErrorInfo 接口实现,我们一次只能查看其中一个错误。 然而,尽管有这个限制,我们仍然可以改进这个基本的实现。 让我们看看如何使用新的基类来做到这一点:

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using CompanyName.ApplicationName.Extensions;
namespace CompanyName.ApplicationName.DataModels
{
   
    public abstract class BaseValidationModelExtended :
    INotifyPropertyChanged, IDataErrorInfo
    {
   
        protected ObservableCollection<string> errors =
            new ObservableCollection<string>();
        protected ObservableCollection<string> externalErrors =
            new ObservableCollection<string>();
        protected BaseValidationModelExtended()
        {
   
            ExternalErrors.CollectionChanged += ExternalErrors_CollectionChanged;
        }
        public virtual ObservableCollection<string> Errors => errors;
        public ObservableCollection<string> ExternalErrors => externalErrors;
        public virtual bool HasError => errors != null && errors.Any();
        #region IDataErrorInfo Members
            public string Error
        {
   
            get
            {
   
                if (!HasError) return string.Empty;
                StringBuilder errors = new StringBuilder();
                Errors.ForEach(e => errors.AppendUniqueOnNewLineIfNotEmpty(e));
                return errors.ToString();
            }
        }
        public virtual string this[string propertyName] => string.Empty;
        #endregion
            #region INotifyPropertyChanged Members
            public virtual event PropertyChangedEventHandler PropertyChanged;
        protected virtual void NotifyPropertyChanged(
            params string[] propertyNames)
        {
   
            if (PropertyChanged != null)
            {
   
                foreach (string propertyName in propertyNames)
                {
   
                    if (propertyName != nameof(HasError)) PropertyChanged(this,
                                                                          new PropertyChangedEventArgs(propertyName));
                }
                PropertyChanged(this,
                                new PropertyChangedEventArgs(nameof(HasError)));
            }
        }
        protected virtual void NotifyPropertyChanged(
            [CallerMemberName]string propertyName = "")
        {
   
            if (PropertyChanged != null)
            {
   
                if (propertyName != nameof(HasError)) PropertyChanged(this,
                                                                      new PropertyChangedEventArgs(propertyName));
                PropertyChanged(this,
                                new PropertyChangedEventArgs(nameof(HasError)));
            }
        }
        #endregion
            private void ExternalErrors_CollectionChanged(object sender,
                                                          NotifyCollectionChangedEventArgs e) =>
            NotifyPropertyChanged(nameof(Errors));
    }
}

在这个例子中,我们添加了两个集合来保存错误消息; Errors 集合属性包含在派生类中生成的验证错误,而 ExternalErrors 集合属性包含外部生成的验证错误,通常来自父视图模型。

在构造函数中,我们将 ExternalErrors_CollectionChanged 事件处理程序附加到 ExternalErrors 集合属性的 CollectionChanged 事件,以便在添加或删除项目时通知它。

在错误集合属性声明之后,我们看到 HasError 表达式体属性,它检查 Errors 集合是否包含任何错误。 请注意,我们检查错误字段是否为空,而不是 Errors 属性,因为调用 Errors 属性会重新生成错误消息,并且我们不希望每次调用 HasError 属性时都重新生成两次。

接下来,我们看到 IDataErrorInfo 接口的新实现。 类索引器与前一个实现中的索引器保持一致,但我们看到 Error 属性的定义有所不同,它现在编译所有错误的完整列表,而不是一次返回单个错误消息。

在其中,我们首先检查是否存在任何错误,如果没有则返回一个空字符串。 如果确实存在错误,我们将初始化一个 StringBuilder 对象并使用我们的 ForEach 扩展方法来遍历 Errors 集合,并将它们中的每一个都附加到其中(如果它们尚未包含)。 在返回输出之前,我们使用另一个扩展方法来执行此操作,所以让我们看看现在的样子:

public static void AppendUniqueOnNewLineIfNotEmpty(
    this StringBuilder stringBuilder, string text)
{
   
    if (text.Trim().Length > 0 && !stringBuilder.ToString().Contains(text))
        stringBuilder.AppendFormat("{0}{1}", stringBuilder.ToString().Trim().
                                   Length == 0 ? string.Empty : Environment.NewLine, text);
}

在我们的 AppendUniqueOnNewLineIfNotEmpty 扩展方法中,我们首先检查输入值不是空字符串,并且它不存在于 StringBuilder 对象中。 如果文本输入参数有效,我们使用三元运算符来确定它是否是要添加的第一个值,以及在添加新的唯一值之前是否需要在其前面加上新行。

现在回到我们的验证基类,我们看到了 INotifyPropertyChanged 接口的新实现。 请注意,我们重复前面的 BaseSynchronizableDataModel 类示例,每次为任何其他属性注册更改时都会引发 PropertyChanged 事件,但与前面的示例不同,我们在这里引发 HasError 属性,而不是 HasChanges 属性。

如果我们愿意,我们可以将这两者结合起来,并在每次收到其他属性更改的通知时为这两个属性引发 PropertyChanged 事件。 在这种情况下,目的是调用 HasError 属性,该属性将在 UI 中用于显示或隐藏显示错误消息的控件,因此它将在每次可验证属性更改后更新。

在我们类的底部,我们看到了表达式主体 ExternalErrors_CollectionChanged 方法,该方法为 Errors 集合属性调用 NotifyPropertyChanged 方法。 这会通知绑定到此属性的数据的控件其值已更改,并且它们应该检索该新值

现在让我们看一个示例实现,使用我们的 Product 类的扩展版本:

public class ProductExtended : BaseValidationModelExtended
{
   
    ...
        public override ObservableCollection<string> Errors
    {
   
        get
        {
   
            errors = new ObservableCollection<string>();
            errors.AddUniqueIfNotEmpty(this[nameof(Name)]);
            errors.AddUniqueIfNotEmpty(this[nameof(Price)]);
            errors.AddRange(ExternalErrors);
            return errors;
        }
    }
    ...
}

因此,当从外部将错误添加到 ExternalErrors 集合时,将调用 ExternalErrors_CollectionChanged 方法,这会通知 Errors 属性的更改。 这会导致调用属性并将外部错误与任何内部错误一起添加到内部错误集合中。

要使 IDataErrorInfo 接口的这个特定实现工作,每个数据模型类都需要重写此 Errors 属性以添加来自每个已验证属性的错误消息。 我们提供了一些扩展方法来简化此任务。 顾名思义,AddUniqueIfNotEmpty 方法将字符串添加到集合中,如果它们不存在于集合中:

public static void AddUniqueIfNotEmpty(
    this ObservableCollection<string> collection, string text)
{
   
  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

0neKing2017

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值