6.4 Binding 对数据的转换与校验
前面我们已经知道,Binding的作用就是架在Source与Target之间的桥梁,数据可以在这座桥梁的帮助下来流通。就像现实世界中的桥梁会设置一些关卡进行安检一样,Binding 这座桥上也可以设置关卡对数据的有效性进行检验,不仅如此,当 Binding 两端要求使用不同的数据类型时,我们还可以为数据设置转换器。
Binding 用于数据校验的关卡是它的关卡是它的 ValidattionRules 属性,用于数据类型转换的关卡是它的 Convert 属性。下面就让我们来学习使用它们。
6.4.1 Binding 的数据校验
Binding 的 ValidationRules 属性类型是 Collection<ValidationRule>,从它的名称和数据类型可以得知可以为每个 Binding 设置多个数据校验条件,每个条件是一个 ValidationRule 类型对象。ValidationRule 类是一个抽象类,在使用的时候我们需要创建它的派生类并实现它的 Validate 方法。Validate 方法的返回值是 ValidationResult 类型对象,如果校验通过,就把 ValidationResult 对象的 IsValid 属性设为 true,反之,需要把 IsValid 属性设为 false 并为其 ErrorContent 属性设置一个合适的消息内容(一般是个字符串)。
下面这个程序是在UI上绘制一个TextBox和一个Slider,然后在后台C#代码里使用Binding把它们关联起来——以Slider为源、TextBox为目标。Slider的取值范围是0到100,也就是说,我们需要校验TextBox里输入的值是不是在0到100这个范围内。
<StackPanel>
<TextBox x:Name="TextBox" Margin="5" />
<Slider x:Name="Slider" Minimum="0" Maximum="100" Margin="5"/>
</StackPanel>
为了进行校验,需要准备一个ValidationRule的派生类:
public MainWindow()
{
InitializeComponent();
Binding binding = new Binding("Value") { Source = this.Slider };
binding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;
RangeValidationRule rvr = new RangeValidationRule();
binding.ValidationRules.Add(rvr);
this.TextBox.SetBinding(TextBox.TextProperty, binding);
}
完成后运行程序,当输入 0 到 100 之间的值时程序正常显示,但输入这个区间之外的值或不能被解析的值时 TextBox 会显示红色边框,表示值是错误的,不能把它传递给 Source。
Binding 进行校验时的默认行为是认为来自 Source 的数据总是正确的,只有来自 Target 的数据(因为 Target 多为 UI 控件,所以等价于用户输入的数据)才有可能有问题,为了不让有问题的数据污染 Source 所以需要校验。换句话说,Binding 只在 Target 被外部方法更新时校验数据,而来自 Binding 的 Source 数据更新 Target 时是不会进行校验的。如果想改变这种行为,或者说当来自 Source 的数据也有可能出问题时,我们就需要将校验条件的 ValidatesOnTargetUpdated 属性设置为 true。
先把slider1的取值范围由0到100改成-10到110:
<Slider x:Name="Slider" Minimum="-10" Maximum="110" Margin="5"/>
然后把设置Binding的代码改为:
rvr.ValidatesOnTargetUpdated = true;
你可能会想:当校验错误的时候Validate方法返回的ValidationResult对象携带着一条错误消息,如何显示这条消息呢?想要做到这一点,需要用到后面才会详细讲解的知识——路由时间(Routed Event)。
首先,在创建 Binding 时要把 Binding 对象的 NotifyOnValidateError 属性设置为 true,这样,当数据校验失败的时候 Binding 会像报警器一样发出一个信号,这个信号会以 Binding 对象的 Target 为起点在 UI 元素树上传播。信号每到达一个结点,如果这个结点上设置有对这种信号的监听器(事件处理器),那么这个监听器就会被触发用以处理这个信号。信号处理完后,程序员还可以选择是让信号继续向下传播还是就此终止——这就是路由事件,信号在 UI 元素树上的传递过程就称为路由(Route)。
建立 Binding 的代码如下:
public MainWindow()
{
InitializeComponent();
Binding binding = new Binding("Value") { Source = this.Slider };
binding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;
RangeValidationRule rvr = new RangeValidationRule();
rvr.ValidatesOnTargetUpdated = true;
binding.ValidationRules.Add(rvr);
binding.NotifyOnValidationError = true;
this.TextBox.SetBinding(TextBox.TextProperty, binding);
this.TextBox.AddHandler(Validation.ErrorEvent, new RoutedEventHandler(this.ValidationError));
}
用于侦听校验错误事件的事件处理器如下:
private void ValidationError(object sender, RoutedEventArgs e)
{
if (Validation.GetErrors(this.TextBox).Count > 0)
{
this.TextBox.ToolTip = Validation.GetErrors(this.TextBox)[0].ToString();
}
}
程序运行时如果校验失败,TextBox的ToolTip就会提示用户
6.4.2 Binding 的数据转换
不知道大家有没有注意到,Slider 的 Value 属性是 double 类型值、TextBox 的 Text 属性是 string 类型值,在 C# 这种强类型(strong-typed)语言中却可以往来自如,这是怎么回事呢?
原来,Binding 还有另外一种机制称为数据转换(Data Convert),当 Source 端 Path 所关联的数据与 Target 端目标属性数据类型不一致时,我们可以添加数据转换器(Data Converter)。上面提到的问题实际上是 double 类型与 string 类型互相转换的问题,处理起来比较简单,所以 WPF 类库就自动替我们做了。但有些类型之间的转换就不是 WPF 能替我们做的了,例如下面这些情况。
- Source 里的数据是 Y、N 和 X 三个值(可能是 char 类型、string类型或自定义枚举类型),UI 上对应的是 CheckBox 控件,需要把这三个值映射为它的 IsChecked 属性值(bool?类型)。
- 当 TextBox 已经输入了文字时用于登录的 Button 才会出现,这是 string 类型与 Visibility 枚举类型或 bool 类型之间的转换(Binding 的 Mode 将是 OneWay)。
- Source 里面的数据可能是 Male 或 Famale(string 或枚举),UI 上对应的是用于显示头像的 Image 控件,这时候需要把 Source 里的值转换成对应的头像图片 URI(亦是 OneWay)。当遇到这些情况时,我们只能自己动手写 Converter,方法是创建一个类并让这个类实习那 IValueConverter 接口。IValueConverter 接口定义如下:
public interface IValueConverter
{
object Convert(object value, Type targetType, object parameter, CultureInfo culture);
object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture);
}
当数据从 Binding 的 Source 流向 Target 时,Convert 方法将被调用;反之,ConvertBack 方法将被调用。这两个方法的参数列表一模一样:第一个参数为 object,最大限度地保证了 Converter 的重用性(可以在方法体内对实际类型进行判断);第二个参数用于确定方法的返回类型(个人认为形参名字叫 outputType 比 targetType 要好,可以避免与 Binding 的 Target 混淆);第三个参数用于把额外的信息传入参数,若需要传递多个信息则可以把信息放入一个集合对象来传入方法。
Binding 对象的 Mode 属性会影响到这两个方法的调用。如果 Mode 为 TwoWay 或 Default 行为与 TwoWay一致则两个方法都有可能被调用;如果 Mode 为 OneWay 或 Default 行为与 OneWay 一致则只有 Convert 方法会被调用:其他情况同理。
下面这个例子是一个Converter的综合实例,程序的用途是在列表里向玩家显示一些军用飞机的状态。
首先创建几个自定义数据类型:
public enum Category
{
Bomber,
Fighter
}
public enum State
{
Available,
Locked,
Unkown
}
public class Plane
{
public Category Category { get; set; }
public string Name { get; set; } = string.Empty;
public State State { get; set; }
}
在UI里Plane的Category属性被映射为轰炸机或战斗机的图标,这两个图标我已经加入了项目如图6-34所示。
(图6-34)
同时,飞机的 State 属性在 UI 里被映射为 CheckBox。因为存在以上两个映射关系,我们需要提供了两个 Converter:一个是由 Category 类型单向转换为 string 类型(XAML 编译器能把 stirng 对象解析为图片资源),另一个是在 State 与 bool? 类型之间的双向转换,代码如下:
public class CategoryToSourceConverter: IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
Category category = (Category)value;
switch (category)
{
case Category.Bomber:
return @"\Icons\Bomber.png";
case Category.Fighter:
return @"\Icons\Fighter.png";
default:
return null;
}
}
// 不会被调用
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
public class StateToNullableBoolConverter: IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
State state = (State)value;
switch (state)
{
case State.Locked:
return false;
case State.Available:
return true;
case State.Unkown
default:
return null;
}
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
bool? nb = (bool?)value;
switch (nb)
{
case true :
return State.Available;
case false:
return State.Locked;
case null:
default:
return State.Unkown;
}
}
}
下面我们看看如何在 XAML 里消费这些Converter。XAML代码的框架如下:
<Window x:Class="DataBinding.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converter="clr-namespace:DataBinding.Convertor"
Title="MainWindow" Height="210" Width="210">
<Window.Resources>
<converter:CategoryToSourceConverter x:Key="cts"/>
<converter:StateToNullableBoolConverter x:Key="stnb"/>
</Window.Resources>
<StackPanel>
<ListBox x:Name="ListBoxPlan" Height="160" Margin="5" />
<Button x:Name="ButtonLoad" Content="Load" Height="25" Margin="5,0" Click="ButtonLoad_OnClick" />
<Button x:Name="ButtonSave" Content="Save" Height="25" Margin="5,0" Click="ButtonSave_OnClick" />
</StackPanel>
</Window>
XAML 代码中已经添加了对程序集的引用并映射为名称空间 local,同时,以资源的形式创建了两个 Converter 的实例。名为 ListBoxPlane 的 ListBox 控件是我们工作的重点,需要为它添加用于显示数据的 DataTemplate。我们把焦点集中在 ListBox 控件的 ItemTemplate 属性上:
<ListBox x:Name="ListBoxPlan" Height="160" Margin="5">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Image Width="20" Height="20"
Source="{Binding Path=Category, Converter={StaticResource cts}}" />
<TextBlock Text="{Binding Path=Name}" Width="60" Margin="80,0" />
<CheckBox IsThreeState="True" IsChecked="{Binding Path=State, Converter={StaticResource stnb}}"></CheckBox>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
Load 按钮的 Click 事件处理器负责把一组飞机的数据赋值给 ListBox 的 ItemsSource 属性,Save 按钮的 Click 事件处理器负责把用户更改过的数据写入文件:
private void ButtonLoad_OnClick(object sender, RoutedEventArgs e)
{
List<Plane> planes = new List<Plane>()
{
new Plane() { Category = Category.Bomber, Name = "B-1", State = State.Unkown },
new Plane() { Category = Category.Bomber, Name = "B-2", State = State.Unkown },
new Plane() { Category = Category.Bomber, Name = "F-22", State = State.Unkown },
new Plane() { Category = Category.Bomber, Name = "Su-47", State = State.Unkown },
new Plane() { Category = Category.Bomber, Name = "B-52", State = State.Unkown },
new Plane() { Category = Category.Bomber, Name = "J-10", State = State.Unkown },
};
this.ListBoxPlane.ItemsSource = planes;
}
private void ButtonSave_OnClick(object sender, RoutedEventArgs e)
{
StringBuilder sb = new StringBuilder();
foreach (Plane p in ListBoxPlane.Items)
{
sb.AppendLine(string.Format("CateGory={0}, Name={1}, State={2}", p.Category, p.Name, p.State));
}
File.WriteAllText(@"D\PlaneList.txt", sb.ToString());
}
6.5 MultiBinding
有时候 UI 要需要显示的信息你由不止一个数据来源决定,这时候就需要使用 MultiBinding,即多路 Binding。MultiBinding 与 Binding 一样均以 BindingBase 为基类,也就是说,凡是能使用 Binding 对象的场合都能使用 MultiBinding。MultiBinding 具有一个名为 Bindings 的属性,其类型是 Collection<BindingBase>,通过捅咕这个属性 MultiBinding 把一组 Binding 对象聚合起来,处在这个集合中的 Binding 对象可以拥有自己的数据校验与转换机制,它们汇集起来将共同决定传往 MultiBinding 目标的数据。
考虑这样一个需求,有一个用于新用户注册的 UI(包含4个 TextBox 和一个 Button),还有如下一些限定:
- 第一、二个 TextBox 输入用户名,要求内容一致。
- 第三、四个 TextBox 输入用户 E-mail,要求内容一致。
- 当 TextBox 的内容全部符合要求的时候,Button 可用。
此 UI 的 XAML 代码如下:
<StackPanel>
<TextBox x:Name="TextBox1" Height="23" Margin="5" />
<TextBox x:Name="TextBox2" Height="23" Margin="5,0" />
<TextBox x:Name="TextBox3" Height="23" Margin="5" />
<TextBox x:Name="TextBox4" Height="23" Margin="5,0" />
<Button x:Name="Button" Content="Submit" Width="80" Margin="5" />
</StackPanel>
然后把用于设置 MultiBinding 的代码写在名为 SetMultiBinding 的方法里并在窗体的构造器中调用:
public MainWindow()
{
InitializeComponent();
SetMultiBinding();
}
private void SetMultiBinding()
{
Binding b1 = new Binding("Text") { Source = this.TextBox1 };
Binding b2 = new Binding("Text") { Source = this.TextBox2 };
Binding b3 = new Binding("Text") { Source = this.TextBox3 };
Binding b4 = new Binding("Text") { Source = this.TextBox4 };
MultiBinding mb = new MultiBinding() { Mode = BindingMode.OneWay };
mb.Bindings.Add(b1);
mb.Bindings.Add(b2);
mb.Bindings.Add(b3);
mb.Bindings.Add(b4);
mb.Converter = new LogonMultiConverter();
this.Button.SetBinding(Button.IsEnabledProperty, mb);
}
这里还有几个点需要注意:
- MultiBinding 对于添加子集 Binding 的顺序是敏感的,因为这个顺序决定了汇集到 Converter 里数据的顺序。
- MultiBinding 的 Converter 实现的是 IMultiValueConverter 接口。
本例的Converter代码如下:
public class LogonMultiBindingConverter: IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (!values.Cast<string>().Any(text => string.IsNullOrEmpty(text))
&& values[0].ToString() == values[1].ToString()
&& values[2].ToString() == values[3].ToString())
{
return true;
}
return false;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
6.6 小结
WPF 的核心理念是变传统的 UI 驱动程序为数据驱动 UI,撑这个理念的基础就是本章讲述的 Data Binding 和与之相关的数据校验与转换。在使用 Binding 时,最重要的事情就是准确地设置它的源和路径。