数据绑定

数据绑定是WPF的核心,也是MVVM开发模式的根基。WPF中,数据可以绑定到对象、控件、资源甚至其他绑定,数据绑定完整支持双向同步、转换、校验等功能,配合依赖属性和变更通知机制,我们的图形界面可以真正做到数据驱动。这篇笔记我们对WPF中的数据绑定用法进行介绍。

Binding 标记扩展

Binding是一个XAML中的标记扩展,它写在XAML的属性值位置,并指定要绑定的内容路径。

<TextBlock Text="{Binding Name}"/>

上面XAML的含义是TextBlock的Text属性绑定到数据源的Name属性上。

绑定数据源

绑定到DataContext内的属性

DataContextFrameworkElement上的依赖属性,它表示元素的数据上下文,它也是最常用的Binding数据源。当绑定没有显式指定源时,WPF会自动沿元素树向上找最近的DataContext,由于DataContext是可继承的依赖属性,我们通常把它设置在窗口级别,窗口里所有绑定都以它为源。

<Window x:Class="Gacfox.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel>
        <TextBlock Text="{Binding Name}"/>
        <TextBlock Text="{Binding Age}"/>
    </StackPanel>
</Window>
using Gacfox.Demo.Model;
using System.Windows;

namespace Gacfox.Demo;

public partial class MainWindow : Window
{
    private readonly Student _student = new() { Name = "张三", Age = 20 };

    public MainWindow()
    {
        InitializeComponent();
        DataContext = _student;
    }
}

DataContext其实是object类型,因此我们可以将自定义的Student变量赋予它,XAML中,我们使用Binding绑定的字段其实就是赋予给DataContext的对象内的属性。

绑定到另一个元素的属性

WPF中,我们也可以使用另一个元素的属性作为绑定数据源。下面例子中,我们没写任何C#代码,直接基于该绑定机制实现了滑块的值实时显示在文本里的功能。

<Window x:Class="Gacfox.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel>
        <Slider x:Name="volumeSlider" Minimum="0" Maximum="100" Value="50"/>
        <TextBlock Text="{Binding ElementName=volumeSlider, Path=Value, StringFormat='音量:{0:0}'}"/>
    </StackPanel>
</Window>

Source 指定绑定源对象

WPF中,绑定允许使用Source直接指定源对象,这种用法常配合StaticResource绑定XAML资源里声明的对象。

<Window x:Class="Gacfox.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <local:Config x:Key="config" Theme="Dark"/>
    </Window.Resources>
    <TextBlock Text="{Binding Source={StaticResource config}, Path=Theme}"/>
</Window>

RelativeSource 相对自身定位源对象

RelativeSource用于相对自身定位源对象,常用于控件模板内部或绑定到父级元素。

下面例子我们将TextBlockText属性绑定到了自身的ActualWidth属性,用于实时获取并显示UI控件自身的实际渲染宽度。

<TextBlock Text="{Binding RelativeSource={RelativeSource Self}, Path=ActualWidth}"/>

下面例子我们向上查找最近的Window,绑定它的标题。

<TextBlock Text="{Binding RelativeSource={RelativeSource AncestorType=Window}, Path=Title}"/>

Path 绑定路径

Path绑定路径指定从源对象上取哪个值,它的语法其实非常灵活,例如:

  • {Binding Path=Name}:绑定Name属性,最基础的写法
  • {Binding Name}Path是默认的隐式属性,Path=可省略
  • {Binding Address.City}:Path支持多级属性,一路向下取值
  • {Binding Students[0].Name}:Path支持索引器,取集合第1项的Name
  • {Binding}:整个Path都省略时,绑定源对象本身

一个常见坑是XAML中,绑定路径是个字符串,因此写错了也不会编译报错,只会在输出窗口打印绑定错误消息,运行界面显示为空。遇到类似问题时,需要看看输出窗口中有没有错误信息。

Mode 绑定模式

绑定的同步方向由Mode属性控制:

模式 方向 典型场景
OneWay 从源到目标 用于只读展示,如TextBlock显示
TwoWay 源和目标之间双向同步 用于编辑场景,如TextBox输入
OneTime 仅绑定建立时同步一次 用于初始化后不再变的数据
OneWayToSource 从目标到源 用于目标属性驱动源更新的特殊场景

不显式指定Mode时,每个依赖属性有自己的默认模式,例如TextBox.Text默认TwoWayTextBlock.Text默认OneWay。当你不确定时建议将Mode显式写出来,这样代码可读性更好。

数据变更通知 INotifyPropertyChanged

WPF数据绑定中,从源到目标的绑定能生效的前提是源能通知自身发生了变化,普通的属性是没有这个功能的,数据源类需要实现INotifyPropertyChanged才能获得该能力,这和Winform中是类似的。

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace Gacfox.Demo.Model;

public class Student : INotifyPropertyChanged
{
    private string? _name;
    public string? Name
    {
        get => _name;
        set
        {
            _name = value;
            OnPropertyChanged();
        }
    }

    private int _age;
    public int Age
    {
        get => _age;
        set
        {
            _age = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

代码中,我们用到了[CallerMemberName],这是使用INotifyPropertyChanged时的一种最佳实践,它能让编译器自动把调用处的属性名填进参数,这样每个属性的setter只需写OnPropertyChanged(),不用手写字符串了。实现通知后,界面上所有绑定到Name的控件都会在属性变化的同时实时刷新。

绑定集合 ObservableCollection

对于集合,为了实现集合增删条目时自动通知界面刷新,.NET中提供了INotifyCollectionChanged接口,不过它用起来要实现大量集合操作的变更通知,这非常繁琐,除非极特殊情况我们不必直接实现它,在.NET中还有一个实现好的ObservableCollection<T>类。下面例子中,我们把学生列表绑定到了ListBox,并支持动态增删。

<Window x:Class="Gacfox.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <ListBox x:Name="studentList" DisplayMemberPath="Name"/>
        <StackPanel Grid.Row="1" Orientation="Horizontal">
            <Button Content="添加" Click="AddButton_Click" Width="80" Margin="0,10,10,0"/>
            <Button Content="删除" Click="RemoveButton_Click" Width="80" Margin="0,10,10,0"/>
        </StackPanel>
    </Grid>
</Window>
using Gacfox.Demo.Model;
using System.Collections.ObjectModel;
using System.Windows;

namespace Gacfox.Demo;

public partial class MainWindow : Window
{
    private readonly ObservableCollection<Student> _students = new()
    {
        new Student { Name = "汤姆", Age = 18 },
        new Student { Name = "杰瑞", Age = 17 },
    };

    public MainWindow()
    {
        InitializeComponent();
        studentList.ItemsSource = _students;
    }

    private void AddButton_Click(object sender, RoutedEventArgs e)
    {
        _students.Add(new Student { Name = "新同学", Age = 18 });
    }

    private void RemoveButton_Click(object sender, RoutedEventArgs e)
    {
        if (studentList.SelectedItem is Student s)
        {
            _students.Remove(s);
        }
    }
}

值转换器 IValueConverter

IValueConverter是绑定管道中的转换器,它能在源和目标之间做值的变换,下面例子中,我们实现了布尔转颜色和布尔转可见性。

using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;

namespace Gacfox.Demo;

public class BoolToBrushConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        => value is true ? Brushes.ForestGreen : Brushes.IndianRed;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => throw new NotSupportedException();
}

public class BoolToVisibilityConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        => value is true ? Visibility.Visible : Visibility.Collapsed;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => value is Visibility.Visible;
}

代码中,Convert方法是源到目标方向的转换,ConvertBack方法是反向的,单向绑定中后者不会被调用。另外parameter参数对应XAML中的ConverterParameter,我们可以在这里传参复用同一个转换器。

转换器是无状态的,它们通常声明为XAML资源,通过Converter属性引用。

<Window x:Class="Gacfox.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <local:BoolToBrushConverter x:Key="boolToBrush"/>
        <local:BoolToVisibilityConverter x:Key="boolToVis"/>
    </Window.Resources>
    <StackPanel Margin="20">
        <CheckBox x:Name="onlineCheck" Content="在线"/>
        <Ellipse Width="20" Height="20" Margin="0,10"
                 Fill="{Binding ElementName=onlineCheck, Path=IsChecked, Converter={StaticResource boolToBrush}}"/>
        <TextBlock Text="详细信息面板"
                   Visibility="{Binding ElementName=onlineCheck, Path=IsChecked, Converter={StaticResource boolToVis}}"/>
    </StackPanel>
</Window>

运行后我们可以看到,勾选复选框,指示灯变绿且信息面板显示,取消勾选则变红隐藏,这些逻辑完全是基于强大的数据绑定机制实现的,没有一行C#代码。

多绑定 MultiBinding

有时一个目标属性依赖多个源,例如“姓名 = 姓 + 名”,MultiBinding可以把多个绑定聚合起来,配合IMultiValueConverterStringFormat输出。

<Window x:Class="Gacfox.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <TextBlock>
        <TextBlock.Text>
            <MultiBinding StringFormat="{}{0} {1}">
                <Binding Path="LastName"/>
                <Binding Path="FirstName"/>
            </MultiBinding>
        </TextBlock.Text>
    </TextBlock>
</Window>

StringFormat通常足以应付拼接类需求,只有当需要真正的计算(如取最大值)时才可能用到IMultiValueConverter

数据校验 INotifyDataErrorInfo

TwoWay绑定中用户输入可能不合法,WPF提供了校验机制,我们可以为数据类实现INotifyDataErrorInfo接口并编写校验逻辑。

using System.Collections;
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace Gacfox.Demo.Model;

public class Student : INotifyPropertyChanged, INotifyDataErrorInfo
{
    private string? _name;
    public string? Name
    {
        get => _name;
        set
        {
            _name = value;
            ValidateName();
            OnPropertyChanged();
        }
    }

    private void ValidateName()
    {
        _errors.Remove(nameof(Name));
        if (string.IsNullOrWhiteSpace(_name))
            _errors[nameof(Name)] = new List<string> { "姓名不能为空" };
        else if (_name.Length > 10)
            _errors[nameof(Name)] = new List<string> { "姓名不能超过10个字符" };
        ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Name)));
    }

    private readonly Dictionary<string, List<string>> _errors = new();

    public bool HasErrors => _errors.Count > 0;

    public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;

    public IEnumerable GetErrors(string? propertyName)
        => propertyName != null && _errors.TryGetValue(propertyName, out var errors)
            ? errors
            : Enumerable.Empty<string>();

    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

绑定侧只需在Binding上添加ValidatesOnNotifyDataErrors=True(默认开启),输入框校验失败时WPF会自动给它画上红色边框。

<Window x:Class="Gacfox.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"/>
    </StackPanel>
</Window>

如果想显示具体错误文字,可以绑定Validation.Errors附加属性,配合控件模板定制错误提示样式,这些内容我们将在后续章节介绍。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。