Microsoft Windows Presentation Foundation (WPF) 具有一个丰富数据绑定系统。除了作为通过 Model-View-ViewModel (MVVM) 模式从支持逻辑和数据对 UI 定义进行松散耦合的关键推动力之外,数据绑定系统还为业务数据验证方案提供强大而灵活的支持。WPF 中的数据绑定机制包括多个选项,可用于在创建可编辑视图时评估输入数据的有效性。此外,通过针对控件的 WPF 模板和样式功能,您可以轻松地自定义向用户指示验证错误的方式。
为了支持复杂规则并向用户显示验证错误,通常需要组合使用各种可用的验证机制。即使是看似简单的数据输入形式也可能在业务规则变得复杂时带来验证难题。常用方案涉及单个属性级别的简单规则以及交叉耦合属性,在交叉耦合属性中,一个属性的有效性取决于另一个属性的值。然而,通过 WPF 数据绑定中的验证支持,可以轻松地解决这些难题。
在本文中,您将了解如何使用 IDataErrorInfo 接口实现、ValidationRules、BindingGroups、异常以及与验证相关的附加属性和事件来满足数据验证需要。您还将了解如何使用自己的 ErrorTemplates 和 ToolTips 来自定义验证错误的显示。在本文中,我假设您已熟悉 WPF 的基本数据绑定功能。
数据验证概述
几乎每当您在应用程序中输入或修改数据时,都需要确保数据是有效的,以避免与这些更改的来源(在这种情况下为用户)相去甚远。而且,您需要在用户输入的数据无效时向他们提供清晰指示,还能够向其提供一些有关如何更正数据的指示。只要您知道需使用何种功能以及何时使用,便可通过 WPF 相当轻松地完成这些任务。
在使用 WPF 中的数据绑定来呈现业务数据时,通常应使用 Binding 对象在目标控件的单个属性与数据源对象属性之间提供数据管道。若要使验证是相关的,通常需进行 TwoWay 数据绑定 — 这意味着,除了从源属性流向目标属性以进行显示的数据之外,编辑过的数据也会从目标流向源,如图 1 所示。
图 1 TwoWay 数据绑定中的数据流
可使用三种机制来确定通过数据绑定控件输入的数据是否有效。图 2 对这些机制进行了总结。
图 2 绑定验证机制
验证机制 说明
异常 通过在某个 Binding 对象上设置 ValidatesOnExceptions 属性,如果在尝试对源对象属性设置已修改的值的过程中引发异常,则将为该 Binding 设置验证错误。
ValidationRules Binding 类具有一个用于提供 ValidationRule 派生类实例的集合的属性。这些 ValidationRules 需要覆盖某个 Validate 方法,该方法由 Binding 在每次绑定控件中的数据发生更改时进行调用。如果 Validate 方法返回无效的 ValidationResult 对象,则将为该 Binding 设置验证错误。
IDataErrorInfo 通过在绑定数据源对象上实现 IDataErrorInfo 接口并在 Binding 对象上设置 ValidatesOnDataErrors 属性,Binding 将调用从绑定数据源对象公开的 IDataErrorInfo API。如果从这些属性调用返回非 null 或非空字符串,则将为该 Binding 设置验证错误。
当用户在 TwoWay 数据绑定中输入或修改数据时,将启动以下工作流:
用户通过击键、鼠标、触摸或与各元素间的手写笔交互来输入或修改数据,从而更改元素的属性。
如果需要,可将数据转换为数据源属性类型。
设置源属性值。
触发 Binding.SourceUpdated 附加事件。
如果数据源属性上的 setter 引发异常,则异常会由 Binding 捕获,并可用于指示验证错误。
如果实现了 IDataErrorInfo 属性,则会对数据源对象调用这些属性。
向用户呈现验证错误指示,并触发 Validation.Error 附加事件。
如您所见,该过程中有多个位置可以产生验证错误,具体取决于所选择的机制。列表中未显示触发 ValidationRule 的位置。这是因为,根据为 ValidationRule 上的 ValidationStep 属性设置的值,可以在该过程中的各个位置触发 ValidationRule,包括在类型转换之前、转换之后、更新属性之后或提交更改的值时(如果数据对象实现 IEditableObject)。默认值为 RawProposedValue,它在类型转换之前发生。数据从目标控件属性类型转换为数据源对象属性类型的位置通常隐式产生,不会触及代码的任何部分(如 TextBox 中的数字输入)。此类型转换过程可能引发异常,这些异常应该用于向用户指示验证错误。
如果无法将值写入源对象属性,则该值显然是无效输入。如果选择挂接 ValidationRules,则会在该过程中由 ValidationStep 属性指示的位置处调用 ValidationRules,它们可基于嵌入其中或从其调用的任何逻辑来返回验证错误。如果源对象属性 setter 引发异常,则几乎应总是将该异常视为验证错误,这与类型转换的情况相同。
最后,如果实现 IDataErrorInfo,则将针对为了基于从接口返回的字符串来检查是否存在验证错误而设置的属性,来调用向该接口的数据源对象添加的索引器属性。稍后我会更详细地介绍每种机制。
您必须决定需要何时进行验证。验证将在 Binding 向基础源对象属性写入数据时进行。何时进行验证由 Binding 的 UpdateSourceTrigger 属性来指定,对于大多数属性,该属性设置为 PropertyChanged。某些属性(如 TextBox.Text)会将该值更改为 FocusChange,这意味着验证将在焦点离开正用于编辑数据的控件时发生。也可将该值设置为 Explicit,这意味着必须对绑定显式调用验证。在本文后面讨论的 BindingGroup 将使用 Explicit 模式。
在验证方案中(尤其是对于 TextBoxes),通常需要立即向用户提供反馈。若要对此提供支持,应将 Binding 上的 UpdateSourceTrigger 属性设置为 PropertyChanged:
Text="{Binding Path=Activity.Description, UpdateSourceTrigger=PropertyChanged}
事实证明,对于许多实际验证方案,您需要利用这些机制中的多种机制。根据您所关心的验证错误类型以及验证逻辑的位置,每种机制各有其优缺点。
业务验证方案
为了更具体地说明这一点,让我们来看一个具有半真实业务环境的编辑方案,您会看到每种机制如何发挥作用。此方案和验证规则基于我为某个客户编写的一个实际应用程序,其中有一个相当简单的窗体,它因用于验证的支持业务规则而要求使用几乎每种验证机制。对于本文中使用的更加简单的应用程序,我会使用每种机制来演示其用法,即使并未明确要求使用所有这些机制。
假设您需要编写一个应用程序以便为在家中提供客户支持电话服务的现场技术人员(可以是网络专家,但也可以是尝试追加销售其他功能和服务的人员)提供支持。对于技术人员在现场进行的每个活动,该技术人员都需要向一个列明他所进行的各项活动的报告进行输入,并将该报告与多个数据片段相关联。图 3 中显示了对象模型。
图 5 验证错误
因为异常可能在类型转换过程中发生,所以最好只要有可能发生类型转换失败,就要在输入 Bindings 上设置此属性,即使支持属性只对不会发生异常的成员变量设置值。
例如,假设要将 TextBox 用作 DateTime 属性的输入控件。如果用户输入无法转换的字符串,则 ValidatesOnExceptions 是 Binding 可以指示错误的唯一方式,因为永远不会调用源对象属性。
如果需要在存在无效数据时执行某些特定操作(如禁用某个命令),则可以在控件上挂接 Validation.Error 附加事件。您还需要在 Binding 上将 NotifyOnValidationError 属性设置为 true。
<TextBox Name="ageTextBox"
Text ="{Binding Path=Age,
ValidatesOnExceptions=True,
NotifyOnValidationError=True}"
Validation.Error="OnValidationError".../>
ValidationRule 验证
在某些方案中,可能需要在 UI 级别嵌入验证,并需要使用更加复杂的逻辑来确定输入是否有效。对于示例应用程序,请考虑用于 Inventory 字段的验证规则。如果输入数据,则该数据应是遵循特定模式的逗号分隔的型号列表。ValidationRule 可以轻松满足此要求,因为它完全取决于设置的值。ValidationRule 可使用 string.Split 调用将输入转换为字符串数组,然后使用正则表达式来检查各个部分是否符合给定模式。为此,可以按图 6 所示来定义 ValidationRule。
图 6 用于验证字符串数组的 ValidationRule
public class InventoryValidationRule : ValidationRule {
public override ValidationResult Validate(
object value, CultureInfo cultureInfo) {
if (InventoryPattern == null)
return ValidationResult.ValidResult;
if (!(value is string))
return new ValidationResult(false,
"Inventory should be a comma separated list of model numbers as a string");
string[] pieces = value.ToString().Split(‘,’);
Regex m_RegEx = new Regex(InventoryPattern);
foreach (string item in pieces) {
Match match = m_RegEx.Match(item);
if (match == null || match == Match.Empty)
return new ValidationResult(
false, "Invalid input format");
}
return ValidationResult.ValidResult;
}
public string InventoryPattern { get; set; }
}
在 ValidationRule 上公开的属性可以在使用位置通过 XAML 进行设置,从而允许这些属性更加灵活一些。此验证规则将忽略无法转换为字符串数组的值。但在规则可以执行 string.Split 时,它将使用 RegEx 来验证逗号分隔列表中的每个字符串是否都符合通过 InventoryPattern 属性设置的模式。
当返回有效标志设置为 false 的 ValidationResult 时,可以在 UI 中使用您所提供的错误消息,以向用户呈现错误(我将在后面加以说明)。ValidationRule 的一个弊端是需要使用 XAML 中的扩展 Binding 元素来将其挂接,如下面的代码所示:
<TextBox Name="inventoryTextBox"...>
<TextBox.Text>
<Binding Path="Activity.Inventory"
ValidatesOnExceptions="True"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True">
<Binding.ValidationRules>
<local:InventoryValidationRule
InventoryPattern="^\D?(\d{3})\D?\D?(\d{3})\D?(\d{4})$"/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
在此示例中,由于 ValidatesOnExceptions 属性设置为 true,因此我的 Binding 仍会在发生异常时引发验证错误,我还会根据设置为 true 的 ValidatesOnDataErrors 来支持 IDataErrorInfo 验证(接下来我将对此进行讨论)。
如果将多个 ValidationRules 附加到同一个属性,则这些规则可以各自具有不同的 ValidationStep 属性值,或具有相同的值。将按声明的顺序对同一 ValidationStep 中的规则进行评估。很明显,较早 ValidationSteps 中的规则将在较晚 ValidationStep 中的规则之前运行。可能不那么明显的地方是,如果 ValidationRule 返回错误,则不会评估任何后续规则。因此,第一个验证错误是在 ValidationRules 导致错误时所指示的唯一验证错误。
IDataErrorInfo 验证
IDataErrorInfo 接口需要实现者公开一个属性和一个索引器:
public interface IDataErrorInfo {
string Error { get; }
string this[string propertyName] { get; }
}
Error 属性用于指示整个对象的错误,而索引器用于指示单个属性级别的错误。两者的工作原理相同:如果返回非 null 或非空字符串,则表示存在验证错误。此外,返回的字符串可用于向用户显示错误(我将在后面加以说明)。
当使用绑定到数据源对象上的各个属性的各个控件时,该接口的最重要部分就是索引器。只有在将对象显示在 DataGrid 或 BindingGroup 中的这种方案中,才使用 Error 属性。Error 属性用于指示行级别的错误,而索引器用于指示单元格级别的错误。
实现 IDataErrorInfo 具有一个很大的弊端:索引器的实现通常会导致较大的 switch-case 语句(对象中的每个属性名称都对应于一种情况),您必须基于字符串进行切换和匹配,并返回指示错误的字符串。而且,在对象上设置属性值之前,不会调用 IDataErrorInfo 的实现。如果其他对象订阅了该对象上的 INotifyPropertyChanged.PropertyChanged,则已通知这些对象发生了更改,它们可能已基于 IDataErrorInfo 实现要声明为无效的数据开始工作。如果这对于您的应用程序可能是个问题,则需要在对设置的值不满意时从属性 setter 引发异常。
IDataErrorInfo 的优点是,它可用于轻松地处理交叉耦合属性。例如,除了使用 ValidationRule 来验证 Inventory 字段的输入格式之外,还应记住这一要求:当 ActivityType 为 Install 时,必须填写 Inventory 字段。ValidationRule 本身无权访问数据绑定对象上的其他属性。它只是传递为 Binding 挂接的属性所设置的值。若要满足此要求,当设置了 ActivityType 属性时,您需要使验证在 Inventory 属性上发生,并在 ActivityType 设置为 Install 而 Inventory 的值为空时返回无效结果。
若要实现此目的,您需要使用 IDataErrorInfo,以便可在评估 Inventory 时检查 Inventory 和 ActivityType 属性,如下所示:
public string this[string propertyName] {
get { return IsValid(propertyName); }
}
private string IsValid(string propertyName) {
switch (propertyName) {
...
case "Inventory":
if (ActivityType != null &&
ActivityType.Name == "Install" &&
string.IsNullOrWhiteSpace(Inventory))
return "Inventory expended must be entered for installs";
break;
}
此外,您需要让 Inventory Binding 在 ActivityType 属性发生更改时调用验证。通常,仅当 UI 中的该属性发生更改时,Binding 才查询 IDataErrorInfo 实现或调用 ValidationRules。在本例中,即使 Inventory 属性尚未更改但相关 ActivityType 已更改,我也需要触发 Binding 验证的重新评估。
可通过两种方式让 Inventory Binding 在 ActivityType 属性发生更改时进行刷新。第一种(也是较为简单)的方式是在设置 ActivityType 时为 Inventory 发布 PropertyChanged 事件:
ActivityType _ActivityType;
public ActivityType ActivityType {
get { return _ActivityType; }
set {
if (value != _ActivityType) {
_ActivityType = value;
PropertyChanged(this,
new PropertyChangedEventArgs("ActivityType"));
PropertyChanged(this,
new PropertyChangedEventArgs("Inventory"));
}
}
}
这会使 Binding 刷新并重新评估该 Binding 的验证。
第二种方式是在 ActivityType ComboBox 或其父元素之一的上面挂接 Binding.SourceUpdated 附加事件,并从该事件的代码隐藏处理程序来触发 Binding 刷新:
<ComboBox Name="activityTypeIdComboBox"
Binding.SourceUpdated="OnPropertySet"...
private void OnPropetySet(object sender,
DataTransferEventArgs e) {
if (activityTypeIdComboBox == e.TargetObject) {
inventoryTextBox.GetBindingExpression(
TextBox.TextProperty).UpdateSource();
}
}
以编程方式调用 Binding 上的 UpdateSource 会使其将绑定目标元素中的当前值写入源属性,从而如同用户刚刚编辑过控件那样来触发验证链。
将 BindingGroup 用于交叉耦合属性
Microsoft .NET Framework 3.5 SP1 中新增了 BindingGroup 功能。BindingGroup 专门用于一次性评估一组绑定上的验证。例如,它允许用户填写整个窗体,并一直等到其按“提交”或“保存”按钮以评估窗体的验证规则,然后一次性呈现验证错误。在示例应用程序中,我要求必须至少提供一个 Customer、Objective 或 Reason。BindingGroup 也可以用于评估窗体的子集。
若要使用 BindingGroup,需要一组具有普通 Bindings 并共享公共上级元素的控件。在示例应用程序中,Customer ComboBox、Objective ComboBox 和 Reason TextBox 都位于同一个布局 Grid 中。BindingGroup 是 FrameworkElement 上的属性。它具有 ValidationRules 集合属性,可以使用一个或多个 ValidationRule 对象来填充该属性。下面的 XAML 演示示例应用程序的 BindingGroup 挂接:
<Grid>...
<Grid.BindingGroup>
<BindingGroup>
<BindingGroup.ValidationRules>
<local:CustomerObjectiveOrReasonValidationRule
ValidationStep="UpdatedValue"
ValidatesOnTargetUpdated="True"/>
</BindingGroup.ValidationRules>
</BindingGroup>
</Grid.BindingGroup>
</Grid>
在此示例中,我向集合中添加了 CustomerObjectiveOrReasonValidationRule 的一个实例。使用 ValidationStep 属性,可以在某种程度上控制传递给规则的值。UpdatedValue 表示要使用写入到数据源对象的值(在写入该值后)。您还可以为 ValidationStep 选择值,从而可以使用用户的原始输入、应用了类型和值转换后的值或是“已提交的”值(这意味着实现 IEditableObject 接口以针对对象的属性做出事务性更改)。
ValidatesOnTargetUpdated 标志使得每次通过 Bindings 设置目标属性时都会对规则进行评估。这包括最初设置属性时的情况,因此在初始数据无效时,以及每当用户在属于 BindingGroup 的控件中更改值时,都将立刻获得验证错误指示。
挂接到 BindingGroup 的 ValidationRule 的工作方式与挂接到单个 Binding 的 ValidationRule 稍有不同。图 7 显示了挂接到上面代码示例中演示的 BindingGroup 的 ValidationRule。
图 7 BindingGroup 的 ValidationRule
public class CustomerObjectiveOrReasonValidationRule :
ValidationRule {
public override ValidationResult Validate(
object value, CultureInfo cultureInfo) {
BindingGroup bindingGroup = value as BindingGroup;
if (bindingGroup == null)
return new ValidationResult(false,
"CustomerObjectiveOrReasonValidationRule should only be used with a BindingGroup");
if (bindingGroup.Items.Count == 1) {
object item = bindingGroup.Items[0];
ActivityEditorViewModel viewModel =
item as ActivityEditorViewModel;
if (viewModel != null && viewModel.Activity != null &&
!viewModel.Activity.CustomerObjectiveOrReasonEntered())
return new ValidationResult(false,
"You must enter one of Customer, Objective, or Reason to a valid entry");
}
return ValidationResult.ValidResult;
}
}
在挂接到单个 Binding 的 ValidationRule 中,传入的值是来自一个数据源属性的单个值,该数据源属性设置为该 Binding 的 Path。对于 BindingGroup,传递给 ValidationRule 的值是 BindingGroup 本身。该值包含一个 Items 集合,该集合由包含元素的 DataContext(本例中为 Grid)进行填充。
对于示例应用程序,我使用 MVVM 模式,因此,视图的 DataContext 是 ViewModel 本身。Items 集合仅包含对 ViewModel 的单个引用。我可以从 ViewModel 来访问它上面的 Activity 属性。本例中 Activity 类所具有的验证方法可确定是否输入了至少一个 Customer、Objective 或 Reason,因此,我不必在 ValidationRule 中重复该逻辑。
与前面介绍的其他 ValidationRule 一样,如果您对传入的数据值满意,则可返回一个 ValidationResult.ValidResult。如果不满意,则可使用设置为 false 的有效标志和指示问题的字符串消息来构造新的 ValidationResult,该消息随后可以用于显示。
不过,设置 ValidatesOnTargetUpdated 标志并不足以让 ValidationRules 自动触发。BindingGroup 是围绕为整个控件组显式触发验证(通常是通过在窗体上按“提交”或“保存”按钮这类操作)的概念而设计的。在某些方案中,用户在认为编辑过程完成之前不希望被验证错误指示所打扰,因此 BindingGroup 在设计上考虑了此方法。
在示例应用程序中,我希望每当用户更改窗体中的内容时,将立即向其提供验证错误反馈。若要使用 BindingGroup 实现此目的,必须在属于该组的各个输入控件上挂接适当的更改事件,并使这些事件的事件处理程序触发 BindingGroup 的评估。在示例应用程序中,这意味着在 TextBox 上的两个 ComboBoxes 和 TextBox.TextChanged 事件上挂接 ComboBox.SelectionChanged 事件。所有这些事件都可以指向代码隐藏中的单个处理方法:
private void OnCommitBindingGroup(
object sender, EventArgs e) {
CrossCoupledPropsGrid.BindingGroup.CommitEdit();
}
请注意,对于验证显示,将在 BindingGroup 所在的 FrameworkElement(如示例应用程序中的 Grid)上显示默认红色边框,如图 4 所示。也可以使用 Validation.ValidationAdornerSite 和 Validation.ValidationAdornerSiteFor 附加属性来更改显示验证指示的位置。默认情况下,各个控件也会为其各自的验证错误显示红色边框。在示例应用程序中,我通过 Style 将 ErrorTemplate 设置为 null 以将这些边框关闭。
对于 .NET Framework 3.5 SP1 中的 BindingGroup,您可能会在初始窗体加载时遇到与正常显示验证错误有关的问题,即使在 ValidationRule 上设置了 ValidatesOnTargetUpdated 属性。我针对此问题找到的一个解决方法是“稍微修改”BindingGroup 中的绑定属性之一。在示例应用程序中,您可以在视图的 Loaded 事件中,在 TextBox 中最初呈现的任何文本结尾处添加和删除一个空格,如下所示:
string originalText = m_ProductTextBox.Text;
m_ProductTextBox.Text += " ";
m_ProductTextBox.Text = originalText;
这会使 BindingGroup ValidationRule 在所包含的 Binding 属性之一发生更改时触发,从而调用每个 Binding 的验证。此行为在 .NET Framework 4.0 中得到了修复,因此无需该解决方法便可获得验证错误的初始显示 — 只需在验证规则上将 ValidatesOnTargetUpdated 属性设置为 true。
验证错误显示
如前所述,WPF 显示验证错误的默认方式是在控件周围绘制红色边框。通常需要对此方法进行自定义,以通过其他方式来显示错误。而且,默认情况下不会显示与验证错误关联的错误消息。常见的要求是仅当存在验证错误时才在工具提示中显示错误消息。通过将 Styles 和一组与验证关联的附加属性进行组合,可以相当轻松地自定义验证错误显示。
添加显示错误文本的工具提示非常简单。只需定义一个应用于输入控件的 Style,每当存在验证错误时,它便将该控件上的 ToolTip 属性设置为验证错误文本。若要对此提供支持,需要使用两个附加属性:Validation.HasError 和 Validation.Errors。下面演示了一个针对 TextBox 类型并设置工具提示的 Style:
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Validation.HasError"
Value="True">
<Setter Property="ToolTip">
<Setter.Value>
<Binding
Path="(Validation.Errors).CurrentItem.ErrorContent"
RelativeSource="{x:Static RelativeSource.Self}" />
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
您可以看到,Style 只包含 Validation.HasError 附加属性的属性触发器。当 Binding 更新其源对象属性且验证机制生成错误时,HasError 属性会设置为 true。这种情况可能源自异常、ValidationRule 或 IDataErrorInfo 调用。该 Style 随后使用 Validation.Errors 附加属性,该属性会在存在验证错误时包含一个错误字符串集合。可以使用该集合类型的 CurrentItem 属性来仅获取集合中的第一个字符串。也可以设计为将数据绑定到集合,并为面向列表的控件中的每一项显示 ErrorContent 属性。
若要将控件的默认验证错误显示更改为红色边框之外的内容,需要将 Validation.ErrorTemplate 附加属性设置为要自定义的控件上的新模板。在示例应用程序中,将在存在错误的每个控件右侧显示一个小的红色渐变圆形,而不是显示红色边框。为此,可定义用作 ErrorTemplate 的控件模板。
<ControlTemplate x:Key="InputErrorTemplate">
<DockPanel>
<Ellipse DockPanel.Dock="Right" Margin="2,0"
ToolTip="Contains invalid data"
Width="10" Height="10">
<Ellipse.Fill>
<LinearGradientBrush>
<GradientStop Color="#11FF1111" Offset="0" />
<GradientStop Color="#FFFF0000" Offset="1" />
</LinearGradientBrush>
</Ellipse.Fill>
</Ellipse>
<AdornedElementPlaceholder />
</DockPanel>
</ControlTemplate>
若要将该控件模板挂接到某个控件,只需设置该控件的 Validation.ErrorTemplate 属性,您可以通过 Style 再次执行此操作:
<Style TargetType="TextBox">
<Setter Property="Validation.ErrorTemplate"
Value="{StaticResource InputErrorTemplate}" />
...
</Style>
总结
在本文中,我演示了如何使用 WPF 数据绑定的三种验证机制来实现一些业务数据验证方案。您已了解到如何使用异常、ValidationRule 和 IDataErrorInfo 接口来实现单个属性验证,以及如何使用其验证规则取决于控件上其他属性的当前值的属性。您还了解到如何使用 BindingGroup 来一次性评估多个 Bindings,以及如何自定义 WPF 默认方式之外的错误显示。
本文的示例应用程序具有满足某个简单应用程序中描述的业务规则的整套验证,该应用程序使用 MVVM 将视图挂接到为其提供支持的数据。