(六)深入浅出WPF——深入浅出话 Binding(3)

190 阅读9分钟

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 时,最重要的事情就是准确地设置它的源和路径。