验证用户输入 - WPF MVVM






4.69/5 (28投票s)
使用 IDataErrorInfo 接口在 WPF MVVM 应用程序中验证用户输入。
引言
在编写以数据为中心的应用程序时,验证用户输入成为一个重要的设计考量。最重要的考量之一是何时进行输入验证。您是在用户尝试保存数据后才验证输入,还是在用户输入数据时进行验证。我个人认为,在用户忙于输入数据时,在用户尝试提交之前进行验证是更好的。幸运的是,.NET Framework 提供了用于这种情况的接口,即 IDataErrorInfo
接口。通过在您的模型中实现此接口,并结合 WPF 数据绑定,用户输入验证将变得自动化,实现验证仅取决于如何将验证错误呈现给用户。在使用 MVVM 设计模式时,该接口可以轻松地将代码从代码隐藏移至 ViewModel 和 Model。
本文是一篇关于在 MVVM WPF 应用程序中实现此接口的教程,确保 View 中不编写任何代码。演示应用程序将仅包含一个表单,用户可以在其中输入新产品。
要求
要学习本教程,只需要 Visual Studio(我使用的是 Visual C# 2010 Express,但您可以使用任何版本)。我还使用了一个 MVVM 应用程序模板,可以从 这里 下载。我还会假定您对 MVVM 模式以及 WPF 数据绑定有基本了解,因为本教程将重点关注 IDataErrorInfo
接口的实现。
那么,让我们开始吧...
模型
安装 MVVM 应用程序模板后,打开 Visual Studio,使用该模板启动一个新项目,这将为您提供一个可用的 MVVM 应用骨架。我通常在创建模型时开始大多数项目,因为我倾向于同时开发模型和数据存储结构。由于此示例实际上不会执行任何数据保存或检索操作,我们可以只创建一个模型。我们的应用程序规范只需要一个表单,用户可以在其中输入并保存产品的 Product Name、Height 和 Width。这意味着我们只需要一个模型,即一个具有三个属性的模型:ProductName
、Width
和 Height
。右键单击models文件夹,并添加一个名为 Product
的新类。将该类设为 public
,以便我们可以使用它。在您的 Product
类中添加三个 public
属性:ProductName(string)
、Width
和 Height
,两者均为 int
。在文件顶部添加 using 语句 System.ComponentModel
。这是 IDataErrorInfo
接口所在的命名空间。通过在类声明之后添加接口名称,确保您的类实现了 IDataErrorInfo
接口,如下所示:
class Contact : IDataErrorInfo
完成此操作后,intellisense 应该会在声明下方显示一条蓝线。如果将光标悬停在蓝线上,您将可以选择是实现接口还是显式实现接口。要实现 IDataErrorInfo
接口,一个类需要公开两个 readonly
属性:一个错误属性和一个项属性,项属性是一个索引器,它接受一个字符串参数,代表实现类中某个属性的名称。您是隐式还是显式实现接口取决于您的类中是否还有其他索引器,以及是否需要区分它们。对我们而言,我们只需要隐式实现接口,所以请选择此选项。这将生成所需属性的属性存根,并带有通用的 throw new NotImplementedException
,看起来应该像这样:
public string Error
{
get
{
throw new NotImplementedException();}
}
}
public string this[string columnName]
{
get
{
throw new NotImplementedException();
}
}
错误属性返回一个 string
,指示整个对象有什么问题,而 Item
属性(实现为索引器,因此显示为 public string this[columnName]
)返回一个 string
,指示与作为参数传递的特定属性相关的错误。如果属性根据您指定的验证规则有效,则 Item
属性返回一个空字符串。在大多数情况下,可以保留 Error
属性为 NotImplemented
,而在索引器中为类的每个属性实现单独的验证。基本上,它的工作原理是检查正在验证哪个属性(使用作为输入的参数),然后根据您指定的规则验证该属性。对于我们的验证,假设每个产品的名称必须超过 5 个字母,Height 不得大于 Width,并且显然每个属性都不能为空。
让我们将每个验证实现为自己的方法,该方法可以根据需要从 Item
属性调用。让每个验证方法返回一个 string
。如果验证失败,该方法应返回相应的错误消息,否则应返回一个空字符串。每个验证方法都非常相似,它们首先检查属性是否具有值,然后检查值是否符合正确的规则,否则返回错误消息。如果属性有效,它将通过所有测试并返回一个空字符串。在 Item 索引器本身中,我们声明一个 string validationResult
来保存我们的错误消息,然后使用 switch
语句根据正在验证的属性来调用正确的验证方法,将结果分配给我们的 validationResult string
,然后将其返回给调用函数。这样就完成了我们的 Contact 模型,也完成了实现 IDataErrorInfo
接口所需的所有工作,现在我们的代码应该如下所示:
public class Product:IDataErrorInfo
{
#region state properties
public string ProductName{ get; set; }
public int Width { get; set; }
public int Height { get; set; }
#endregion
public void Save()
{
//Insert code to save new Product to database etc
}
public string Error
{
get { throw new NotImplementedException(); }
}
public string this[string propertyName]
{
get
{
string validationResult = null;
switch (propertyName)
{
case "ProductName":
validationResult = ValidateName();
break;
case "Height":
validationResult = ValidateHeight();
break;
case "Width":
validationResult = ValidateWidth();
break;
default:
throw new ApplicationException("Unknown Property being validated on Product.");
}
return validationResult;
}
}
private string ValidateName()
{
if (String.IsNullOrEmpty (this.ProductName))
return "Product Name needs to be entered.";
else if(this.ProductName.Length < 5)
return "Product Name should have more than 5 letters.";
else
return String.Empty;
}
private string ValidateHeight()
{
if (this.Height <= 0)
return "Height should be greater than 0";
if (this.Height > this.Width)
return "Height should be less than Width.";
else
return String.Empty;
}
private string ValidateWidth()
{
if (this.Width <= 0)
return "Width should be greater than 0";
if (this.Width < this.Height)
return "Width should be greater than Height.";
else
return String.Empty;
}
}
我添加了一个 Save
方法,但将其留空。在实际应用中,您可以在此处将产品保存到数据库或 XML 文件等。
ViewModel
现在我们的模型已经全部完成,我们需要决定如何将该模型呈现给用户。在我们的例子中,最好的方案是创建一个具有空属性的 Product
类的当前实例,用户可以通过 TextBox
和 2 个 Sliders 填充该实例,然后在所有属性都有效的情况下保存。因此,删除项目中默认的MainView.xaml和MainViewModel.cs文件,并在ViewModels文件夹中添加一个新类NewProductViewModel.cs,在 Views 文件夹中添加一个新的 WindowNewProductView.xaml。展开解决方案资源管理器中的App.xaml节点,并打开App.xaml.cs。应用程序的 OnStartup
方法就位于此处,我们需要更改该方法,使其如下所示:
private void OnStartup(object sender, StartupEventArgs e)
{
// Create the ViewModel and expose it using the View's DataContext
Views.NewProductView newProductView = new Views.NewProductView();
NewProducts.Models.Product newProduct = new Models.Product();
newProductView.DataContext = new ViewModels.NewProductViewModel(newProduct);
newProductView.Show();
}
打开您的 NewProductViewModel 文件,并添加 System.Windows
、System.Windows.Input
、System.ComponentModel
、Contacts.Commands
和 Contacts.Models
using 指令。确保 NewProductViewModel
继承自 ViewModelBase
。添加一个 private
方法 Exit
,我们将在此处关闭应用程序,如下所示:
private void Exit()
{
Application.Current.Shutdown();
}
以及一个 ICommand ExitCommand
,我们将使用它来绑定到 Exit MenuItem
,如下所示:
private DelegateCommand exitCommand;
public ICommand ExitCommand
{
get
{
if (exitCommand == null)
{
exitCommand = new DelegateCommand(Exit);
}
return exitCommand;
}
}
让您的 NewProductViewModel
类实现 IDataErrorInfo
和 INotifyPropertyChanged
接口,将它们添加到类声明中 ViewModelBase
之后。让 intellisense 自动为您生成 IDataErrorInfo
接口的属性存根。
在 NewProductViewModel
类中,将您的 Product
类的一个 readonly
实例添加为 private
字段,并将其命名为 currentProduct
。添加一个接受 Product
作为参数的构造函数,并将 currentProduct
指向该实例,如下所示:
private readonly Product currentProduct;
public NewProductViewModel(Product newProduct)
{
this.currentProduct = newProduct;
}
然后,我们需要为 View 的绑定设置一些 public
属性,这些属性代表 currentProduct
的属性。这些属性需要实现 INotifyPropertyChanged
,并且必须设置和获取 currentProduct
上的相关属性,并且需要如下所示,请记住 NewProductViewModel
继承了实现 OnPropertyChanged
处理程序的 ViewModelBase
:
public string ProductName
{
get { return currentProduct.ProductName; }
set
{
if (currentProduct.ProductName != value)
{
currentProduct.ProductName = value;
base.OnPropertyChanged("ProductName");
}
}
}
对于 Width
和 Height
属性,我们存在一个依赖关系,即 Height
不得超过 Width
。这意味着如果一个属性发生更改,我们就需要检查另一个属性是否仍然有效。最简单的方法是,当任一属性更改时,就好像两个属性都已更改一样。我们可以通过在任一属性更改时调用 base.OnPropertyChanged
来实现这一点。因此,我们的 Property
声明现在应该如下所示:
public int Width
{
get { return currentProduct.Width; }
set
{
if (this.currentProduct.Width != value)
{
this.currentProduct.Width = value;
base.OnPropertyChanged("Width");
base.OnPropertyChanged("Height");
}
}
}
public int Height
{
get { return currentProduct.Height; }
set
{
if (this.currentProduct.Height != value)
{
this.currentProduct.Height = value;
base.OnPropertyChanged("Height");
base.OnPropertyChanged("Width");
}
}
}
现在剩下的就是将我们的 IDataErrorInfo
成员设置为返回我们基类 Product
类的相关错误和项属性。对于错误属性,我们只需将 currentProduct
转换为 IDataErrorInfo
并调用错误属性。对于 Item
属性,过程相同,只是添加了一个调用来确保 CommandManager
更新所有验证,如下所示:
public string Error
{
get
{
return (currentProduct as IDataErrorInfo).Error;
}
}
public string this[string columnName]
{
get
{
string error = (currentProduct as IDataErrorInfo)[columnName];
CommandManager.InvalidateRequerySuggested();
return error;
}
}
唯一剩下的就是实现一个 ICommand
(SaveCommand
) 来将 currentProduct
保存到文件,以及一个由 SaveCommand
调用的保存方法。 save
方法简单地调用 currentProduct.Save()
,而 SaveCommand
创建一个 DelegateCommand
,将 Save
方法作为参数传递,如下所示:
private DelegateCommand saveCommand;
public ICommand ExitCommand
{
get
{
if (exitCommand == null)
{
exitCommand = new DelegateCommand(Exit);
}
return exitCommand;
}
}
private void Save()
{
currentContact.Save();
}
这将为我们提供一个 Command
,用于绑定到 NewProductView
上的 Save 按钮。这标志着我们的 NewProductViewModel
基础部分的结束,完成了确保用户了解每个属性有效性的所有要求。现在要实现的唯一工作是实际的 View。
View
我们本练习的 View 将非常简单。它将包含顶部的一个菜单,其中包含典型的文件 - 退出菜单项,用户可以使用它们关闭应用程序;一个带标签的 TextBox
,用户将使用它输入新产品的 Product Name;2 个带标签的 Sliders,每个 Sliders 的值都显示在标签旁,用户将使用它们输入 Height
和 Width
;以及一个按钮,用户可以使用它来保存 Product
。
打开 NewProductView
的 XAML 文件,并添加一个指向您的 Commands
命名空间的命名空间引用。这将允许我们在 View 中添加 CommandReference
,我们可用它来创建一个 InputBinding
,以便用户可以使用 Ctrl-X 关闭应用程序。
xmlns:c="clr-namespace:NewProducts.Commands"
然后,在 ContactViewModel
中创建对 ExitCommand
的引用,如下所示:
<Window.Resources>
<c:CommandReference x:Key="ExitCommandReference" Command="{Binding ExitCommand}" />
</Window.Resources>
然后创建 InputBinding
,如下所示:
<Window.InputBindings>
<KeyBinding Key="X" Modifiers="Control"
Command="{StaticResource ExitCommandReference}" />
</Window.InputBindings>
接下来,删除默认的 grid,并用 DockPanel
替换它。在 dockpanel
中添加一个 Menu,并将其 DockPanel.Dock
属性设置为 top。在此 Menu 中,添加一个 MenuItem
,其 Header 为 _File
,并在其中创建一个另一个 MenuItem
,其 Header 为 E_xit
。设置此 Exit MenuItem
的 Command
属性以绑定到我们的 ExitCommand
命令,并将其 InputGestureText
设置为 Ctrl-X
,如下所示:
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Command="{Binding ExitCommand}"
Header="E_xit" InputGestureText="Ctrl-X" />
</MenuItem>
</Menu>
</DockPanel>
在 DockPanel
中,菜单下方,添加一个 Grid
,并在网格上定义 3 列和 4 行。将第一列的宽度设置为 Auto
,第二列设置为 5*
,第三列设置为 *
。将所有行的行高设置为 Auto。在网格的第一行,在第一列中放置一个 Label
,在第二列中放置一个 TextBox
。在接下来的 2 行中,在第一列中放置一个标签,在第二列中放置一个 slider,在第三列中放置另一个标签。分别使用 x
: notation
、sliHeight
和 sliWidth
为 Sliders 命名。将标签的内容分别设置为 Product Name :
、Width :
和 Height :
,并相应地命名 TextBoxes
,例如 txtProductName
、txtHeight
和 txtWidth
。对于 txtProductName
的 Text
属性,创建一个 Binding 并将 Binding 的 Path 设置为我们 NewProductViewModel
上的 ProductName
属性。对于 Sliders,在 Value
属性上创建 Binding。
在每个 Binding 中,将 ValidatesOnDataErrors
设置为 True
,并将 UpdateSourceTrigger
属性设置为 PropertyChanged
。对于第三列中的标签,它们将显示 Sliders 的值,我们为 Content
属性设置了一个 Binding,将 Binding 的 ElementName
设置为相应的 slider,并将 Path
设置为 slider 的 Value
属性。
在网格的第四行放置一个 Button,将其 Content 设置为 _Save
,并为其 Command
属性创建一个 Binding,该 Binding 的 Path 指向我们 ContactViewModel
上的 SaveCommand
。现在 Window 内容的 XAML 应该看起来像这样:
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="5*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- PRODUCT NAME-->
<Label Grid.Row="0" Grid.Column="0" Content="Product Name:"
HorizontalAlignment="Right" Margin="3"/>
<TextBox x:Name="txtProductName" Grid.Row="0" Grid.Column="1" Margin="3"
Text="{Binding Path=ProductName, ValidatesOnDataErrors=True,
UpdateSourceTrigger=PropertyChanged}"/>
<!-- HEIGHT-->
<Label Grid.Row="1" Grid.Column="0" Content="Height:"
HorizontalAlignment="Right" Margin="3"/>
<Slider Grid.Row="1" Grid.Column="1" Margin="3" x:Name="sliHeight" Maximum="100"
Value="{Binding Path=Height, ValidatesOnDataErrors=True,
UpdateSourceTrigger=PropertyChanged}"/>
<Label Grid.Row="1" Grid.Column="2"
Content="{Binding ElementName=sliHeight, Path=Value}"/>
<!-- WIDTH-->
<Label Grid.Row="2" Grid.Column="0" Content="Width:"
HorizontalAlignment="Right" Margin="3"/>
<Slider Grid.Row="2" Grid.Column="1" Margin="3" x:Name="sliWidth" Maximum="100"
Value="{Binding Path=Width, ValidatesOnDataErrors=True,
UpdateSourceTrigger=PropertyChanged}"/>
<Label Grid.Row="2" Grid.Column="2"
Content="{Binding ElementName=sliWidth, Path=Value}"/>
<!-- SAVE BUTTON -->
<Button Grid.Row="3" Grid.Column="1" Command="{Binding Path=SaveCommand}" Content="_Save"
HorizontalAlignment="Right" Margin="4,2" MinWidth="60"/>
</Grid>
这样就完成了!如果您现在运行应用程序,您会看到每个数据输入控件都有一个红色的边框,一旦您在每个控件中输入了有效条目,红色边框就会消失。不幸的是,应用程序仍然存在一些严重缺陷。即使数据无效,仍然可以保存产品。此外,默认的红色边框并不像一个严肃的应用程序应该提供的那么具有信息量,所以我们确实应该尝试实现一些更用户友好的东西,让用户更精确地了解发生了什么。
清理
保存无效数据
关于用户可以保存无效数据的问题,最好的解决方案是禁用 Save 按钮,直到所有属性都有效为止。这意味着需要定义一种方法来将 Button 的 IsEnabled
属性设置为 false
,如果任何属性无效。考虑到 IsEnabled
属性是一个布尔值,如果我们有一个布尔属性 AllPropertiesValid
在我们的 NewProductViewModel
中,我们可以将其绑定到 Button 的 IsEnabled
属性,这样会最简单。
所以,打开NewProductViewModel.cs文件,并定义一个 bool 属性 AllPropertiesValid
,它返回一个 private
字段 allPropertiesValid
,如下所示:
private bool allPropertiesValid = false;
public bool AllPropertiesValid
{
get { return allPropertiesValid; }
set
{
if (allPropertiesValid != value)
{
allPropertiesValid = value;
base.OnPropertyChanged("AllPropertiesValid");
}
接下来出现的问题是如何设置此属性。IDataErrorInfo
Item 索引器验证每个属性,但对于其他属性的验证状态没有任何信息,因此我们需要一种方法来存储每个属性的验证状态,然后仅在 Item
索引器中更改每个特定状态。由于 Item
索引器有一个输入参数声明正在检查的属性的名称,我认为最好的选择是使用一个 Dictionary
,其中 Key
可以是属性的名称,Value
是一个布尔值,表示该属性的状态。因此,在我们的索引器中,我们可以设置正在检查的当前属性的状态,然后搜索 Dictionary
以查看所有属性是否有效,并相应地设置 AllPropertiesValid
。因此,我们需要声明一个 Dictionary<string,bool>
称为 validProperties
,并在构造函数中用三个属性初始化 Dictionary
,如下所示:
private Dictionary<string,bool> validProperties;
public NewProductViewModel(Product newProduct)
{
this.currentProduct = newProduct;
this.validProperties = new Dictionary<string, bool>();
this.validProperties.Add("ProductName", false);
this.validProperties.Add("Height", false);
this.validProperties.Add("Width", false);
}
然后,我们需要实现一个 private
方法,该方法将检查所有属性是否有效,并相应地设置 AllPropertiesValid
:
private void ValidateProperties()
{
foreach(bool isValid in validProperties.Values )
{
if (isValid == false)
{
this.AllPropertiesValid = false;
return;
}
}
this.AllPropertiesValid = true;
}
然后,我们需要在 Item
索引器中设置正确的状态,并调用此方法来设置我们的布尔值,所以我们需要将 Item 索引器更改为这样:
string IDataErrorInfo.this[string propertyName]
{
get
{
string error = (currentContact as IDataErrorInfo)[propertyName];
validProperties[propertyName] = String.IsNullOrEmpty(error) ? true : false;
ValidateProperties();
CommandManager.InvalidateRequerySuggested();
return error;
}
}
完成后,我们只需要在 NewProductView
中设置 Binding。打开NewProductView.xaml文件,并将 Button 声明更改为这样:
<!-- SAVE BUTTON -->
<Button Grid.Row="3" Grid.Column="1" Command="{Binding Path=SaveCommand}" Content="_Save"
HorizontalAlignment="Right" Margin="4,2" MinWidth="60"
IsEnabled="{Binding Path=AllPropertiesValid}"/>
这样我们就可以开始了。按 F5,您应该会看到按钮仅在所有属性有效时才启用。一旦某个属性变得无效,按钮就会禁用。
告知用户
有多种方法可以告知用户正在发生的事情,确切的实现方式显然取决于公司标准等。最常见的方法之一是在用户将鼠标悬停在 TextBox
上时显示一个 ToolTip,告知他们错误消息。另一种方法是在 TextBox
下方显示标签,显示错误消息。知道 TextBox
具有 Validation.HasError
属性,并且该属性返回一个验证错误列表,其中每个 ErrorContent
属性返回特定的错误消息,实现一个绑定到 HasError
属性并显示 ToolTip 的 Trigger(当 TextBox
有错误时)是相对简单的。
在 Grid
中,添加一个 Grid.Resources
标签(您可以将其定义在 Window.Resources
下,甚至在 Application.Resources
下,以确保应用程序范围内的遵守)。在 Grid 资源下,定义一个 Style
,其 TargetType
为 TextBox
。在 Style.Triggers
下,添加一个 Trigger
,其 Property
设置为 Validation.HasError
,并在属性返回 true
时触发。在此 Trigger
中添加一个 Setter
,其中 Property
设置为 ToolTip
,并为 Value
属性创建一个 Binding
,该 Binding 绑定到自身(TextBox
),并且 Path
返回列表中第一个错误的 ErrorContent
,如下所示:
<Grid.Resources>
<Style TargetType="{x:Type TextBox}">
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="true">
<Setter Property="ToolTip"
Value="{Binding RelativeSource={RelativeSource Self},
Path=(Validation.Errors)[0].ErrorContent}"/>
</Trigger>
</Style.Triggers>
</Style>
</Grid.Resources>
然后,我们需要为 Sliders 创建另一个 Style
,我以完全相同的方式实现了它。这样就完成了。一个用户友好的表单,用户可以在其中添加新 Product
,并且只有在她在所有字段中输入有效数据后才能保存信息。如果任何字段不正确,她将以一种相对不显眼的方式被告知原因,并且可以轻松地纠正任何错误。
结论
IDataErrorInfo
接口在数据输入验证中确实是一个有用的接口,它使得实现非常简单。我希望您在阅读本教程时和我写它时一样开心。.NET 真棒!!!
历史
- 2010年8月3日:初始发布