理解和使用WPF 验证机制
首先建立一个demo用以学习和实验WPF Data Validation机制。创建一个数据实体类:
public class Employee
{
public string Name { get; set; }
public int? Age { get; set; }
}
创建一个用户控件或者窗口,用以输入Name和Age,如下:
<Grid Width="400" Height="200">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBlock Text="User Name:"VerticalAlignment="Center"/>
<TextBox Grid.Column="1" x:Name="tb" Height="30">
<TextBox.Text>
<Binding Path="Name">
<Binding.ValidationRules>
<local:NotNullValidationRule/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
<TextBlock Text="Age" Grid.Row="1"VerticalAlignment="Center"/>
<TextBox Grid.Row="1"Grid.Column="1" Height="30">
<TextBox.Text>
<Binding Path="Age">
<Binding.ValidationRules>
<local:NotNullValidationRule/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
<Button Grid.Row="2"Grid.Column="1" Content="Save" Width="60" Height="23"/>
</Grid>
在后置代码中连接数据上下文,如下:
void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
Employee p = new Employee();
DataContext = p;
}
要运行此demo还需要创建一NotNullValidationRule 类,数据验证的工作正是在此类中完成,此类的代码如下:
public class NotNullValidationRule : ValidationRule
{
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
if (value == null || string.IsNullOrWhiteSpace(value as string))
{
return new ValidationResult(false, "value cannot benull");
}
return ValidationResult.ValidResult;
}
}
可以看到,此类必须从ValidationRule派生,然后重写Validate方法,参数value就是设置数据绑定的控件属性所表示的值。在我们的示例中,value就是TextBox.Text的值,也就是用户输入的文本。验证逻辑非常简单,不再赘述。验证完成后需要返回一个ValidationResult 对象表示验证结果,如果验证的数据无效,就需要为验证结果指定一个字符串作为错误信息反馈给用户。
好了,现在demo可以运行了,在表示Name的文本框中输入一些字符,然后删除所有刚才输入的字符,最后按下tab键让焦点离开改文本框。可以看见文本框出现了一个红色边框。显然,红色边框不是很美观,而且验证错误信息也没有通过Tooltip的方式呈现出来,记得以前是可以的,现在用的是.NetFramework 4.5,是没有Tooltip提示的。下面我们就自定义一下验证出错时的UI显示。
验证错误的显示样式是由Validation.ErrorTemplate来控制的,这是一个关联属性,类型是ControlTemplate,下面是一个验证错误控件模板的示例:
<ControlTemplate x:Key="ErrorTempalte">
<StackPanel Orientation="Horizontal">
<StackPanel.Triggers>
<EventTrigger RoutedEvent="FrameworkElement.Loaded" SourceName="bd">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="bd"Storyboard.TargetProperty="RenderTransform.ScaleX" From="0" To="1" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</StackPanel.Triggers>
<AdornedElementPlaceholder/>
<Border CornerRadius="3"BorderBrush="DarkMagenta" Background="#AAFF0000"BorderThickness="1" Padding="5 2" x:Name="bd">
<Border.RenderTransform>
<ScaleTransform/>
</Border.RenderTransform>
<TextBlock Foreground="White" VerticalAlignment="Center" Text="{BindingPath=/ErrorContent}"/>
</Border>
</StackPanel>
</ControlTemplate>
把以上模板应用到TextBox,
<TextBox Validation.ErrorTemplate="{StaticResource ErrorTempalte}">…
再次运行demo,当焦点离开文本框后,会有一个红色的错误提示显示在文本框的右边,而且还有一个X放心的放大动画,使界面变得有一些动感了。对于这个ControlTemplate,需要记住的是,模板根元素的DataContext是一个ValidationError对象的列表,而在上面的模板中,我们将列表中第一个对象的ErrorContent显示了出来(实际上,当一个绑定有多个ValidationRule的时候,WPF绑定引擎一旦发现有一个ValidationRule验证失败,那么后续的ValidationRule将不会被执行),ErrorContent属性就是我们在构造函数中指定的错误信息。另外一个需要注意的是,这个模板中的界面元素是显示在AdornerLayer中的;要将显示错误的界面元素显示在被验证控件的周围,需要AdornedElementPlaceholder元素的支持,该元素是一个占位符,跟被验证控件有着同样的位置和尺寸;而且还可以通过此类的AdornedElement属性来访问被验证控件。
解决了错误信息显示的问题,还有下一个问题等着我们解决;假设有一个添加用户的界面,而且所有字段都是必须的,用户在填写了部分信息后,点击了保存按钮;如果我们直接保存,那么大部分情况下会出错,因为还存在无效值。但是你可能会想,我们已经为每个绑定设置了ValidationRule,为什么这些ValidationRule没有起作用呢?这是因为对于Binding来说,如果Target值没有变化,那么是不会引发验证的;而且如果设置了绑定的UpdateSourceTrigger="LostFocus" 即使文本框的值变了,但是在文本框焦点离开之前,也是不会引发验证。更糟糕的是,用户根本就没有对文本框做任何输入,所以也就谈不上焦点离开。所以这就要求我们在用户点击保存按钮的时候,手动引发所有验证操作,如果存在任何严重错误,那么验证错误就会像之前那样滑动出来。所谓手动引发,指的是我们自己写代码去引发。可以想象,这不是一个轻松的工作,首先需要获取所有的BindingExpressionBase对象,然后对每个绑定对象调用:
BindingExpressionBase exp = tb.GetBindingExpression(TextBox.TextProperty);
exp.ValidateWithoutUpdate();
显然,这种方案是让人无法忍受的。VaildationRule.ValidateOnTargetUpdated属性或许会给我们带来解决问题的曙光。修改NotNullValidationRule类如下:
public NotNullValidationRule()
{
this.ValidatesOnTargetUpdated = true;
}
我们给NotNullValidationRule 添加了一个构造函数,其中把ValidatesOnTargetUpdated设置为true;意思是,当Target被更新的时候执行验证逻辑。当我们设置DataContext的时候,Target将会被更新。所以当我们做了这个修改后,重新运行demo,你会发现,窗口一出现,所有的验证错误就被立刻显示了出来。这显然不是我们想要的,我们希望的逻辑是,当用户第一次进入编辑界面,即使数据对象是无效的,也不要显示验证错误,只有当用户点击了保存按钮或者焦点离开了某个绑定了数据对象的控件的时候才显示验证错误。由此我们得到的结论是:这个属性也许在某个时候会有用处,但是现在对我们来说却是无用的。
另一个解决此问题的方案是使用BindingGroup类,改类型是在.NetFramework 3.5 SP1引入的。BindingGroup能够将一组绑定集合起来,整体更新。如下所示:
<Grid Width="400" Height="200">
<Grid.BindingGroup>
<BindingGroup x:Name="bg"/>
</Grid.BindingGroup>…
如果设置了Grid的BindingGroup属性,那么Grid里面控件的所有绑定都会属于同一个BindingGroup。实际上属于不属于同一个BindingGroup,主要是看Binding对象的BindingGroupName属性是否和BindingGroup的Name相同,如果都没有设置,自然就是相同的了。
添加了BindingGroup之后,我们需要在保存按钮的事件处理方法中,验证所有属于BindingGroup的绑定,如下:
private void Button_Click(object sender, RoutedEventArgs e)
{
bool isValid = bg.ValidateWithoutUpdate();
if (isValid)
{
//Saveyour employee
}
}
试着运行demo,你会发现,点击了保存按钮后,界面没有任何响应,而且查看isValid的值,居然为true。但是,假如尝试着在Name和Age的文本框里键入一些文本,再删除这些键入的文本,然后点击保存按钮,验证就会起作用。根本的原因是:BindingGroup总是认为初始值是有效的值,不需要再验证。这在编辑一个用户的时候是有用的,但是因为我们是在添加一个对象,我们的初始值都是无效的,所以BindingGroup这一特性使得我们无法完成验证任务。BindingGroup还有一个UpdateSources方法,可以将所有属于BindingGroup的BindingExpressionBase执行UpdateSource方法;但是这个方法有着同样的缺陷,只对那些改变过的属性进行更新。
而且BindingGroup还有另外一个特性也非常令人生厌,当你应用了BindingGroup后,它会改变属于BindingGroup的Binding的UpdateSourceTrigger属性的默认值为Explicit,这就意味着,当焦点离开的时候,验证将不会触发;除非显式调用每个BindingExpressionBase的UpdateSource方法,而这也是我们要使用的方法。但是显示指定每个Binding的UpdateSourceTrigger会覆盖这个行为,BindingGroup会尊重你的设定。所以这基本上完成了我们的任务,改动的代码如下:
<Grid Width="400" Height="200" x:Name="grid">
<Grid.BindingGroup>
<BindingGroup x:Name="bg"/>…
<Binding Path="Name"UpdateSourceTrigger="LostFocus">…
<Binding Path="Age"UpdateSourceTrigger="LostFocus">…
public MainWindow()
{
InitializeComponent();
Employee p = new Employee();
DataContext = p;
}
保存按钮事件处理方法:
private void Button_Click(object sender, RoutedEventArgs e)
{
foreach (var item in bg.BindingExpressions)
{
item.UpdateSource();
}
}
可以看到,问题的解决方案还是很简单的。问题基本上解决了,但是对于仔细查看demo就会发现,当界面显示以后,将焦点转移到Name文本框上,然后再离开,这时候没有发生验证。这是可以理解的,因为文本框里的数据没有发生变化,既然之前没有显示验证错误,那么现在也不应该显示。如果一定要实现这个需求,就应该做出如下变化:
public MainWindow()
{
InitializeComponent();
Employee p = new Employee();
DataContext = p;
Loaded += MainWindow_Loaded;
LostFocus += MainWindow_LostFocus;
}
void MainWindow_LostFocus(object sender, RoutedEventArgs e)
{
foreach (var item in bg.BindingExpressions)
{
if (item.Target == e.Source)
{
item.UpdateSource();
break;
}
}
}
因为LostFocus是一个路由事件,所以在主窗口中的LostFocus事件处理方法能够处理所有包含控件的LostFocus事件。这方法里面,我们获取了当前失去焦点的控件的绑定对象,对其进行手动更新。注意到,我们显式调用的UpdateSource,所以在Xaml中就不需要再设定UpdateSourceTrigger="LostFocus"了。
如果把上面的代码封装到一个关联属性里,用起来会更方便,如下:
public static class FEExtension
{
public static bool GetValidateOnLostFocus(DependencyObject obj)
{
return (bool)obj.GetValue(ValidateOnLostFocusProperty);
}
public static void SetValidateOnLostFocus(DependencyObject obj, bool value)
{
obj.SetValue(ValidateOnLostFocusProperty, value);
}
public static readonly DependencyProperty ValidateOnLostFocusProperty =
DependencyProperty.RegisterAttached("ValidateOnLostFocus", typeof(bool), typeof(FEExtension),
new FrameworkPropertyMetadata(false, OnValidateOnLostFocusChanged));
private static void OnValidateOnLostFocusChanged(object sender,DependencyPropertyChangedEventArgs e)
{
var fe = sender as FrameworkElement;
if (e.NewValue.Equals(true))
{
fe.LostFocus += fe_LostFocus;
}
else
{
fe.LostFocus -= fe_LostFocus;
}
}
static void fe_LostFocus(object sender, RoutedEventArgs e)
{
var fe = sender as FrameworkElement;
foreach (var item in fe.BindingGroup.BindingExpressions)
{
if (item.Target == e.Source)
{
item.UpdateSource();
break;
}
}
}
}
使用方法如下:
<Grid Width="400" Height="200" x:Name="grid" local:FEExtension.ValidateOnLostFocus="true">
深入理解ValidationRule
假设我们有一个毕业时间属性,如下:
public class Employee
{
public string Name { get; set; }
public int? Age { get; set; }
public DateTime GraduationDate { get; set; }
}
该属性不能大于当前时间,所以需要创建一个验证类如下:
class PassedDateTimeValidationRule : ValidationRule
{
public PassedDateTimeValidationRule()
{
ValidationStep= ValidationStep.ConvertedProposedValue;
}
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
DateTime dt = (DateTime)value;
if (dt < DateTime.Now)
{
return ValidationResult.ValidResult;
}
return new ValidationResult(false, "Cannot select afuture date");
}
}
验证逻辑非常简单,需要注意的是构造函数中把ValidationStep设置为ValidationStep.ConvertedProposedValue,这表示我们得到的参数value的值已经被Converter转换过了,如果没有Converter,WPF绑定引擎至少会帮你做一个类型转换。所以在上例中,我们的value参数已经不是字符串了,而是一个DateTime对象。
ValidationStep是一个枚举,每个值的解释如下:
public enum ValidationStep
{
// 使用原始值,对于TextBox来说,就是Text属性表示的字符串
RawProposedValue = 0,
//
// 使用转换后的值,将原始值经过类型转换或者经过Binding的Converter转换过的值,这个值
// 还没有被更新到我们的数据对象里面。
ConvertedProposedValue = 1,
//
// 使用更新过的值,也就是说,我们的数据对象的属性值已经被更新了,
// 然后用这个更新的值再做数据验证
UpdatedValue = 2,
//
// 使用提交后的值,验证将会发生在调用了BindingGroup.CommitEdit之后。
//大部分情况下我们都不会再数据提交了再做验证,所以使用该值的情况应该非常少见。
CommittedValue = 3,
}
对于ValidationStep.UpdatedValue,value参数会有所不同,value参数实际上是包含当前ValidationRule对象的BindingExpressionBase对象。说包含有些不恰当,因为是Binding对象包含了ValidationRule。但是BindingExpressionBase和Binding之间是通过public属性相互引用的。如果当前的ValidationRule是属于BindingGroup的,那么value参数就是BindingGroup对象,你可以对其进行转换,这样就可以方法BindingGroup公开的任何方法和属性了。
深入理解BindingGroup
首先来看看BindingGroup提供了那些功能:
public class BindingGroup : DependencyObject
{
//得到所有属于当前BindingGroup的BindingExpressionBase对象
public Collection<BindingExpressionBase> BindingExpressions { get; }
//获取当前BindingGroup的所有数据上下文对象,在本文中只有一个,就是Employee对象
public IList Items { get; }
//该集合将在UpdateSources,ValidateWithoutUpdate,CommitEdit被调用的时候进行验证调用
public Collection<ValidationRule> ValidationRules { get; }
//下面这三个方法对应IEditableObject的方法,调用下面的方法后,如果你的对象实现了IEditableObject接口,那么你的方法将会被调用
public void BeginEdit();
public void CancelEdit();
public bool CommitEdit();
//下面两个方法前面已经介绍过了
public bool UpdateSources();
public boolValidateWithoutUpdate();
}
BindingGroup提供了很多功能,但是确有一个缺陷。这就是我前面提到的,如果文本框里的值没有变化,即使是无效的,它也不会再次验证,也就是说,他会假定初始数据都是有效的,在添加一个新的实体的时候,就无法完成验证数据的功能。
前面说到,我们可以单独调用每个binding对象的Update方法,但是,当你这么做了之后,BindingGroup本身的ValidationRules就得不到执行了。
一个比较Hack的方法是,将每个绑定的NeedsVlidation属性设置为true,如下:
foreach (var item in bg.BindingExpressions)
{
typeof(BindingExpressionBase)
.GetProperty("NeedsValidation",System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
.SetValue(item, true);
}
bg.ValidateWithoutUpdate();
如此,ValidateWithoutUpdate方法就显的正常了。因为用反射更改了BindingExpressionBase的内部属性,所以说这个方法有点Hack。