面向已经了解 WPF 基本控件和依赖属性,准备系统学习数据绑定的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,以便在当前安装了 .NET 9 SDK 的电脑上直接编译。数据绑定机制在 .NET 9 和 .NET 10 中没有变化。
最后更新:2026-10-19

目录

  1. 数据绑定的基本关系
  2. 基于依赖对象的数据绑定
  3. 基于普通数据类型的数据绑定
  4. 基于对象的数据绑定
  5. 基于ADO.NET的数据绑定
  6. 基于Linq的数据绑定

写在前面

这份笔记怎么用

这份笔记有三种阅读方式:

路线 做法 适合情况
直接运行 打开随附示例,逐个切换六个页签 想先看到效果
逐步跟做 从第 1 章开始,在同一个 WPF 项目中添加代码 想亲手敲一遍
按需查阅 直接阅读对应来源类型的章节 已经遇到具体绑定问题

建议至少完成第 1 章,因为后面的所有示例都建立在同一套绑定语法上。

示例项目位置

1
C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfDataBindingDemo

运行方式:

1
2
3
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfDataBindingDemo"
dotnet build
dotnet run

如果使用 Visual Studio:

  1. 打开项目。
  2. 确认启动项目是 WpfDataBindingDemo。
  3. 按 F5 以调试方式启动。
  4. 打开“输出”窗口,把显示来源切换为“调试”。

2026 年的目标框架

新项目如果安装了 .NET 10 SDK,推荐:

1
<TargetFramework>net10.0-windows</TargetFramework>

如果电脑上只有 .NET 9 SDK,就使用:

1
<TargetFramework>net9.0-windows</TargetFramework>

本篇示例已经在 .NET 9 上编译并启动验证。把目标框架改成 .NET 10 后,数据绑定代码不需要修改。

先建立直觉:数据绑定到底是什么

数据绑定是在“目标属性”和“来源属性”之间建立一条同步通道:

1
2
3
4
5
6
7
来源对象的值
|
v
BindingExpression
|
v
目标控件的属性

例如:

1
<TextBlock Text="{Binding EmployeeName}" />

这段 XAML 的含义不是“把 EmployeeName 复制成一段固定文本”,而是:

1
2
3
每当 EmployeeName 变化
就重新读取它
并更新 TextBlock.Text

目标、来源、路径

学习数据绑定时,先分清三个词:

名称 含义 示例
目标 接收值的控件属性 TextBlock.Text
来源 提供值的对象 MainViewModel
路径 来源对象上的属性 EmployeeName

对应写法:

1
Text="{Binding EmployeeName}"

数据绑定的基本关系

图 1:目标属性、来源属性、绑定表达式和数据流向的关系。

可以读成:

1
2
把来源对象上的 EmployeeName
绑定到 TextBlock.Text

来源对象从哪里来?如果没有显式指定 Source,通常来自 DataContext。

DataContext 是什么

DataContext 是 WPF 中非常重要的概念。它是一个可继承的依赖属性,用来给绑定提供默认来源。

示例在窗口上设置了:

1
2
3
<Window.DataContext>
<viewModels:MainViewModel />
</Window.DataContext>

之后子元素的绑定:

1
<TextBlock Text="{Binding Title}" />

会去当前 DataContext 中查找 Title 属性。

可以用下面的关系理解:

1
2
3
4
5
6
7
8
9
10
Window.DataContext = MainViewModel
|
v
子元素没有自己的 DataContext
|
v
继承 Window 的 DataContext
|
v
TextBlock 绑定 Title

一个绑定表达式的关键信息

完整的绑定通常包含:

设置 作用
Source 指定来源对象
ElementName 指定另一个 XAML 元素作为来源
RelativeSource 相对于目标元素查找来源
Path 指定来源上的属性
Mode 指定数据流向
UpdateSourceTrigger 指定什么时候写回来源
Converter 在来源值和目标值之间转换
StringFormat 格式化显示文本

后面六章会反复使用这些设置。

不同数据来源的绑定方式

图 2:六类常见来源及其通知方式和适用场景。

绑定的三种常见流向

模式 方向 典型场景
OneWay 来源到目标 只读展示
TwoWay 来源和目标双向同步 文本框编辑
OneTime 只读取一次 静态配置

还有两种不常用但需要知道的方向:

模式 方向
OneWayToSource 目标到来源
Default 使用目标属性的默认模式

1. 数据绑定的基本关系

本节目标

学完本节,你应该能够:

  • 写出一个最小绑定表达式。
  • 说清目标、来源和路径分别是什么。
  • 使用 ElementName 把两个控件绑定起来。
  • 解释 OneWay、TwoWay 和 OneTime 的区别。
  • 知道 UpdateSourceTrigger 控制什么。

1.1 最小示例:滑块和文本框

在 XAML 中放入一个 Slider 和一个 TextBox:

1
2
3
4
5
6
7
8
9
10
11
12
<StackPanel Margin="30">
<Slider x:Name="ValueSlider"
Minimum="0"
Maximum="100"
Value="35"/>

<TextBox Margin="0,20,0,0"
Text="{Binding ElementName=ValueSlider,
Path=Value,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>

运行后:

  1. 拖动滑块,文本框中的数字会变化。
  2. 在文本框中输入数字,滑块位置也会变化。

这就是最基本的双向绑定。

1.2 拆解绑定表达式

1
2
3
4
Text="{Binding ElementName=ValueSlider,
Path=Value,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"

逐项解释:

设置 值 含义
目标属性 Text 文本框中显示的文字
ElementName ValueSlider 来源是名为 ValueSlider 的元素
Path Value 来源属性是 Slider.Value
Mode TwoWay 双向同步
UpdateSourceTrigger PropertyChanged 目标变化时立即写回来源

1.3 为什么这里不用 DataContext

因为来源已经通过 ElementName 明确指定了:

1
ElementName=ValueSlider

只要绑定中有明确的来源,就不需要再依赖 DataContext。

常见来源写法:

写法 来源
{Binding Name} 当前 DataContext 的 Name
{Binding ElementName=Other, Path=Text} 名为 Other 的元素
{Binding Source={StaticResource Data}, Path=Name} 资源对象
{Binding RelativeSource={RelativeSource Self}, Path=ActualWidth} 目标元素自身

1.4 目标属性必须是依赖属性

绑定目标必须是依赖属性。例如:

1
Text="{Binding EmployeeName}"

TextBlock.Text 是依赖属性,所以可以绑定。

如果把绑定写到普通 CLR 属性上,通常不会得到 WPF 绑定系统的支持。

可以记成:

1
2
目标端:依赖属性
来源端:依赖属性或普通属性都可以

来源端可以是普通 CLR 属性,这是 WPF 数据绑定非常常见的设计。

1.5 OneWay:只从来源流向目标

1
<TextBlock Text="{Binding Title, Mode=OneWay}" />

含义:

1
2
Title 改变 -> TextBlock.Text 改变
TextBlock.Text 改变 -/-> Title 改变

只读显示通常使用 OneWay。

很多目标属性的默认模式就是 OneWay,所以有时可以省略。

1.6 TwoWay:双向同步

1
<TextBox Text="{Binding Title, Mode=TwoWay}" />

含义:

1
2
Title 改变 -> TextBox.Text 改变
TextBox.Text 改变 -> Title 改变

编辑类控件经常使用双向绑定,例如:

  • TextBox.Text
  • CheckBox.IsChecked
  • Slider.Value
  • ComboBox.SelectedItem

1.7 OneTime:只读取一次

1
<TextBlock Text="{Binding Title, Mode=OneTime}" />

绑定只在初始化时读取一次来源值。之后来源变化不会继续更新目标。

适合:

  • 启动后不会变化的配置
  • 一次性标题
  • 性能敏感且确定不会变化的静态内容

不要把需要实时更新的内容写成 OneTime,否则很容易出现“值明明变了,界面不动”的问题。

1.8 UpdateSourceTrigger 控制写回时机

对于双向绑定,目标变化后什么时候写回来源,由 UpdateSourceTrigger 控制:

值 含义 适合场景
PropertyChanged 目标属性每次变化就写回 实时搜索、即时预览
LostFocus 目标失去焦点时写回 普通表单
Explicit 调用 UpdateSource() 时写回 需要手动提交
Default 使用目标属性的默认行为 通常不用手动指定

例如:

1
2
3
<TextBox Text="{Binding Title,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}" />

每输入一个字符,来源属性就会更新。

如果使用 LostFocus,只有离开文本框后才更新。

1.9 BindingExpression 是什么

同一个 Binding 对象可以被多个目标共享,但每个目标与来源之间的实际连接叫 BindingExpression。

可以简单理解为:

1
2
Binding:描述“怎样绑定”
BindingExpression:某个目标元素上的实际绑定实例

通常写界面时不需要直接操作它。排查绑定错误或手动调用 UpdateSource 时可能会用到。

1.10 绑定失败时怎么办

绑定路径写错时,WPF 通常不会让程序崩溃,而是:

  1. 在调试输出中记录绑定错误。
  2. 目标属性可能保持默认值或显示为空。

例如:

1
<TextBlock Text="{Binding EmplyeeName}" />

这里把 EmployeeName 拼错了。程序可能照常运行,但文本为空。

排查顺序:

  1. 查看输出窗口的 System.Windows.Data Error。
  2. 检查 Path 拼写。
  3. 检查 DataContext 是否正确。
  4. 检查属性是否是 public。
  5. 检查来源对象是否真的包含这个属性。

1.11 本节小结

问题 答案
绑定由什么组成 目标、来源、路径
目标属性要求 依赖属性
来源属性要求 依赖属性或普通属性
双向绑定怎么写 Mode=TwoWay
即时写回怎么写 UpdateSourceTrigger=PropertyChanged
路径写错会崩溃吗 通常不会,但绑定会失败

小练习

  1. 把滑块示例改成 OneWay,观察从文本框输入时滑块是否变化。
  2. 把 UpdateSourceTrigger 改成 LostFocus,观察输入过程中和离开文本框后的区别。
  3. 故意把 Path=Value 改成 Path=Valu,查看输出窗口的错误信息。

2. 基于依赖对象的数据绑定

本节目标

理解依赖对象作为绑定来源时的行为,并能够:

  • 定义一个 DependencyObject 来源类。
  • 用依赖属性保存绑定来源值。
  • 在 XAML 中把控件绑定到依赖对象。
  • 解释为什么依赖属性天然适合作为绑定来源。
  • 知道依赖对象来源适合什么场景。

2.1 什么是依赖对象

DependencyObject 是 WPF 属性系统的基础类型。依赖属性必须注册在依赖对象上。

常见依赖对象包括:

  • Window
  • Grid
  • TextBox
  • Button
  • 自定义控件

也可以自己定义:

1
2
3
public class BindingSourceObject : DependencyObject
{
}

2.2 依赖属性为什么适合做来源

依赖属性由 WPF 属性系统管理,变化通知是属性系统能力的一部分。绑定读取依赖属性时,可以直接接入这套通知机制。

对比普通属性:

来源属性类型 变化通知
依赖属性 属性系统原生支持
普通属性 通常要实现 INotifyPropertyChanged

这就是为什么控件属性作为绑定来源时通常不需要额外写通知代码。

2.3 定义绑定来源对象

新建 DependencyObjects/BindingSourceObject.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.Windows;
using System.Windows.Media;

namespace WpfDataBindingDemo.DependencyObjects;

public class BindingSourceObject : DependencyObject
{
public static readonly DependencyProperty DisplayNameProperty =
DependencyProperty.Register(
nameof(DisplayName),
typeof(string),
typeof(BindingSourceObject),
new FrameworkPropertyMetadata(
"依赖对象来源",
FrameworkPropertyMetadataOptions.AffectsRender));

public string DisplayName
{
get => (string)GetValue(DisplayNameProperty);
set => SetValue(DisplayNameProperty, value);
}

public static readonly DependencyProperty AccentBrushProperty =
DependencyProperty.Register(
nameof(AccentBrush),
typeof(Brush),
typeof(BindingSourceObject),
new FrameworkPropertyMetadata(
Brushes.DodgerBlue,
FrameworkPropertyMetadataOptions.AffectsRender));

public Brush AccentBrush
{
get => (Brush)GetValue(AccentBrushProperty);
set => SetValue(AccentBrushProperty, value);
}
}

这里有两个依赖属性:

属性 类型 用途
DisplayName string 显示名称
AccentBrush Brush 强调色

2.4 在窗口代码中创建来源对象

在 MainWindow.xaml.cs 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();

BindingSource = new BindingSourceObject
{
DisplayName = "依赖对象示例",
AccentBrush = System.Windows.Media.Brushes.DodgerBlue
};
BindingSourcePreview.DataContext = BindingSource;
}

public BindingSourceObject BindingSource { get; }
}

这里把 BindingSourcePreview 的 DataContext 设置为来源对象。

2.5 在 XAML 中绑定到依赖对象

先在窗口资源中声明转换器:

1
2
3
<Window.Resources>
<converters:BrushConverter x:Key="BrushConverter"/>
</Window.Resources>

然后写:

1
2
<TextBlock Text="{Binding ElementName=BindingSource,
Path=DisplayName}" />

但 BindingSource 是窗口的普通 CLR 属性,不是 XAML 命名元素。要把它作为 ElementName 来源,需要先把它暴露成一个命名元素,或者在代码中设置 DataContext。

示例采用后者:

1
2
3
4
5
<Border x:Name="BindingSourcePreview"
BorderBrush="{Binding Path=AccentBrush}">
<TextBlock Text="{Binding Path=DisplayName}"
Foreground="{Binding Path=AccentBrush}"/>
</Border>

并在构造函数中:

1
BindingSourcePreview.DataContext = BindingSource;

这样内部绑定会从 BindingSource 读取属性。

2.6 完整示例:依赖对象页签

示例中的 XAML 使用:

1
2
3
4
5
6
7
8
9
10
11
<Border x:Name="BindingSourcePreview"
Background="#F7F9FD"
BorderBrush="{Binding Path=AccentBrush}"
BorderThickness="2"
CornerRadius="10"
Padding="20">
<TextBlock Text="{Binding Path=DisplayName}"
FontSize="22"
FontWeight="SemiBold"
Foreground="{Binding Path=AccentBrush}"/>
</Border>

当代码改变来源对象的依赖属性:

1
2
BindingSource.DisplayName = "新的标题";
BindingSource.AccentBrush = Brushes.SeaGreen;

绑定目标会自动刷新。

2.7 依赖对象来源的优点

优点 说明
原生变化通知 依赖属性由 WPF 管理
支持样式和动画 依赖属性可以参与这些机制
支持属性值继承 可以设置 Inherits
与 XAML 配合自然 控件本身就是依赖对象

2.8 依赖对象来源的限制

依赖对象适合界面属性,但不是什么数据都应该做成依赖对象。

限制 说明
必须继承 DependencyObject 普通 POCO 不能直接使用
依赖属性写法较冗长 注册、包装器、元数据
不适合业务实体 业务对象通常使用普通 C# 类和通知接口
需要理解属性系统 调试比普通属性复杂

实际项目中:

  • 控件、视觉属性:依赖对象或依赖属性
  • ViewModel、业务实体:普通类和 INotifyPropertyChanged

2.9 依赖对象和普通对象的对比

对比项 依赖对象 普通对象
基类 DependencyObject 任意类
属性系统 WPF 依赖属性 CLR 属性
变化通知 原生支持 通常实现 INotifyPropertyChanged
样式和动画 原生支持 不直接支持
适合 控件、视觉对象 ViewModel、业务对象

2.10 本节小结

问题 答案
依赖对象可以作为来源吗 可以
依赖属性需要额外通知吗 通常不需要
怎样把依赖对象设为来源 设置目标元素的 DataContext 或使用 Source
适合业务实体吗 通常不适合
适合控件属性吗 非常适合

小练习

  1. 给 BindingSourceObject 增加一个 Description 依赖属性,并绑定到 TextBlock。
  2. 在代码中修改 DisplayName,观察界面是否自动更新。
  3. 尝试用 Source 属性而不是 DataContext 来绑定同一个对象。

3. 基于普通数据类型的数据绑定

本节目标

理解普通 CLR 属性作为绑定来源时的通知机制,并能够:

  • 在 ViewModel 中实现 INotifyPropertyChanged。
  • 绑定 string、int、decimal 等普通类型。
  • 使用 CallerMemberName 减少字符串拼写错误。
  • 区分“值类型”和“普通属性”这两个概念。
  • 知道什么时候不需要通知。

3.1 普通类型也能绑定

绑定来源不要求是依赖对象。下面这些都可以:

1
2
3
public string Title { get; set; }
public int Count { get; set; }
public decimal Price { get; set; }

但有一个关键条件:

如果希望来源值变化后界面自动更新,普通属性通常需要通知机制。

3.2 没有通知会发生什么

假设 ViewModel 写成:

1
2
3
4
public class MainViewModel
{
public string Title { get; set; } = "初始标题";
}

XAML:

1
<TextBlock Text="{Binding Title}" />

第一次显示时会读取“初始标题”。

如果代码执行:

1
viewModel.Title = "新标题";

界面不一定会自动更新,因为没有通知告诉绑定系统“Title 变了”。

3.3 实现 INotifyPropertyChanged

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.ComponentModel;
using System.Runtime.CompilerServices;

public class MainViewModel : INotifyPropertyChanged
{
private string _title = "数据绑定实验台";

public string Title
{
get => _title;
set
{
if (_title == value)
{
return;
}

_title = value;
OnPropertyChanged();
}
}

public event PropertyChangedEventHandler? PropertyChanged;

private void OnPropertyChanged(
[CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(propertyName));
}
}

当 Title 改变时,绑定系统收到通知,然后重新读取新值。

3.4 CallerMemberName 有什么好处

OnPropertyChanged() 没有传参数,为什么也能知道属性名?

因为参数上有:

1
[CallerMemberName]

编译器会自动把调用者的成员名传进去。上面的调用等价于:

1
OnPropertyChanged(nameof(Title));

这样可以避免手写字符串:

1
2
// 容易拼错,而且重命名后可能忘记改
OnPropertyChanged("Titel");

3.5 在 XAML 中绑定普通属性

窗口设置 DataContext:

1
2
3
<Window.DataContext>
<viewModels:MainViewModel />
</Window.DataContext>

绑定:

1
2
3
4
5
<TextBox Text="{Binding Title,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}" />

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

效果:

1
2
3
4
5
6
7
8
9
10
在 TextBox 输入
|
v
Title 属性更新
|
v
PropertyChanged 通知
|
v
TextBlock 更新

3.6 值类型和引用类型都能绑定

普通数据类型不只指 int、decimal。字符串、布尔值、枚举、日期都可以绑定。

示例:

1
2
3
public bool IsEnabled { get; set; }
public DateTime CreatedAt { get; set; }
public OrderStatus Status { get; set; }

对应 XAML:

1
2
3
<CheckBox IsChecked="{Binding IsEnabled}" />
<TextBlock Text="{Binding CreatedAt}" />
<TextBlock Text="{Binding Status}" />

是否显示得好,取决于目标属性能否理解来源值。不能直接转换时,可以使用 StringFormat 或转换器。

3.7 StringFormat 格式化

金额:

1
2
<TextBlock Text="{Binding Salary,
StringFormat={}{0:C0}}" />

日期:

1
2
<TextBlock Text="{Binding CreatedAt,
StringFormat={}{0:yyyy-MM-dd HH:mm}}" />

注意 StringFormat 前面的 {}:

1
StringFormat={}{0:C0}

它用来告诉 XAML 解析器:后面的花括号不是标记扩展。

3.8 什么时候不需要 INotifyPropertyChanged

如果属性只读取一次,之后不会变化,可以不实现通知:

1
public string ProductCode { get; } = "P-1001";

如果使用:

1
<TextBlock Text="{Binding ProductCode, Mode=OneTime}" />

一次读取即可。

但表单编辑、实时状态、动态计算结果通常需要通知。

3.9 普通属性绑定的常见错误

错误 结果 修正
没有实现通知 改值后界面不更新 实现 INotifyPropertyChanged
通知了错误的属性名 目标不刷新 使用 CallerMemberName
属性不是 public 绑定找不到 改为 public
DataContext 为空 绑定无来源 设置 DataContext
双向绑定目标不支持写回 只显示不更新来源 检查目标属性是否可写
使用 OneTime 后续变化不更新 改为 OneWay 或 TwoWay

3.10 本节小结

问题 答案
普通类型能绑定吗 能
为什么需要通知 让界面知道来源变了
最常用通知接口 INotifyPropertyChanged
怎样减少字符串错误 使用 [CallerMemberName]
字符串能绑定吗 能
数字能格式化吗 能,使用 StringFormat

小练习

  1. 给 MainViewModel 增加 Age 属性,并绑定到 TextBlock。
  2. 把 Age 改成只读属性,观察是否还能作为绑定来源。
  3. 使用 StringFormat 把工资显示成带货币符号的格式。
  4. 移除 PropertyChanged 通知,观察输入后另一个文本是否还更新。

4. 基于对象的数据绑定

本节目标

理解对象作为绑定来源时的常见模式,并能够:

  • 把对象属性绑定到控件。
  • 使用 SelectedItem 绑定当前选中对象。
  • 使用 ObservableCollection<T> 显示动态列表。
  • 解释对象属性变化和集合变化是两种不同通知。
  • 知道什么时候需要 INotifyPropertyChanged,什么时候需要 ObservableCollection<T>。

4.1 一个对象可以包含多个属性

先定义:

1
2
3
4
5
6
7
public class Employee
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Department { get; set; } = string.Empty;
public decimal Salary { get; set; }
}

ViewModel:

1
public Employee SelectedEmployee { get; set; }

绑定:

1
2
<TextBlock Text="{Binding SelectedEmployee.Name}" />
<TextBlock Text="{Binding SelectedEmployee.Department}" />

这叫“路径绑定”。Path 可以逐级访问对象属性。

4.2 对象属性变化通知

如果只显示对象,SelectedEmployee.Name 可以正常读取。

但如果用户在文本框中修改姓名,并希望其他位置同步更新,对象属性也需要通知。

因此 Employee 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Employee : INotifyPropertyChanged
{
private string _name = string.Empty;

public string Name
{
get => _name;
set
{
if (_name == value)
{
return;
}

_name = value;
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(nameof(Name)));
}
}

public event PropertyChangedEventHandler? PropertyChanged;
}

这样:

1
2
3
4
5
6
7
8
9
10
TextBox 修改 Name
|
v
Employee.Name 更新
|
v
PropertyChanged 通知
|
v
其他绑定到 Name 的控件刷新

4.3 集合绑定

单个对象适合详情展示,多个对象通常需要集合。

示例:

1
2
3
4
5
public ObservableCollection<Employee> Employees { get; } = new()
{
new Employee { Name = "张三" },
new Employee { Name = "李四" }
};

绑定:

1
2
<ListBox ItemsSource="{Binding Employees}"
DisplayMemberPath="Name" />

ObservableCollection<T> 会在添加、删除、移动元素时通知界面。

4.4 ObservableCollection 和 List 的区别

集合 集合变化通知 适合
List<T> 没有 静态数据、查询结果
ObservableCollection<T> 有 可增删改的界面列表

如果列表内容会动态变化,优先使用 ObservableCollection<T>。

注意:

1
2
ObservableCollection<T> 通知“集合变了”
INotifyPropertyChanged 通知“对象属性变了”

这两个职责不同。

4.5 SelectedItem 绑定

列表通常还需要绑定“当前选中项”:

1
2
3
<ListBox ItemsSource="{Binding Employees}"
SelectedItem="{Binding SelectedEmployee, Mode=TwoWay}"
DisplayMemberPath="Name" />

SelectedEmployee 需要通知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Employee? _selectedEmployee;

public Employee? SelectedEmployee
{
get => _selectedEmployee;
set
{
if (ReferenceEquals(_selectedEmployee, value))
{
return;
}

_selectedEmployee = value;
OnPropertyChanged();
}
}

否则列表选择变化后,其他绑定到 SelectedEmployee 的控件不会更新。

4.6 详情面板怎样绑定

详情面板可以设置:

1
2
3
4
5
6
7
8
<StackPanel DataContext="{Binding SelectedEmployee}">
<TextBox Text="{Binding Name,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}" />
<TextBox Text="{Binding Department,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>

这时内部绑定路径从:

1
SelectedEmployee.Name

变成了:

1
Name

因为 DataContext 已经切换到选中的员工对象。

4.7 完整对象模型

示例中的 Employee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class Employee : INotifyPropertyChanged
{
private string _name = string.Empty;
private string _department = string.Empty;
private decimal _salary;

public int Id { get; set; }

public string Name
{
get => _name;
set => SetField(ref _name, value);
}

public string Department
{
get => _department;
set => SetField(ref _department, value);
}

public decimal Salary
{
get => _salary;
set => SetField(ref _salary, value);
}

public event PropertyChangedEventHandler? PropertyChanged;

private void SetField<T>(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return;
}

field = value;
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(propertyName));
}
}

4.8 对象绑定和列表绑定的配合

完整关系:

1
2
3
4
5
6
7
8
9
MainViewModel
|
+-- Employees: ObservableCollection<Employee>
|
+-- SelectedEmployee: Employee
|
+-- Name
+-- Department
+-- Salary

界面:

1
2
3
ListBox -> Employees
选中项 -> SelectedEmployee
详情区 -> SelectedEmployee 的属性

4.9 添加新对象

示例按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void AddEmployee_Click(object sender, RoutedEventArgs e)
{
var nextId = _viewModel.Employees.Count + 1;
var employee = new Employee
{
Id = nextId,
Name = $"新员工 {nextId}",
Department = "待分配",
Salary = 8000m
};

_viewModel.Employees.Add(employee);
_viewModel.SelectedEmployee = employee;
}

点击后:

  1. ObservableCollection 发出集合变化通知。
  2. ListBox 增加新项。
  3. SelectedEmployee 指向新对象。
  4. 详情区切换到新对象。

4.10 本节小结

问题 答案
对象属性怎样绑定 {Binding Object.Property}
对象属性变化怎样通知 INotifyPropertyChanged
集合变化怎样通知 ObservableCollection<T>
选中项怎样绑定 SelectedItem
详情面板怎样简化路径 设置 DataContext 为选中对象
两种通知一样吗 不一样,一个管属性,一个管集合

小练习

  1. 给 Employee 增加 Email 属性,并绑定到详情面板。
  2. 把 ObservableCollection 换成 List,点击添加按钮观察列表是否更新。
  3. 给列表增加删除按钮,删除后观察选中项怎样变化。

5. 基于ADO.NET的数据绑定

本节目标

理解 ADO.NET 数据对象怎样参与 WPF 绑定,并能够:

  • 创建内存中的 DataTable。
  • 使用 DataView 进行筛选和排序。
  • 把 DataView 绑定到 DataGrid。
  • 理解 DataRowView 和列绑定。
  • 知道 ADO.NET 绑定适合什么场景。

5.1 ADO.NET 在 WPF 中的角色

WPF 不要求一定使用 Entity Framework 或 ORM。ADO.NET 的 DataTable、DataView 和 DataSet 仍然可以参与绑定。

典型流程:

1
2
3
4
5
6
7
8
9
10
数据库查询
|
v
DataTable
|
v
DataView
|
v
DataGrid

本篇示例不连接真实数据库,而是在内存中创建 DataTable,这样可以专注于绑定机制。

5.2 创建 DataTable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static DataTable CreateEmployeeDataTable()
{
var table = new DataTable("Employees");

table.Columns.Add("Id", typeof(int));
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Department", typeof(string));
table.Columns.Add("Salary", typeof(decimal));

table.Rows.Add(1, "陈晨", "研发部", 13000m);
table.Rows.Add(2, "林雪", "产品部", 10800m);
table.Rows.Add(3, "周航", "设计部", 9600m);
table.Rows.Add(4, "吴越", "测试部", 9000m);
table.Rows.Add(5, "何静", "研发部", 15000m);

return table;
}

这段代码做了三件事:

  1. 创建表结构。
  2. 定义列和列类型。
  3. 插入示例行。

5.3 DataTable 和 DataView 的区别

对象 作用
DataTable 保存行和列
DataView 对表提供排序、筛选和当前视图

可以把它理解为:

1
2
DataTable = 原始数据
DataView = 从某个角度看这些数据

同一个 DataTable 可以有多个 DataView,每个视图使用不同的筛选或排序。

5.4 创建 DataView

1
2
3
4
5
6
7
8
9
private readonly DataView _employeeView;

public MainWindow()
{
InitializeComponent();

_employeeView = CreateEmployeeDataTable().DefaultView;
EmployeeDataGrid.ItemsSource = _employeeView;
}

DefaultView 返回表的默认视图。

5.5 绑定到 DataGrid

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<DataGrid x:Name="EmployeeDataGrid"
AutoGenerateColumns="False"
IsReadOnly="False"
CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="编号"
Binding="{Binding Id}"
IsReadOnly="True"/>
<DataGridTextColumn Header="姓名"
Binding="{Binding Name}"/>
<DataGridTextColumn Header="部门"
Binding="{Binding Department}"/>
<DataGridTextColumn Header="工资"
Binding="{Binding Salary,
StringFormat={}{0:C0}}"/>
</DataGrid.Columns>
</DataGrid>

这里的 Binding="{Binding Name}" 不是绑定到某个 ViewModel 属性,而是绑定到当前行对象上的 Name 列。

5.6 DataRowView 是什么

DataView 中的每一行在绑定时通常表现为 DataRowView。

可以把它理解为“视图中的一行”。它把列名暴露成可绑定属性,所以:

1
Binding="{Binding Name}"

可以读到列 Name。

如果列名写成:

1
table.Columns.Add("EmployeeName", typeof(string));

绑定就要改成:

1
Binding="{Binding EmployeeName}"

列名和绑定路径必须一致。

5.7 使用 RowFilter 筛选

示例:

1
2
3
4
5
6
7
8
9
private void FilterDevelopment_Click(object sender, RoutedEventArgs e)
{
_employeeView.RowFilter = "Department = '研发部'";
}

private void ClearFilter_Click(object sender, RoutedEventArgs e)
{
_employeeView.RowFilter = string.Empty;
}

RowFilter 使用类似 SQL 的表达式语法,但它是在内存中的 DataView 上执行,不是发给数据库。

运行后点击“只看研发部”,表格会只显示研发部员工。

5.8 使用 Sort 排序

1
2
3
4
private void SortBySalary_Click(object sender, RoutedEventArgs e)
{
_employeeView.Sort = "Salary DESC";
}

DESC 表示降序,ASC 表示升序。

可以组合多列:

1
_employeeView.Sort = "Department ASC, Salary DESC";

5.9 ADO.NET 绑定的优点

优点 说明
内置表格结构 列名、类型和行集合
支持筛选排序 DataView 提供现成能力
与 DataGrid 配合好 行列绑定直观
兼容传统数据库代码 已有 ADO.NET 项目容易接入

5.10 ADO.NET 绑定的限制

限制 说明
数据模型偏表格 复杂对象关系不够自然
强类型较弱 列名通常以字符串表示
业务逻辑容易分散 不要把大量业务规则塞进 DataTable
现代项目常用 ORM Entity Framework、Dapper 等更常见

学习绑定机制时,DataTable 很直观;新项目选择数据访问方案时,要根据项目规模和团队习惯决定。

5.11 真实数据库怎样接入

如果使用 SQL Server,可以大致写成:

1
2
3
4
5
6
7
8
using var connection = new SqlConnection(connectionString);
using var adapter = new SqlDataAdapter(
"SELECT Id, Name, Department, Salary FROM Employees",
connection);

var table = new DataTable();
adapter.Fill(table);
EmployeeDataGrid.ItemsSource = table.DefaultView;

本篇不执行真实数据库连接,是为了让示例在任何电脑上都能直接运行。

5.12 本节小结

问题 答案
DataTable 是什么 内存中的表
DataView 是什么 表的筛选排序视图
怎样绑定表格 绑定 DataView
行绑定写什么 Binding="{Binding 列名}"
怎样筛选 设置 RowFilter
怎样排序 设置 Sort

小练习

  1. 增加一个“只看工资大于 10000”的筛选按钮。
  2. 把 Sort 改成按部门升序、工资降序。
  3. 给 DataTable 增加一个 Email 列,并在 DataGrid 中显示。
  4. 修改列名,观察绑定路径是否必须同步变化。

6. 基于Linq的数据绑定

本节目标

理解 LINQ 查询结果怎样参与 WPF 绑定,并能够:

  • 用 LINQ 过滤、排序和投影对象集合。
  • 把查询结果赋给 ItemsSource。
  • 理解 LINQ 查询结果和可观察集合的区别。
  • 知道什么时候需要重新执行查询。
  • 知道匿名类型绑定的注意事项。

6.1 LINQ 不是一种控件

LINQ 是 C# 中的查询能力。它本身不是 WPF 绑定机制,但它的结果可以作为绑定来源。

典型流程:

1
2
3
4
5
6
7
8
9
10
原始集合
|
v
LINQ 查询
|
v
List<T> 或 IEnumerable<T>
|
v
ItemsSource

6.2 先准备对象集合

沿用前面的 Employee:

1
public ObservableCollection<Employee> Employees { get; }

示例数据:

1
2
3
4
5
6
7
Employees = new ObservableCollection<Employee>
{
new() { Id = 1, Name = "张三", Department = "研发部", Salary = 12000m },
new() { Id = 2, Name = "李四", Department = "产品部", Salary = 10500m },
new() { Id = 3, Name = "王五", Department = "设计部", Salary = 9800m },
new() { Id = 4, Name = "赵六", Department = "测试部", Salary = 9200m }
};

6.3 基本 LINQ 查询

目标:找出工资不低于 10000 的员工,并按工资降序排列。

1
2
3
4
var result = _viewModel.Employees
.Where(employee => employee.Salary >= 10000)
.OrderByDescending(employee => employee.Salary)
.ToList();

逐段解释:

方法 作用
Where 过滤
OrderByDescending 按工资降序
ToList 立即执行并生成列表

如果没有 ToList,得到的是延迟执行的查询。把它直接赋给 ItemsSource 时,行为可能和预期不同,排查也更困难。初学阶段建议先物化结果。

6.4 绑定查询结果

1
LinqDataGrid.ItemsSource = result;

这里的 result 是 List<Employee>。

DataGrid 会为每个 Employee 创建一行,列绑定读取对象属性:

1
2
3
4
5
<DataGridTextColumn Header="姓名"
Binding="{Binding Name}" />
<DataGridTextColumn Header="工资"
Binding="{Binding Salary,
StringFormat={}{0:C0}}" />

6.5 投影成匿名类型

示例使用:

1
2
3
4
5
6
7
8
9
10
11
var result = _viewModel.Employees
.Where(employee => employee.Salary >= minimumSalary)
.OrderByDescending(employee => employee.Salary)
.Select(employee => new
{
employee.Id,
employee.Name,
employee.Department,
employee.Salary
})
.ToList();

Select 生成了匿名类型对象。

匿名类型适合:

  • 查询结果只用于当前界面
  • 不需要修改对象
  • 不需要在其他层之间传递

如果结果需要长期维护、编辑或跨层传递,建议定义明确的 DTO 或 ViewModel 类型。

6.6 匿名类型能双向绑定吗

匿名类型的属性通常是只读的。因此:

1
2
<DataGrid IsReadOnly="True"
ItemsSource="{Binding ...}" />

更合适。

如果需要编辑,使用可写的实体类型,而不是匿名类型。

6.7 LINQ 结果不会自动跟随原集合变化

这一点非常重要:

1
2
3
4
5
var result = Employees
.Where(employee => employee.Salary >= 10000)
.ToList();

LinqDataGrid.ItemsSource = result;

之后即使 Employees 新增了一个工资 12000 的员工,result 也不会自动增加这一行。

原因:

1
2
3
ToList 已经生成了一个固定列表
LINQ 查询执行完了
原集合变化不会自动重跑查询

需要重新执行查询并重新赋值:

1
2
3
4
LinqDataGrid.ItemsSource = Employees
.Where(employee => employee.Salary >= 10000)
.OrderByDescending(employee => employee.Salary)
.ToList();

6.8 怎样实现“实时查询”

如果希望输入条件变化后立即刷新,可以:

  1. 监听输入变化。
  2. 重新执行 LINQ 查询。
  3. 把新结果赋给 ItemsSource。

示例按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void RunLinqQuery_Click(object sender, RoutedEventArgs e)
{
decimal.TryParse(MinimumSalaryBox.Text, out var minimumSalary);

var result = _viewModel.Employees
.Where(employee => employee.Salary >= minimumSalary)
.OrderByDescending(employee => employee.Salary)
.Select(employee => new
{
employee.Id,
employee.Name,
employee.Department,
employee.Salary
})
.ToList();

LinqDataGrid.ItemsSource = result;
}

6.9 LINQ 和 ObservableCollection 的关系

两者可以配合,但职责不同:

对象 作用
ObservableCollection<T> 保存可变集合,通知增删
LINQ 从集合中查询、筛选、排序、投影

常见模式:

1
2
3
4
5
6
7
8
9
10
原始集合:ObservableCollection<Employee>
|
v
LINQ 查询
|
v
结果列表:List<Employee>
|
v
DataGrid.ItemsSource

6.10 LINQ 的延迟执行

下面两种写法值得区分:

1
var query = Employees.Where(employee => employee.Salary >= 10000);

这是延迟执行的查询。真正遍历时才执行。

1
2
3
var list = Employees
.Where(employee => employee.Salary >= 10000)
.ToList();

这是立即执行,结果已经固定为列表。

绑定前建议明确:

1
我希望结果固定下来,还是每次访问都重新查询?

6.11 LINQ 查询的常见错误

错误 结果 修正
忘记 ToList 结果延迟执行,行为不直观 绑定前物化
查询后不重新赋值 界面不显示新结果 重新执行并赋给 ItemsSource
匿名类型尝试编辑 属性只读 使用可写实体类型
在查询里做耗时操作 界面卡顿 把耗时查询移出 UI 线程
过滤条件解析失败 得到默认值 校验输入

6.12 本节小结

问题 答案
LINQ 能作为绑定来源吗 查询结果可以
结果怎样绑定 赋给 ItemsSource
需要 ToList 吗 建议绑定前物化
原集合变化会自动刷新吗 不会
匿名类型适合编辑吗 不适合
怎样刷新查询 重新执行并重新赋值

小练习

  1. 修改 LINQ 查询,只显示研发部员工。
  2. 按部门分组,显示每个部门的平均工资。
  3. 把匿名类型换成 Employee 对象,观察是否可编辑。
  4. 给查询增加“姓名包含关键字”的条件。

数据绑定语法速查

写法 含义
{Binding Name} 绑定当前 DataContext 的 Name
{Binding Path=Name} 显式写路径
{Binding ElementName=Other, Path=Text} 绑定另一个元素
{Binding Source={StaticResource Data}, Path=Name} 绑定资源对象
{Binding RelativeSource={RelativeSource Self}, Path=ActualWidth} 绑定目标自身
{Binding RelativeSource={RelativeSource AncestorType=Window}, Path=Title} 绑定祖先窗口
{Binding Employees.Count} 路径访问集合属性
{Binding SelectedEmployee.Name} 路径访问对象属性

绑定模式速查

模式 方向 典型场景
OneWay 来源到目标 只读文本
TwoWay 双向 文本框、复选框
OneTime 只读一次 静态配置
OneWayToSource 目标到来源 特殊场景
Default 目标默认 通常省略

更新时机速查

值 什么时候写回来源 适合
PropertyChanged 目标属性每次变化 实时预览
LostFocus 目标失去焦点 普通表单
Explicit 手动调用 UpdateSource 手动提交
Default 目标属性默认规则 一般不用写

数据来源对照

来源类型 典型对象 通知方式 适合
依赖对象 DependencyObject 依赖属性系统 控件、视觉对象
普通数据 string、int、decimal INotifyPropertyChanged ViewModel 属性
对象 实体类 INotifyPropertyChanged 详情、选中项
集合 ObservableCollection<T> 集合变化通知 动态列表
ADO.NET DataTable、DataView 数据视图通知 表格数据
LINQ List<T>、匿名类型 重新赋值 查询结果

常见错误与排查

现象 常见原因 排查方法
文本为空 路径拼错 查看输出窗口绑定错误
修改来源界面不动 没有通知 实现 INotifyPropertyChanged
修改界面来源不动 没有双向绑定 设置 Mode=TwoWay
输入过程中不更新 写回时机不对 使用 PropertyChanged
列表不刷新 使用普通 List 改用 ObservableCollection
选中项不同步 没有绑定 SelectedItem 设置双向绑定
DataGrid 没有数据 列名与绑定路径不一致 检查列名
LINQ 结果不更新 查询结果已经物化 重新执行查询
绑定没有来源 DataContext 为空 设置 DataContext
转换失败 类型不匹配 使用转换器或格式化

调试绑定的三个步骤

  1. 先看输出窗口有没有 System.Windows.Data Error。
  2. 再确认 DataContext 和 Path 是否指向同一个对象。
  3. 最后检查通知和更新时机。

大多数绑定问题不是 WPF 本身出错,而是来源、路径、通知三者之一没有对上。


完整示例代码

文件结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WpfDataBindingDemo/
├─ App.xaml
├─ App.xaml.cs
├─ AssemblyInfo.cs
├─ Converters/
│ └─ BrushConverter.cs
├─ DependencyObjects/
│ └─ BindingSourceObject.cs
├─ Models/
│ └─ Employee.cs
├─ ViewModels/
│ └─ MainViewModel.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
└─ WpfDataBindingDemo.csproj

WpfDataBindingDemo.csproj

1
2
3
4
5
6
7
8
9
10
11
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>

</Project>

Employee.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WpfDataBindingDemo.Models;

public class Employee : INotifyPropertyChanged
{
private string _name = string.Empty;
private string _department = string.Empty;
private decimal _salary;

public int Id { get; set; }

public string Name
{
get => _name;
set => SetField(ref _name, value);
}

public string Department
{
get => _department;
set => SetField(ref _department, value);
}

public decimal Salary
{
get => _salary;
set => SetField(ref _salary, value);
}

public event PropertyChangedEventHandler? PropertyChanged;

private void SetField<T>(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return;
}

field = value;
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(propertyName));
}
}

MainViewModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using WpfDataBindingDemo.Models;

namespace WpfDataBindingDemo.ViewModels;

public class MainViewModel : INotifyPropertyChanged
{
private string _title = "数据绑定实验台";
private string _employeeName = "张三";
private Employee? _selectedEmployee;

public MainViewModel()
{
Employees = new ObservableCollection<Employee>
{
new() { Id = 1, Name = "张三", Department = "研发部", Salary = 12000m },
new() { Id = 2, Name = "李四", Department = "产品部", Salary = 10500m },
new() { Id = 3, Name = "王五", Department = "设计部", Salary = 9800m },
new() { Id = 4, Name = "赵六", Department = "测试部", Salary = 9200m }
};

SelectedEmployee = Employees[0];
}

public ObservableCollection<Employee> Employees { get; }

public Employee? SelectedEmployee
{
get => _selectedEmployee;
set
{
if (ReferenceEquals(_selectedEmployee, value))
{
return;
}

_selectedEmployee = value;
OnPropertyChanged();
}
}

public string Title
{
get => _title;
set => SetField(ref _title, value);
}

public string EmployeeName
{
get => _employeeName;
set => SetField(ref _employeeName, value);
}

public event PropertyChangedEventHandler? PropertyChanged;

private void SetField<T>(
ref T field,
T value,
[CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return;
}

field = value;
OnPropertyChanged(propertyName);
}

private void OnPropertyChanged(
[CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(propertyName));
}
}

BindingSourceObject.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.Windows;
using System.Windows.Media;

namespace WpfDataBindingDemo.DependencyObjects;

public class BindingSourceObject : DependencyObject
{
public static readonly DependencyProperty DisplayNameProperty =
DependencyProperty.Register(
nameof(DisplayName),
typeof(string),
typeof(BindingSourceObject),
new FrameworkPropertyMetadata(
"依赖对象来源",
FrameworkPropertyMetadataOptions.AffectsRender));

public string DisplayName
{
get => (string)GetValue(DisplayNameProperty);
set => SetValue(DisplayNameProperty, value);
}

public static readonly DependencyProperty AccentBrushProperty =
DependencyProperty.Register(
nameof(AccentBrush),
typeof(Brush),
typeof(BindingSourceObject),
new FrameworkPropertyMetadata(
Brushes.DodgerBlue,
FrameworkPropertyMetadataOptions.AffectsRender));

public Brush AccentBrush
{
get => (Brush)GetValue(AccentBrushProperty);
set => SetValue(AccentBrushProperty, value);
}
}

BrushConverter.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;

namespace WpfDataBindingDemo.Converters;

public class BrushConverter : IValueConverter
{
public object Convert(
object value,
Type targetType,
object parameter,
CultureInfo culture)
{
return value switch
{
Brush brush => brush,
string text when !string.IsNullOrWhiteSpace(text) => new SolidColorBrush(
(Color)ColorConverter.ConvertFromString(text)),
_ => Brushes.Transparent
};
}

public object ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture)
{
return value;
}
}

MainWindow.xaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
<Window x:Class="WpfDataBindingDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfDataBindingDemo"
xmlns:converters="clr-namespace:WpfDataBindingDemo.Converters"
xmlns:models="clr-namespace:WpfDataBindingDemo.Models"
xmlns:viewModels="clr-namespace:WpfDataBindingDemo.ViewModels"
Title="WPF 数据绑定学习示例"
Height="900"
Width="1120"
WindowStartupLocation="CenterScreen"
Background="#F4F7FC">
<Window.DataContext>
<viewModels:MainViewModel />
</Window.DataContext>

<Window.Resources>
<converters:BrushConverter x:Key="BrushConverter"/>

<Style TargetType="TabItem">
<Setter Property="FontSize" Value="15"/>
<Setter Property="Padding" Value="18,10"/>
</Style>

<Style TargetType="TextBox">
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Margin" Value="0,4,0,12"/>
</Style>

<Style TargetType="Button">
<Setter Property="Padding" Value="14,8"/>
<Setter Property="Margin" Value="0,0,10,10"/>
<Setter Property="MinWidth" Value="120"/>
</Style>

<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="Background" Value="White"/>
<Setter Property="BorderBrush" Value="#DCE5F2"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="20"/>
</Style>

<Style x:Key="SectionTitleStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="18"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="#172033"/>
<Setter Property="Margin" Value="0,0,0,12"/>
</Style>
</Window.Resources>

<Grid Margin="28">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>

<StackPanel Margin="0,0,0,18">
<TextBlock Text="{Binding Title}"
FontSize="30"
FontWeight="SemiBold"
Foreground="#172033"/>
<TextBlock Text="六个页签分别演示依赖对象、普通数据、对象、ADO.NET 和 LINQ 绑定。"
Margin="0,8,0,0"
FontSize="15"
Foreground="#5D6B82"/>
</StackPanel>

<TabControl Grid.Row="1">
<!-- 1. Basic relationship -->
<TabItem Header="1 基本关系">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="4,16,4,4">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<Border Style="{StaticResource CardStyle}"
Margin="0,0,0,18">
<StackPanel>
<TextBlock Text="数据绑定的最小关系"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="目标:Slider.Value 来源:TextBox.Text 方向:双向"
Margin="0,0,0,14"
FontFamily="Consolas"
Foreground="#2F6FED"/>
<TextBlock Text="拖动滑块,文本框会变化;在文本框中输入数字,滑块也会变化。"
Margin="0,0,0,16"
Foreground="#5D6B82"
TextWrapping="Wrap"/>
<Slider x:Name="RelationshipSlider"
Minimum="0"
Maximum="100"
Value="35"
TickFrequency="10"
IsSnapToTickEnabled="False"/>
<TextBox Text="{Binding ElementName=RelationshipSlider,
Path=Value,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"
Margin="0,14,0,0"
FontSize="18"/>
</StackPanel>
</Border>

<Border Grid.Row="1"
Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="绑定表达式拆解"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="Text=&quot;{Binding ElementName=RelationshipSlider, Path=Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}&quot;"
FontFamily="Consolas"
Foreground="#34425C"
TextWrapping="Wrap"/>
<TextBlock Text="ElementName 指定来源元素;Path 指定来源属性;Mode 决定流向;UpdateSourceTrigger 决定何时写回。"
Margin="0,12,0,0"
Foreground="#5D6B82"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</TabItem>

<!-- 2. Dependency object source -->
<TabItem Header="2 依赖对象">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="4,16,4,4">
<Border Style="{StaticResource CardStyle}"
Margin="0,0,0,18">
<StackPanel>
<TextBlock Text="目标绑定到依赖对象"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="来源对象是 BindingSourceObject,它的 DisplayName 和 AccentBrush 都是依赖属性。"
Margin="0,0,0,16"
Foreground="#5D6B82"
TextWrapping="Wrap"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="130"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<TextBlock Text="DisplayName"
VerticalAlignment="Center"/>
<TextBox Grid.Column="1"
Text="{Binding ElementName=BindingSource,
Path=DisplayName,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>

<TextBlock Grid.Row="1"
Text="AccentBrush"
VerticalAlignment="Center"/>
<ComboBox Grid.Row="1"
Grid.Column="1"
SelectedValue="{Binding ElementName=BindingSource,
Path=AccentBrush,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged,
Converter={StaticResource BrushConverter}}"
Margin="0,4,0,12">
<ComboBoxItem Content="蓝色"
Tag="{Binding Source=#2F6FED,
Converter={StaticResource BrushConverter}}"/>
<ComboBoxItem Content="绿色"
Tag="{Binding Source=#108B68,
Converter={StaticResource BrushConverter}}"/>
<ComboBoxItem Content="橙红色"
Tag="{Binding Source=#D3542E,
Converter={StaticResource BrushConverter}}"/>
</ComboBox>
</Grid>

<Border x:Name="BindingSourcePreview"
Background="#F7F9FD"
BorderBrush="{Binding ElementName=BindingSource,
Path=AccentBrush}"
BorderThickness="2"
CornerRadius="10"
Padding="20"
Margin="0,8,0,0">
<TextBlock Text="{Binding ElementName=BindingSource,
Path=DisplayName}"
FontSize="22"
FontWeight="SemiBold"
Foreground="{Binding ElementName=BindingSource,
Path=AccentBrush}"/>
</Border>
</StackPanel>
</Border>

<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="背后的来源对象"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="public class BindingSourceObject : DependencyObject"
FontFamily="Consolas"
Foreground="#2F6FED"/>
<TextBlock Text="依赖属性本身带有变化通知能力,因此绑定目标可以自动刷新。"
Margin="0,10,0,0"
Foreground="#5D6B82"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>

<!-- 3. Primitive data source -->
<TabItem Header="3 普通数据">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="4,16,4,4">
<Border Style="{StaticResource CardStyle}"
Margin="0,0,0,18">
<StackPanel>
<TextBlock Text="普通数据类型作为来源"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="Window 的 DataContext 是 MainViewModel,Title 和 EmployeeName 是普通 CLR 属性,通过 INotifyPropertyChanged 通知变化。"
Margin="0,0,0,16"
Foreground="#5D6B82"
TextWrapping="Wrap"/>

<TextBlock Text="标题"/>
<TextBox Text="{Binding Title,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>

<TextBlock Text="员工姓名"/>
<TextBox Text="{Binding EmployeeName,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>

<Border Background="#F7F9FD"
CornerRadius="10"
Padding="18">
<StackPanel>
<TextBlock Text="绑定结果"
FontWeight="SemiBold"
Foreground="#5D6B82"/>
<TextBlock Text="{Binding Title}"
FontSize="24"
FontWeight="SemiBold"
Foreground="#2F6FED"
Margin="0,6,0,0"/>
<TextBlock Margin="0,6,0,0">
<Run Text="当前员工:"/>
<Run Text="{Binding EmployeeName}"/>
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</Border>

<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="为什么需要通知"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="如果 ViewModel 不实现 INotifyPropertyChanged,修改属性后界面不会自动更新。"
Foreground="#5D6B82"
TextWrapping="Wrap"/>
<TextBlock Text="private void OnPropertyChanged([CallerMemberName] string? name = null)"
Margin="0,12,0,0"
FontFamily="Consolas"
Foreground="#34425C"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>

<!-- 4. Object source -->
<TabItem Header="4 对象">
<Grid Margin="4,16,4,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>

<Border Style="{StaticResource CardStyle}"
Margin="0,0,18,0">
<StackPanel>
<TextBlock Text="对象列表"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="ItemsSource 绑定到 ObservableCollection&lt;Employee&gt;。"
Margin="0,0,0,14"
Foreground="#5D6B82"
TextWrapping="Wrap"/>
<ListBox ItemsSource="{Binding Employees}"
SelectedItem="{Binding SelectedEmployee,
Mode=TwoWay}"
DisplayMemberPath="Name"
MinHeight="180"/>
<Button Content="添加员工"
Click="AddEmployee_Click"
Margin="0,16,0,0"/>
</StackPanel>
</Border>

<Border Grid.Column="1"
Style="{StaticResource CardStyle}">
<StackPanel DataContext="{Binding SelectedEmployee}">
<TextBlock Text="选中对象的详细信息"
Style="{StaticResource SectionTitleStyle}"/>

<TextBlock Text="姓名"/>
<TextBox Text="{Binding Name,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>

<TextBlock Text="部门"/>
<TextBox Text="{Binding Department,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>

<TextBlock Text="工资"/>
<TextBox Text="{Binding Salary,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged,
StringFormat={}{0:C0}}"/>

<TextBlock Text="当前选择"
Margin="0,8,0,6"
FontWeight="SemiBold"
Foreground="#5D6B82"/>
<TextBlock FontSize="20"
Foreground="#2F6FED">
<Run Text="{Binding Name}"/>
<Run Text=" / "/>
<Run Text="{Binding Department}"/>
</TextBlock>
</StackPanel>
</Border>
</Grid>
</TabItem>

<!-- 5. ADO.NET source -->
<TabItem Header="5 ADO.NET">
<Grid Margin="4,16,4,4">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>

<Border Style="{StaticResource CardStyle}"
Margin="0,0,0,18">
<StackPanel>
<TextBlock Text="DataView 绑定"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="DataTable 保存在内存中,DataView 提供排序和筛选视图,DataGrid 直接绑定这个视图。"
Margin="0,0,0,14"
Foreground="#5D6B82"
TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal">
<Button Content="只看研发部"
Click="FilterDevelopment_Click"/>
<Button Content="清除筛选"
Click="ClearFilter_Click"/>
<Button Content="按工资降序"
Click="SortBySalary_Click"/>
</StackPanel>
</StackPanel>
</Border>

<DataGrid Grid.Row="1"
x:Name="EmployeeDataGrid"
ItemsSource="{Binding}"
AutoGenerateColumns="False"
IsReadOnly="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
AlternatingRowBackground="#F7F9FD"
RowBackground="White"
BorderBrush="#DCE5F2"
BorderThickness="1">
<DataGrid.Columns>
<DataGridTextColumn Header="编号"
Binding="{Binding Id}"
IsReadOnly="True"
Width="90"/>
<DataGridTextColumn Header="姓名"
Binding="{Binding Name}"
Width="*"/>
<DataGridTextColumn Header="部门"
Binding="{Binding Department}"
Width="*"/>
<DataGridTextColumn Header="工资"
Binding="{Binding Salary,
StringFormat={}{0:C0}}"
Width="150"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</TabItem>

<!-- 6. LINQ source -->
<TabItem Header="6 LINQ">
<Grid Margin="4,16,4,4">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>

<Border Style="{StaticResource CardStyle}"
Margin="0,0,0,18">
<StackPanel>
<TextBlock Text="LINQ 查询结果绑定"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock Text="使用 LINQ to Objects 查询员工集合,再把结果赋给 ItemsSource。每次修改查询条件后重新执行查询。"
Margin="0,0,0,14"
Foreground="#5D6B82"
TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal">
<TextBlock Text="最低工资"
VerticalAlignment="Center"
Margin="0,0,10,0"/>
<TextBox x:Name="MinimumSalaryBox"
Text="10000"
Width="120"
Margin="0,0,12,10"
VerticalContentAlignment="Center"/>
<Button Content="执行 LINQ 查询"
Click="RunLinqQuery_Click"/>
<Button Content="显示全部"
Click="ShowAllLinq_Click"/>
</StackPanel>
</StackPanel>
</Border>

<DataGrid Grid.Row="1"
x:Name="LinqDataGrid"
ItemsSource="{Binding}"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserAddRows="False"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
AlternatingRowBackground="#F7F9FD"
RowBackground="White"
BorderBrush="#DCE5F2"
BorderThickness="1">
<DataGrid.Columns>
<DataGridTextColumn Header="编号"
Binding="{Binding Id}"
Width="90"/>
<DataGridTextColumn Header="姓名"
Binding="{Binding Name}"
Width="*"/>
<DataGridTextColumn Header="部门"
Binding="{Binding Department}"
Width="*"/>
<DataGridTextColumn Header="工资"
Binding="{Binding Salary,
StringFormat={}{0:C0}}"
Width="150"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</TabItem>
</TabControl>
</Grid>
</Window>

MainWindow.xaml.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
using System.Data;
using System.Windows;
using System.Windows.Data;
using WpfDataBindingDemo.DependencyObjects;
using WpfDataBindingDemo.Models;
using WpfDataBindingDemo.ViewModels;

namespace WpfDataBindingDemo;

public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
private readonly DataView _employeeView;

public MainWindow()
{
InitializeComponent();

_viewModel = (MainViewModel)DataContext;

BindingSource = new BindingSourceObject
{
DisplayName = "依赖对象示例",
AccentBrush = System.Windows.Media.Brushes.DodgerBlue
};
BindingSourcePreview.DataContext = BindingSource;

_employeeView = CreateEmployeeDataTable().DefaultView;
EmployeeDataGrid.ItemsSource = _employeeView;
}

public BindingSourceObject BindingSource { get; }

private void AddEmployee_Click(object sender, RoutedEventArgs e)
{
var nextId = _viewModel.Employees.Count + 1;
var employee = new Employee
{
Id = nextId,
Name = $"新员工 {nextId}",
Department = "待分配",
Salary = 8000m
};

_viewModel.Employees.Add(employee);
_viewModel.SelectedEmployee = employee;
}

private static DataTable CreateEmployeeDataTable()
{
var table = new DataTable("Employees");

table.Columns.Add("Id", typeof(int));
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Department", typeof(string));
table.Columns.Add("Salary", typeof(decimal));

table.Rows.Add(1, "陈晨", "研发部", 13000m);
table.Rows.Add(2, "林雪", "产品部", 10800m);
table.Rows.Add(3, "周航", "设计部", 9600m);
table.Rows.Add(4, "吴越", "测试部", 9000m);
table.Rows.Add(5, "何静", "研发部", 15000m);

return table;
}

private void FilterDevelopment_Click(object sender, RoutedEventArgs e)
{
_employeeView.RowFilter = "Department = '研发部'";
}

private void ClearFilter_Click(object sender, RoutedEventArgs e)
{
_employeeView.RowFilter = string.Empty;
}

private void SortBySalary_Click(object sender, RoutedEventArgs e)
{
_employeeView.Sort = "Salary DESC";
}

private void RunLinqQuery_Click(object sender, RoutedEventArgs e)
{
decimal.TryParse(MinimumSalaryBox.Text, out var minimumSalary);

var result = _viewModel.Employees
.Where(employee => employee.Salary >= minimumSalary)
.OrderByDescending(employee => employee.Salary)
.Select(employee => new
{
employee.Id,
employee.Name,
employee.Department,
employee.Salary
})
.ToList();

LinqDataGrid.ItemsSource = result;
}

private void ShowAllLinq_Click(object sender, RoutedEventArgs e)
{
LinqDataGrid.ItemsSource = _viewModel.Employees;
}
}

运行检查表

检查项 预期结果
执行 dotnet build 显示 0 个错误
启动程序 出现六个页签
页签 1 拖动滑块 文本框数字变化
页签 1 修改文本框 滑块位置变化
页签 2 修改 DisplayName 预览文本变化
页签 2 选择颜色 预览边框和文字颜色变化
页签 3 修改标题 下方标题同步变化
页签 4 选择员工 详情区切换
页签 4 修改姓名 列表显示同步更新
页签 4 点击添加员工 列表新增一行
页签 5 点击筛选 只显示研发部
页签 5 点击排序 工资按降序排列
页签 6 输入最低工资 查询结果按条件过滤

最后三个综合练习

练习一:做一个可编辑的员工表单

要求:

  • 使用 Employee 对象作为来源。
  • 姓名、部门、工资都使用双向绑定。
  • 修改表单时,列表中的对应项同步更新。
  • 工资使用 StringFormat 格式化。

这个练习可以检验“对象属性通知 + 双向绑定”。

练习二:给 DataView 增加组合筛选

要求:

  • 增加部门筛选。
  • 增加最低工资筛选。
  • 两个条件可以同时生效。
  • 点击“清除筛选”后恢复全部数据。

提示:RowFilter 可以使用类似 Department = '研发部' AND Salary >= 10000 的表达式。

练习三:做一个实时 LINQ 查询

要求:

  • 文本框中输入关键字。
  • 每次输入后重新执行 LINQ 查询。
  • 查询结果显示在 DataGrid。
  • 输入为空时显示全部员工。

这个练习可以帮助你理解“LINQ 查询结果需要重新赋值”这一行为。


结语

数据绑定看起来有很多写法,但可以先抓住固定关系:

1
2
3
4
5
6
目标属性是谁?
来源对象是谁?
来源路径是什么?
数据流向是什么?
什么时候写回?
来源变化时,谁负责通知?

六种来源可以这样记:

来源 核心通知机制
依赖对象 依赖属性系统
普通数据 INotifyPropertyChanged
对象 对象属性通知
集合 ObservableCollection<T>
ADO.NET DataView 的行集视图
LINQ 查询后重新赋值

先把最小的 ElementName 绑定跑通,再依次尝试普通属性、对象、集合、DataView 和 LINQ。每加一种来源,都先观察界面变化,再看通知和路径,理解会比单纯背语法更牢固。