面向已经完成基础数据绑定练习,准备学习尺寸均分、多区域联动、集合呈现和数据模板的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,以便在当前安装了 .NET 9 SDK 的电脑上直接编译。这里用到的布局、绑定和模板机制在 .NET 9 和 .NET 10 中没有变化。
最后更新:2026-10-19

目录

  1. 基于界面控件绑定的尺寸均分处理
  2. 多区域整合绑定的数据关系
  3. 集合数据类型的绑定与数据呈现
  4. 数据模板触发器
  5. 数据模板选择器
  6. 基于数据集合绑定的直方图案例

写在前面

这份笔记解决什么问题

前面四篇笔记走完了一条完整的路:

笔记 回答的问题
WPF 依赖属性学习笔记 属性怎样参与 WPF 的值系统
WPF 依赖属性进阶笔记 元数据、属性值继承和附加属性
WPF 数据绑定学习笔记 绑定怎样把值和界面连起来
WPF 绑定数据源学习笔记 绑定表达式里的来源对象从哪里来

到这里,语法已经够用了,但真正动手写界面时还会遇到新的问题:

1
2
3
4
三列宽度怎样自动均分,不用手算?
左边选了部门,中间和右边怎样自动跟着变?
一个集合,怎样用表格、卡片、列表三种样子同时显示?
同一种数据,怎样按值切换颜色,甚至换一整套模板?

这一篇就是回答这些问题的。六章内容正好对应六种能力:

章节 解决的问题 主要技术点
第 1 章 宽度怎样自动分配 星号列、Auto、SharedSizeGroup、UniformGrid
第 2 章 区域之间怎样联动 SelectedItem、ElementName、DataContext
第 3 章 集合怎样显示出来 ObservableCollection<T>、ItemsControl、DataGrid
第 4 章 外观怎样按数据变化 DataTemplate.Triggers、DataTrigger
第 5 章 模板怎样按数据切换 DataTemplateSelector
第 6 章 统计结果怎样画出来 区间统计、IValueConverter、直方图

这六章不是互相独立的,而是层层递进:第 1 章解决“位置”,第 2 章解决“关系”,第 3 章解决“内容”,第 4、5 章解决“外观”,第 6 章把前面所有东西拼成一个完整案例。

建议的学习顺序

  1. 先直接运行随附示例,把六个页签各点一遍,建立直观印象。
  2. 再回到第 1 章,在自己新建的项目里从零敲一遍。
  3. 每完成一章,就给自己项目增加一个页签,六个页签刚好对应六章。
  4. 最后做第 6 章的直方图,把前面五章的知识点串起来。

不要只复制粘贴。每加一段 XAML,就运行一次,看看界面变了什么。WPF 的学习里,“看到变化”比“记住语法”重要得多。

示例项目位置

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

在 PowerShell 中运行:

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

如果使用 Visual Studio:

  1. 打开 WpfDataTemplateDemo.csproj。
  2. 确认启动项目是 WpfDataTemplateDemo。
  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 后,本文的 XAML 和 C# 代码不需要任何修改。

示例项目文件还开启了两个现代 C# 常用设置:

1
2
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

它们不是模板绑定的要求,但可以减少空引用问题,并让 System、System.Linq、System.Collections.ObjectModel 等常用命名空间自动可用。

先建一个自己的跟做项目

打开 PowerShell,执行:

1
2
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码"
dotnet new wpf -n WpfTemplatePractice -o .\WpfTemplatePractice

生成的目录结构如下:

1
2
3
4
5
6
7
WpfTemplatePractice\
├── App.xaml
├── App.xaml.cs
├── AssemblyInfo.cs
├── MainWindow.xaml
├── MainWindow.xaml.cs
└── WpfTemplatePractice.csproj

后面每一章的代码,都可以加到这个项目的 MainWindow.xaml 里。为了容纳六个页签,先把窗口调大一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Window x:Class="WpfTemplatePractice.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF 模板与集合练习"
Width="1280"
Height="900"
MinWidth="1024"
MinHeight="720"
WindowStartupLocation="CenterScreen"
Background="#F4F7FC">
<TabControl Margin="26">
<TabItem Header="1 尺寸均分">
<!-- 第 1 章的内容放这里 -->
</TabItem>
</TabControl>
</Window>

先按 F5 跑一次。看到一个只有单页签的窗口,就说明环境没问题。

先准备好三份数据模型

第 2 章以后的所有示例,都共用同一套数据。先在项目里新建 Models 文件夹,然后添加三个文件。

Models\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
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.ComponentModel;
using System.Runtime.CompilerServices;

namespace WpfTemplatePractice.Models;

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

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
{
if (SetField(ref _salary, value))
{
// 工资变了,区间字符串也跟着变,界面才能刷新。
OnPropertyChanged(nameof(SalaryBand));
}
}
}

public bool IsManager
{
get => _isManager;
set
{
if (SetField(ref _isManager, value))
{
OnPropertyChanged(nameof(Level));
}
}
}

// 供数据模板选择器判断用哪套模板。
public string Level => IsManager ? "主管" : "成员";

// 供数据触发器直接比较,把区间判断留在 C# 里。
public string SalaryBand => Salary >= 12000m ? "高" : Salary >= 9500m ? "中" : "低";

public event PropertyChangedEventHandler? PropertyChanged;

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

field = value;
OnPropertyChanged(propertyName);
return true;
}

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

Models\Department.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System.Collections.ObjectModel;

namespace WpfTemplatePractice.Models;

public class Department
{
public Department(string name, params Employee[] employees)
{
Name = name;
Employees = new ObservableCollection<Employee>(employees);
}

public string Name { get; }

public ObservableCollection<Employee> Employees { get; }

public int Count => Employees.Count;

public decimal TotalSalary => Employees.Sum(employee => employee.Salary);

public decimal AverageSalary =>
Employees.Count == 0 ? 0m : Employees.Average(employee => employee.Salary);
}

Models\SalaryBucket.cs:

1
2
3
4
5
6
7
8
9
10
11
namespace WpfTemplatePractice.Models;

public sealed class SalaryBucket
{
public required string RangeLabel { get; init; }

public required int Count { get; init; }

// 0 到 1 之间的比例,直方图用它决定柱子高度。
public required double Ratio { get; init; }
}

注意 Department 的三个统计属性都是只读计算属性:Count、TotalSalary、AverageSalary 没有对应的字段,每次读取都现算。这在展示统计值时很方便,但要注意:计算属性不会自己通知界面,后面第 2 章会讲怎么处理。

示例文件分工

随附示例项目的文件分工如下,跟做时可以照着对照:

文件 作用 主要出现在
Models\Employee.cs 员工模型,带属性变化通知 全部章节
Models\Department.cs 部门模型,含成员集合和统计 第 2 章
Models\SalaryBucket.cs 直方图的一个区间 第 6 章
ViewModels\MainViewModel.cs 全部示例数据与命令 全部章节
ViewModels\RelayCommand.cs 简易命令实现 第 3 章
Selectors\EmployeeTemplateSelector.cs 按数据挑模板 第 5 章
Converters\RatioToBarHeightConverter.cs 比例转柱高 第 6 章
MainWindow.xaml 六个页签的全部界面 全部章节
MainWindow.xaml.cs 只调用 InitializeComponent() 全部章节

示例项目的代码后台文件只有三行内容:

1
2
3
4
5
6
7
8
9
10
11
using System.Windows;

namespace WpfDataTemplateDemo;

public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

这是本篇希望达到的效果:界面逻辑写在 XAML 和 ViewModel 里,代码后台只负责初始化。当代码后台越来越长时,通常说明有逻辑该搬到 ViewModel 去了。


1. 基于界面控件绑定的尺寸均分处理

本节目标

学完本节,你应该能够:

  • 说清 Auto、固定数值、* 三种列宽的区别。
  • 用 2*、3* 这样的写法按比例分配剩余空间。
  • 用 SharedSizeGroup 让不同 Grid 里的列对齐。
  • 用 UniformGrid 把空间等分给每个子元素。
  • 通过 ItemsPanelTemplate 替换 ItemsControl 的布局面板。
  • 把 UniformGrid.Columns 绑定到 ViewModel 属性,运行时改变列数。

1.1 为什么宽度不能手算

先看一个常见场景:窗口里横向并排三个卡片。

初学者最容易写出的版本是这样:

1
2
3
4
5
<StackPanel Orientation="Horizontal">
<Border Width="300" Height="100" Background="#EAF1FF"/>
<Border Width="300" Height="100" Background="#EAF1FF"/>
<Border Width="300" Height="100" Background="#EAF1FF"/>
</StackPanel>

这段代码有三个问题:

  1. 窗口变宽时,右边会空出一大块。
  2. 窗口变窄时,会出现横向滚动条。
  3. 想把三列改成四列,必须改三个 Width。

Grid 的星号列可以一次解决这三个问题:宽度不再由你算,而是由 WPF 在布局阶段算。

1.2 星号列:按比例分配剩余空间

1
2
3
4
5
6
7
8
9
10
11
<Grid Height="92">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>

<Border Grid.Column="0" Background="#EAF1FF" CornerRadius="8" Margin="0,0,8,0"/>
<Border Grid.Column="1" Background="#EAF1FF" CornerRadius="8" Margin="0,0,8,0"/>
<Border Grid.Column="2" Background="#EAF1FF" CornerRadius="8"/>
</Grid>

运行后拖动窗口边缘,三块会一起变宽变窄,比例始终保持 1:1:1。

尺寸均分与布局容器

图 1:星号列按权重分配剩余空间,UniformGrid 把格子等分给子元素。

把第三列改成两倍权重:

1
2
3
4
5
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>

这时总权重是 4,三列各占 1/4、1/4、2/4。

星号列的计算规则可以记成一句话:

1
2
先给 Auto 和固定宽度的列分够,
剩下的空间再按星号前的数字当权重分配。

这一点很重要。如果写的是:

1
2
3
4
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>

第二列拿到的是「总宽度减 200」,不是总宽度的一半。

1.3 三种列宽的分工

写法 含义 典型用途
Auto 按内容需要的最小宽度 标签、按钮、图标列
120 固定 120 像素 编号列、状态灯列
* 按权重分剩余空间 主要内容区、自适应列

实际界面里三者经常混用。例如一个「图标 + 标题 + 操作按钮」的列表行:

1
2
3
4
5
6
7
8
9
10
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <!-- 图标:内容多宽就多宽 -->
<ColumnDefinition Width="*"/> <!-- 标题:占满中间 -->
<ColumnDefinition Width="Auto"/> <!-- 按钮:内容多宽就多宽 -->
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="★" FontSize="18"/>
<TextBlock Grid.Column="1" Text="{Binding Name}" Margin="10,0"/>
<Button Grid.Column="2" Content="查看" Padding="12,4"/>
</Grid>

中间列写成 *,标题变长时不会挤压两边的图标和按钮。

1.4 SharedSizeGroup:让不同 Grid 的列对齐

星号列只能在自己的 Grid 内部起作用。如果上下两块内容分属两个 Grid,它们的 Auto 列宽度会各算各的,看起来就参差不齐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="姓名"/>
<TextBlock Grid.Column="1" Text="张三"/>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="所属部门名称"/>
<TextBlock Grid.Column="1" Text="研发部"/>
</Grid>
</StackPanel>

两行的标签宽度不同,第二列起点就不在一条竖线上。SharedSizeGroup 可以把两列绑成一组,取组内最大值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<StackPanel Grid.IsSharedSizeScope="True">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="LabelColumn"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="姓名"/>
<TextBlock Grid.Column="1" Text="张三"/>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="LabelColumn"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="所属部门名称"/>
<TextBlock Grid.Column="1" Text="研发部"/>
</Grid>
</StackPanel>

使用 SharedSizeGroup 有两个前提:

  1. 组名必须完全一致,大小写敏感。
  2. 必须有一个祖先元素设置 Grid.IsSharedSizeScope="True",它管着这一片区域的共享计算。

注意:参与共享的列必须是 Auto。把 * 放进共享组不会得到期望的结果,因为星号列本来就要吸收剩余空间。

1.5 UniformGrid:把格子平均分给子元素

Grid 需要你手写每一列每一行。如果只是想把空间平均分给若干子元素,用 UniformGrid 更省事:

1
2
3
4
5
6
7
8
<UniformGrid Columns="3" Rows="2">
<Border Background="#F0F8F4" Margin="6" Height="80"/>
<Border Background="#F0F8F4" Margin="6" Height="80"/>
<Border Background="#F0F8F4" Margin="6" Height="80"/>
<Border Background="#F0F8F4" Margin="6" Height="80"/>
<Border Background="#F0F8F4" Margin="6" Height="80"/>
<Border Background="#F0F8F4" Margin="6" Height="80"/>
</UniformGrid>

UniformGrid 的特点:

  • 每个格子宽度相同、高度也相同。
  • 写了 Columns 就按列数排,写了 Rows 就按行数排,两个都写时以 Columns 优先。
  • 子元素数量超过格子数时,会自动增加行或列。
  • 格子之间没有间距属性,间距靠子元素的 Margin 实现。

UniformGrid 只按「格子」考虑,不考虑内容大小。如果每个格子里装的是长度不一的文字,格子仍然一样大,文字会换行或被裁切。这一点和第 1.2 节的星号列有本质区别:

容器 分配依据 适合场景
Grid + 星号列 你指定的权重 需要精确控制比例
UniformGrid 完全平均 卡片墙、缩略图、色块
WrapPanel 按子元素自身尺寸排列,放不下就换行 不定数量的标签、卡片

1.6 跟做:用 ItemsPanelTemplate 换掉列表的排布方式

ListBox、ItemsControl 这类控件默认用 StackPanel 排布子项。它们都提供一个 ItemsPanel 属性,可以换成别的面板。

先准备一个最小数据源。在 MainWindow.xaml 里加上:

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

别忘了在窗口根元素上补命名空间:

1
xmlns:viewModels="clr-namespace:WpfTemplatePractice.ViewModels"

然后新建 ViewModels\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
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using WpfTemplatePractice.Models;

namespace WpfTemplatePractice.ViewModels;

public class MainViewModel : INotifyPropertyChanged
{
private int _tileColumns = 4;

public MainViewModel()
{
Employees = new ObservableCollection<Employee>
{
new() { Id = 1, Name = "张三", Department = "研发部", Salary = 12000m, IsManager = true },
new() { Id = 2, Name = "李四", Department = "产品部", Salary = 10500m },
new() { Id = 3, Name = "王五", Department = "设计部", Salary = 9800m },
new() { Id = 4, Name = "赵六", Department = "测试部", Salary = 9200m },
new() { Id = 5, Name = "孙七", Department = "研发部", Salary = 14500m, IsManager = true },
new() { Id = 6, Name = "周八", Department = "测试部", Salary = 8800m },
new() { Id = 7, Name = "吴九", Department = "产品部", Salary = 11200m },
new() { Id = 8, Name = "郑十", Department = "设计部", Salary = 13200m, IsManager = true }
};
}

public ObservableCollection<Employee> Employees { get; }

public int TileColumns
{
get => _tileColumns;
set => SetField(ref _tileColumns, value);
}

public event PropertyChangedEventHandler? PropertyChanged;

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

field = value;
OnPropertyChanged(propertyName);
return true;
}

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

再把第 1 章的页签内容替换成:

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
<TabItem Header="1  尺寸均分">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="4,16,4,4">
<TextBlock Text="星号列:按比例均分可用宽度"
FontSize="18"
FontWeight="SemiBold"/>
<TextBlock Text="三列都写 Width=&quot;*&quot; 时,可用宽度被平均分成三份。"
Foreground="#5D6B82"
Margin="0,6,0,14"/>

<Grid Height="92">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="#EAF1FF" BorderBrush="#9DB8F2"
BorderThickness="1" CornerRadius="8" Margin="0,0,8,0"/>
<Border Grid.Column="1" Background="#EAF1FF" BorderBrush="#9DB8F2"
BorderThickness="1" CornerRadius="8" Margin="0,0,8,0"/>
<Border Grid.Column="2" Background="#EAF1FF" BorderBrush="#9DB8F2"
BorderThickness="1" CornerRadius="8"/>
</Grid>

<TextBlock Text="UniformGrid:把每一格平均分给子元素"
FontSize="18"
FontWeight="SemiBold"
Margin="0,26,0,10"/>

<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="60"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Columns" VerticalAlignment="Center"/>
<Slider Grid.Column="1"
Minimum="2"
Maximum="6"
TickFrequency="1"
IsSnapToTickEnabled="True"
Value="{Binding TileColumns,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Column="2"
Text="{Binding TileColumns}"
HorizontalAlignment="Right"
VerticalAlignment="Center"
FontSize="18"
FontWeight="SemiBold"
Foreground="#2F6FED"/>
</Grid>

<ItemsControl ItemsSource="{Binding Employees}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="{Binding TileColumns}"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#F7F9FD"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="8"
Padding="12"
Margin="6">
<StackPanel>
<TextBlock Text="{Binding Name}"
FontWeight="SemiBold"
Foreground="#172033"/>
<TextBlock Text="{Binding Department}"
Margin="0,4,0,0"
Foreground="#5D6B82"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</TabItem>

运行后拖动滑块,八张员工卡片会随着 Columns 在 2 到 6 之间重新排布。

这段 XAML 里有三个新知识点,逐个拆开看。

第一,ItemsPanelTemplate 是容器的模板,不是数据项的模板:

1
2
3
4
ItemsControl
├── ItemsPanel 决定子项怎么排(这里是 UniformGrid)
├── ItemTemplate 决定每一个子项长什么样(这里是 Border + StackPanel)
└── ItemsSource 决定排什么数据(这里是 Employees)

第二,ItemsPanelTemplate 里的 UniformGrid 居然能写绑定:

1
<UniformGrid Columns="{Binding TileColumns}"/>

这里绑定的来源是 ItemsControl 的 DataContext,也就是窗口的 MainViewModel。模板内部的数据上下文会一路继承下来,所以 TileColumns 能被找到。

第三,Slider.Value 的绑定必须写 UpdateSourceTrigger=PropertyChanged:

1
Value="{Binding TileColumns, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"

Slider 的 Value 属性默认触发方式是 PropertyChanged,但显式写出来更清楚,也提醒我们:只有来源值真的变了,界面才会重排。

1.7 跟做检查点

做完本节,请确认:

检查项 期望结果
拖动窗口边缘 三块蓝色区域等比缩放,右侧不留空白
把第三列改成 2* 第三块明显比前两块宽,比例 1:1:2
拖动滑块 卡片列数在 2 到 6 之间变化,卡片宽度自动重算
把 UniformGrid 换成 WrapPanel 卡片按自身宽度排列,放不下就换行

如果拖动滑块时卡片没反应,先检查两处:MainViewModel 是否实现了 INotifyPropertyChanged,TileColumns 的 setter 是否调用了 SetField。

1.8 小练习

  1. 做一个左右分栏:左侧固定 260 像素,右侧自适应,拖动窗口时只有右侧变化。
  2. 做一个三行两列的 UniformGrid,六个格子里放不同的部门名称。
  3. 把 SharedSizeGroup 用在三个上下排列的表单行上,让「姓名」「所属部门」「联系电话」三个标签左对齐。

2. 多区域整合绑定的数据关系

本节目标

学完本节,你应该能够:

  • 用 SelectedItem 把列表的选中项写回 ViewModel。
  • 用 ElementName 让一个区域的 ItemsSource 来自另一个区域的选中项。
  • 用局部 DataContext 简化详情区的绑定路径。
  • 说清三个区域之间的数据流向。
  • 处理计算属性不通知界面的问题。

2.1 一个界面,三个区域

先看要做的界面:左边是部门列表,中间是这个部门的成员列表,右边是选中成员的详情和统计。

多区域整合绑定的数据关系

图 2:区域一决定区域二显示什么,区域二决定区域三显示什么。

三个区域之间存在一条清晰的链条:

1
2
3
4
5
6
7
区域一(部门列表)
│ 选中某个部门
v
区域二(成员列表)的 ItemsSource 换成这个部门的 Employees
│ 选中某个成员
v
区域三(详情)的 DataContext 换成这个成员

这是初学者最容易做错的地方:很多人会在选择变化时写事件处理代码,手动刷新第二个列表。其实在 WPF 里,这条链完全可以靠绑定自动完成。

2.2 区域一:把选中项写回 ViewModel

ListBox 有 SelectedItem 属性。它默认是双向的,但推荐显式写出 Mode=TwoWay,避免以后改样式时出意外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ListBox x:Name="DepartmentList"
ItemsSource="{Binding Departments}"
SelectedItem="{Binding SelectedDepartment, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Margin="2,6">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
<TextBlock Foreground="#5D6B82">
<Run Text="{Binding Count, Mode=OneWay}"/>
<Run Text=" 人"/>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

这里 x:Name="DepartmentList" 很关键:后面两个区域要靠这个名字找到它。

ViewModel 里对应的两个属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public ObservableCollection<Department> Departments { get; }

public Department? SelectedDepartment
{
get => _selectedDepartment;
set
{
if (SetField(ref _selectedDepartment, value))
{
// 部门变了,依赖它的派生属性要通知界面。
OnPropertyChanged(nameof(SelectedDepartmentEmployees));
}
}
}

public ObservableCollection<Employee>? SelectedDepartmentEmployees =>
SelectedDepartment?.Employees;

注意 SelectedDepartmentEmployees 的写法:它是一个只读的派生属性,返回当前部门的成员集合。当 SelectedDepartment 变化时,必须手动通知它,否则界面不会重新取值。

2.3 区域二:用 ElementName 跨区域取数据

中间这个列表的数据来源不在 ViewModel 里,而在另一个控件里:

1
2
3
4
5
6
7
8
9
10
11
12
13
<ListBox x:Name="EmployeeList"
ItemsSource="{Binding SelectedItem.Employees, ElementName=DepartmentList}"
SelectedItem="{Binding SelectedEmployee, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Margin="2,6">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Salary, StringFormat={}{0:C0}}"
Foreground="#5D6B82"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

把这一句拆开读:

1
2
3
4
5
6
SelectedItem.Employees
│ │
│ └── 部门对象上的成员集合
└── 区域一控件的选中项
ElementName=DepartmentList
└── 去哪个控件找 SelectedItem

整句的意思是:把「部门列表」控件当前选中项的 ​​Employees​​ ** 属性,作为本列表的数据源**。

这样写的好处是链式变化全部自动完成:

  1. 用户点「设计部」。
  2. DepartmentList.SelectedItem 变成设计部对象。
  3. WPF 通知所有引用它的绑定。
  4. EmployeeList.ItemsSource 重新取值为设计部的成员集合。
  5. EmployeeList 重新生成子项。

一行事件处理代码都不用写。

2.4 区域三:用局部 DataContext 缩短绑定路径

右侧详情区如果不用局部 DataContext,每个文本框都要写很长的路径:

1
2
3
<TextBox Text="{Binding SelectedItem.Name, ElementName=EmployeeList, Mode=TwoWay}"/>
<TextBox Text="{Binding SelectedItem.Department, ElementName=EmployeeList, Mode=TwoWay}"/>
<TextBox Text="{Binding SelectedItem.Salary, ElementName=EmployeeList, Mode=TwoWay}"/>

更清晰的写法是先把选中项设为这一块的 DataContext:

1
2
3
4
5
6
7
8
9
10
11
12
13
<StackPanel DataContext="{Binding SelectedItem, ElementName=EmployeeList}">
<TextBlock Text="姓名" Foreground="#5D6B82"/>
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>

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

<TextBlock Text="工资" Foreground="#5D6B82"/>
<TextBox Text="{Binding Salary,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged,
StringFormat={}{0:F0}}"/>
</StackPanel>

DataContext 是可继承的依赖属性。在 StackPanel 上设置后,内部所有子元素的绑定都会以这个员工对象为起点。于是 {Binding Name} 直接命中 Employee.Name,路径短,出错概率低。

这也解释了为什么前面的例子都写 {Binding Xxx} 而不写完整路径:它们都是从某个 DataContext 出发的相对路径。

2.5 统计信息:跨区域引用

统计要跟着区域一(部门)变化,而控件在区域三(详情卡片)里。用 ElementName 指回去即可:

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
<TextBlock Text="统计(跟随区域一)"
Margin="0,10,0,6"
FontWeight="SemiBold"
Foreground="#172033"/>

<TextBlock Foreground="#5D6B82">
<Run Text="人数:"/>
<Run Text="{Binding SelectedItem.Employees.Count,
ElementName=DepartmentList,
Mode=OneWay}"/>
</TextBlock>

<TextBlock Margin="0,4,0,0" Foreground="#5D6B82">
<Run Text="工资合计:"/>
<Run Text="{Binding SelectedItem.TotalSalary,
ElementName=DepartmentList,
Mode=OneWay,
StringFormat={}{0:C0}}"/>
</TextBlock>

<TextBlock Margin="0,4,0,0" Foreground="#5D6B82">
<Run Text="平均工资:"/>
<Run Text="{Binding SelectedItem.AverageSalary,
ElementName=DepartmentList,
Mode=OneWay,
StringFormat={}{0:C0}}"/>
</TextBlock>

这里的路径又跨了一层:

1
2
3
4
5
DepartmentList.SelectedItem           区域一的选中部门
DepartmentList.SelectedItem.Employees 这个部门的成员集合
...Employees.Count 集合里的成员数量
...TotalSalary Department 上的计算属性
...AverageSalary 同上

2.6 三个区域的数据流向图

把三个区域的绑定汇总成一张表:

区域 控件 绑定属性 来源 流向
一 ListBox ItemsSource {Binding Departments} 来源到目标
一 ListBox SelectedItem {Binding SelectedDepartment} 双向
二 ListBox ItemsSource {Binding SelectedItem.Employees, ElementName=DepartmentList} 来源到目标
二 ListBox SelectedItem {Binding SelectedEmployee} 双向
三 StackPanel DataContext {Binding SelectedItem, ElementName=EmployeeList} 来源到目标
三 TextBox Text {Binding Name} 等 双向
三 TextBlock Text {Binding SelectedItem.TotalSalary, ElementName=DepartmentList} 来源到目标

可以总结成一条经验:

1
2
3
需要「选择」的东西,绑到 ViewModel 的属性,双向。
需要「跟随另一个控件」的东西,用 ElementName,单向。
一个区域里绑定太多重复前缀时,先把选中项设为局部 DataContext。

2.7 计算属性不通知界面怎么办

Department.Count、TotalSalary、AverageSalary 都是没有 set 的计算属性。它们本身不会发通知。

所以在两种情况下统计会不刷新:

  1. 成员集合里增删了员工。
  2. 某个员工的工资改了。

在随附示例里,第 3 章的按钮命令会显式补上通知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void RaiseSalary(Employee? employee)
{
if (employee is null)
{
return;
}

employee.Salary += 1000m;

// 工资变了,集合里的计算属性要手动通知。
OnPropertyChanged(nameof(AverageSalary));
OnPropertyChanged(nameof(TotalSalary));
OnPropertyChanged(nameof(MaxSalary));
RefreshSalaryBuckets();
}

更省事的做法是让 Department 自己订阅每个员工的 PropertyChanged,在工资变化时再通知自己的三个统计属性。这种方式叫「聚合通知」,适合成员数量不多、变动频繁的场景。作为练习,可以在自己的项目里试着实现它。

2.8 跟做检查点

检查项 期望结果
点「设计部」 中间列表立刻变成设计部的两名成员
点中间列表的「王五」 右侧姓名变成「王五」,工资变成 $9,800
在右侧把工资改成 15000 中间列表里对应项的金额同步变成 $15,000
点「测试部」 右侧统计的合计和平均都按测试部重算

如果中间列表一直是空的,先检查 x:Name="DepartmentList" 是否拼写一致。ElementName 写错时不会抛异常,绑定会静默失败,只会往「输出」窗口写一条错误。

2.9 小练习

  1. 在三个区域的基础上加第四个区域:部门成员人数超过 2 时显示「人数较多」提示。
  2. 把统计区的「平均工资」改成百分比进度条,用 IValueConverter 把平均值映射成 0 到 1 的比例。
  3. 去掉区域二的 ElementName,改成在 SelectedDepartment 变化时手动刷新集合,比较两种做法的代码量。

3. 集合数据类型的绑定与数据呈现

本节目标

学完本节,你应该能够:

  • 说清 ObservableCollection<T> 和 List<T> 在绑定上的区别。
  • 用 ItemsControl、ListBox、DataGrid 呈现同一份集合。
  • 用 ICommand 让按钮驱动集合变化。
  • 理解「集合通知」和「对象通知」是两件事。
  • 在集合增删改后让统计数字同步刷新。

3.1 为什么必须用 ObservableCollection

把 List<Employee> 绑到 ListBox,界面能显示数据,但新增一项不会出现在界面上。原因是 List<T> 没有变化通知机制。

1
2
3
// 这样绑定,运行后界面能看到 8 个人,
// 但再调用 Add 时界面不会更新。
public List<Employee> Employees { get; } = new();

ObservableCollection<T> 实现了 INotifyCollectionChanged。集合每发生一次增删,都会发出通知,绑定系统收到后更新界面:

1
2
3
using System.Collections.ObjectModel;

public ObservableCollection<Employee> Employees { get; }

两者的差别可以记成一句话:

1
ObservableCollection<T> = List<T> + 集合变化通知

ObservableCollection<T> 在命名空间 System.Collections.ObjectModel 下。它同样支持 Add、Remove、Insert、Clear,用法和 List<T> 基本一致。

3.2 集合通知和对象通知是两件事

这一点是初学者最容易混淆的。看下面两种操作:

操作 需要谁发通知 依赖的接口
往集合里 Add 一个新员工 集合 INotifyCollectionChanged
改某个已有员工的 Salary 员工对象 INotifyPropertyChanged

ObservableCollection<Employee> 只负责「集合成员变了」。集合里某个员工的属性变化,它完全不知道,也不会转发。所以 Employee 必须自己实现 INotifyPropertyChanged。

两者配合起来,界面才能做到:新增一行立刻出现,改工资立刻刷新数字。

场景 集合用 元素用
列表固定不变,只改属性 List<T> 也可以 INotifyPropertyChanged
列表会增删,属性也可能变 ObservableCollection<T> INotifyPropertyChanged
只读展示、从不变化 List<T> 也可以 普通属性也可以

示例项目里的 Employee 同时做了两件容易漏掉的事:

1
2
3
4
5
6
7
8
9
10
11
12
13
public decimal Salary
{
get => _salary;
set
{
if (SetField(ref _salary, value))
{
// SalaryBand 是根据 Salary 算出来的,
// Salary 变了它就必须一起通知,否则第 4 章的触发器不会响应。
OnPropertyChanged(nameof(SalaryBand));
}
}
}

这是「派生属性必须手动通知」的又一例,和第 2 章 Department 的统计属性是同一个道理。

3.3 一份集合,三种容器

同一份 Employees,可以同时绑到三种容器上,它们显示的内容完全一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 表格:适合看结构化字段 -->
<DataGrid ItemsSource="{Binding Employees}"
SelectedItem="{Binding SelectedEmployee, Mode=TwoWay}"
AutoGenerateColumns="False"
CanUserAddRows="False"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="编号" Binding="{Binding Id}" Width="80"/>
<DataGridTextColumn Header="姓名" Binding="{Binding Name}" Width="*"/>
<DataGridTextColumn Header="部门" Binding="{Binding Department}" Width="*"/>
<DataGridCheckBoxColumn Header="主管" Binding="{Binding IsManager}" Width="90"/>
<DataGridTextColumn Header="工资"
Binding="{Binding Salary, StringFormat={}{0:C0}}"
Width="140"/>
</DataGrid.Columns>
</DataGrid>
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
<!-- 卡片:适合展示少量字段 -->
<ItemsControl ItemsSource="{Binding Employees}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#F7F9FD"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="8"
Padding="12"
Margin="0,0,10,10"
Width="170">
<StackPanel>
<TextBlock Text="{Binding Name}"
FontWeight="SemiBold"
Foreground="#172033"/>
<TextBlock Text="{Binding Department}"
Margin="0,4,0,0"
Foreground="#5D6B82"/>
<TextBlock Text="{Binding Salary, StringFormat={}{0:C0}}"
Margin="0,4,0,0"
Foreground="#2F6FED"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

三种容器的定位可以这样记:

容器 默认面板 是否有选中概念 适合场景
ItemsControl StackPanel 没有 纯展示、卡片墙
ListBox VirtualizingStackPanel 有 SelectedItem 需要用户选择
DataGrid 行列布局 有 SelectedItem,还支持编辑 表格、多列对比

注意 ItemsControl 没有 SelectedItem 属性。如果你需要「选中某项」这个概念,就要换成 ListBox 或 DataGrid。

3.4 用命令驱动集合变化

按钮直接写 Click 事件处理也行,但在绑定风格的项目里更推荐用命令:命令把「做什么」放在 ViewModel 里,界面只负责调用。

先写一个最小命令实现 ViewModels\RelayCommand.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
using System.Windows.Input;

namespace WpfTemplatePractice.ViewModels;

public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;

public RelayCommand(
Action<object?> execute,
Func<object?, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}

public event EventHandler? CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}

public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke(parameter) ?? true;
}

public void Execute(object? parameter)
{
_execute(parameter);
}
}

CanExecuteChanged 挂在 CommandManager.RequerySuggested 上,是一个偷懒但实用的做法:WPF 会在界面交互后自动重新询问所有命令能否执行,不需要手写刷新逻辑。

再在 ViewModel 里加三个命令:

1
2
3
4
5
public ICommand AddEmployeeCommand { get; }

public ICommand RemoveEmployeeCommand { get; }

public ICommand RaiseSalaryCommand { get; }
1
2
3
4
5
6
7
8
9
AddEmployeeCommand = new RelayCommand(_ => AddEmployee());

RemoveEmployeeCommand = new RelayCommand(
parameter => RemoveEmployee(parameter as Employee),
parameter => parameter is Employee);

RaiseSalaryCommand = new RelayCommand(
parameter => RaiseSalary(parameter as Employee),
parameter => parameter is Employee);

第二个参数是「能不能执行」。parameter is Employee 表示没选中员工时按钮自动变灰,不需要手写 IsEnabled 绑定。

三个命令的实现:

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
private void AddEmployee()
{
var nextId = Employees.Count == 0
? 1
: Employees.Max(employee => employee.Id) + 1;

var employee = new Employee
{
Id = nextId,
Name = $"新员工 {nextId}",
Department = SelectedDepartment?.Name ?? "待分配",
Salary = 9000m
};

Employees.Add(employee);
SelectedDepartment?.Employees.Add(employee);
SelectedEmployee = employee;
RefreshSalaryBuckets();
}

private void RemoveEmployee(Employee? employee)
{
if (employee is null)
{
return;
}

Employees.Remove(employee);
foreach (var department in Departments)
{
department.Employees.Remove(employee);
}

if (ReferenceEquals(SelectedEmployee, employee))
{
SelectedEmployee = Employees.FirstOrDefault();
}

RefreshSalaryBuckets();
}

private void RaiseSalary(Employee? employee)
{
if (employee is null)
{
return;
}

employee.Salary += 1000m;
OnPropertyChanged(nameof(AverageSalary));
OnPropertyChanged(nameof(TotalSalary));
OnPropertyChanged(nameof(MaxSalary));
RefreshSalaryBuckets();
}

注意 AddEmployee 里的两行:

1
2
Employees.Add(employee);                    // 总表加一份
SelectedDepartment?.Employees.Add(employee); // 当前部门也加一份

因为区域二的成员列表绑的是 SelectedDepartment.Employees,只往总表加是不够的。这是「一份数据出现在多个集合里」的典型处理方式:同一个对象引用被两个集合持有,两边的通知都会各自发出。

界面上对应的按钮:

1
2
3
4
5
6
7
8
9
10
<StackPanel Orientation="Horizontal">
<Button Content="新增员工"
Command="{Binding AddEmployeeCommand}"/>
<Button Content="工资 +1000"
Command="{Binding RaiseSalaryCommand}"
CommandParameter="{Binding SelectedEmployee}"/>
<Button Content="删除选中"
Command="{Binding RemoveEmployeeCommand}"
CommandParameter="{Binding SelectedEmployee}"/>
</StackPanel>

CommandParameter 把当前选中的员工传给命令。命令内部拿到的是 object,所以要用 as Employee 转回来。

3.5 统计数字:跟随集合变化

第 2 章已经讲过,计算属性不会自己通知。这里再补一个细节:统计属性依赖的是集合,集合增删时它们必须重算。

1
2
3
4
5
6
7
8
9
public int EmployeeCount => Employees.Count;

public decimal AverageSalary =>
Employees.Count == 0 ? 0m : Employees.Average(employee => employee.Salary);

public decimal TotalSalary => Employees.Sum(employee => employee.Salary);

public decimal MaxSalary =>
Employees.Count == 0 ? 0m : Employees.Max(employee => employee.Salary);

示例项目把这些通知统一放在 RefreshSalaryBuckets() 里,每次集合或工资变化后调用一次:

1
2
3
4
OnPropertyChanged(nameof(EmployeeCount));
OnPropertyChanged(nameof(AverageSalary));
OnPropertyChanged(nameof(TotalSalary));
OnPropertyChanged(nameof(MaxSalary));

这种「改完数据统一刷新一批派生属性」的写法,比在每处改动后零散地补通知更不容易漏。

3.6 跟做:搭出第 3 章页签

把第 3 章的页签内容写成:

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
<TabItem Header="3  集合绑定">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="4,16,4,4">
<StackPanel Orientation="Horizontal">
<Button Content="新增员工" Command="{Binding AddEmployeeCommand}"/>
<Button Content="工资 +1000"
Command="{Binding RaiseSalaryCommand}"
CommandParameter="{Binding SelectedEmployee}"/>
<Button Content="删除选中"
Command="{Binding RemoveEmployeeCommand}"
CommandParameter="{Binding SelectedEmployee}"/>
</StackPanel>

<Grid Margin="0,10,0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="人数" Foreground="#5D6B82"/>
<TextBlock Text="{Binding EmployeeCount}"
FontSize="22"
FontWeight="SemiBold"
Foreground="#2F6FED"/>
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="平均工资" Foreground="#5D6B82"/>
<TextBlock Text="{Binding AverageSalary, StringFormat={}{0:C0}}"
FontSize="22"
FontWeight="SemiBold"
Foreground="#2F6FED"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="工资合计" Foreground="#5D6B82"/>
<TextBlock Text="{Binding TotalSalary, StringFormat={}{0:C0}}"
FontSize="22"
FontWeight="SemiBold"
Foreground="#2F6FED"/>
</StackPanel>
<StackPanel Grid.Column="3">
<TextBlock Text="最高工资" Foreground="#5D6B82"/>
<TextBlock Text="{Binding MaxSalary, StringFormat={}{0:C0}}"
FontSize="22"
FontWeight="SemiBold"
Foreground="#2F6FED"/>
</StackPanel>
</Grid>

<DataGrid ItemsSource="{Binding Employees}"
SelectedItem="{Binding SelectedEmployee, Mode=TwoWay}"
AutoGenerateColumns="False"
CanUserAddRows="False"
MinHeight="280">
<DataGrid.Columns>
<DataGridTextColumn Header="编号" Binding="{Binding Id}" Width="80"/>
<DataGridTextColumn Header="姓名" Binding="{Binding Name}" Width="*"/>
<DataGridTextColumn Header="部门" Binding="{Binding Department}" Width="*"/>
<DataGridCheckBoxColumn Header="主管" Binding="{Binding IsManager}" Width="90"/>
<DataGridTextColumn Header="工资"
Binding="{Binding Salary, StringFormat={}{0:C0}}"
Width="140"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</ScrollViewer>
</TabItem>

先点「新增员工」,观察列表和四个统计数字同时变化。再选中某一行点「工资 +1000」,观察平均工资和合计一起变大。

3.7 跟做检查点

检查项 期望结果
点「新增员工」 列表末尾多一行「新员工 9」,人数从 8 变 9
点「工资 +1000」 选中行工资加 1000,平均工资和合计同步变大
没选中任何行时 「工资 +1000」和「删除选中」两个按钮自动变灰
点「删除选中」 行消失,人数和合计同步减少
在 DataGrid 里改工资 同一份数据的卡片视图同步变化

如果点「新增员工」后列表没变化,八成是把 ObservableCollection 写成了 List,或者忘了在 Employees 上发出集合通知(用 ObservableCollection 就不会有这个问题)。

3.8 小练习

  1. 增加「按部门筛选」下拉框,用 CollectionViewSource 或 FilteredEmployees 实现。
  2. 给 DataGrid 的工资列加排序,观察排序是否会改变源集合顺序。
  3. 把「新增员工」改成从文本框中取姓名,练习 TextBox 双向绑定和命令参数配合。

4. 数据模板触发器

本节目标

学完本节,你应该能够:

  • 在 DataTemplate.Triggers 里写 DataTrigger。
  • 用 Setter 的 TargetName 精确控制模板内部的控件。
  • 说明为什么「按数据值切换外观」要放在模板里,而不是写事件处理。
  • 知道触发器适合什么、不适合什么。
  • 用滑块改数据,实时看到触发器生效。

4.1 需求:同一套模板,不同外观

假设员工列表要求:工资高的行偏绿,中的偏黄,低的偏红。

一种做法是给每种情况各写一套 DataTemplate,再用选择器切换。但这里三种情况除了颜色完全一样,复制三份模板会造成维护负担。

更好的做法是只写一套模板,在里面声明「当数据满足某条件时,改这些属性」:

模板触发器、模板选择器与直方图

图 3:模板触发器改一套模板的外观,模板选择器换整套模板,直方图把统计结果画出来。

4.2 先给数据一个可比较的字段

DataTrigger 的 Binding 可以绑定任意属性,Value 与它做相等比较。

最容易踩的坑是直接绑定 Salary 然后写 Value="12000":这样只有工资恰好等于 12000 时才生效,工资是 12001 就不匹配了。触发器做的是相等比较,不是范围比较。

所以正确做法是在模型里先算好一个区间字符串:

1
2
// 供数据触发器直接比较,把区间判断留在 C# 里。
public string SalaryBand => Salary >= 12000m ? "高" : Salary >= 9500m ? "中" : "低";

触发器只需要比较三个字符串:"高"、"中"、"低"。

这样做还有一个好处:区间规则集中在 C# 里,以后要改分界线(比如改成 10000),只改一处,XAML 完全不用动。

做法 优点 缺点
绑定 Salary 直接比数值 不需要额外属性 只能相等比较,无法表达区间
绑定 SalaryBand 比字符串 区间规则集中在 C#,XAML 简单 需要多写一个派生属性
用 IValueConverter 转换颜色 规则可复用 转换器多了以后不好管理

示例项目采用第二种,因为第 5 章的选择器正好也要用 Level 这样的派生属性,风格统一。

4.3 写一个带触发器的模板

先在 Window.Resources 里定义模板:

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
<DataTemplate x:Key="TriggeredEmployeeTemplate">
<Border x:Name="RowBorder"
Background="White"
BorderBrush="#E3EAF5"
BorderThickness="1"
CornerRadius="8"
Padding="14"
Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>

<StackPanel>
<TextBlock Text="{Binding Name}"
FontSize="16"
FontWeight="SemiBold"
Foreground="#172033"/>
<TextBlock Text="{Binding Department}"
Margin="0,4,0,0"
Foreground="#5D6B82"/>
</StackPanel>

<TextBlock x:Name="SalaryText"
Grid.Column="1"
Text="{Binding Salary, StringFormat={}{0:C0}}"
FontSize="16"
FontWeight="SemiBold"
VerticalAlignment="Center"
Foreground="#5D6B82"/>
</Grid>
</Border>

<DataTemplate.Triggers>
<DataTrigger Binding="{Binding SalaryBand}" Value="高">
<Setter TargetName="SalaryText" Property="Foreground" Value="#14804A"/>
<Setter TargetName="RowBorder" Property="Background" Value="#F0F8F4"/>
<Setter TargetName="RowBorder" Property="BorderBrush" Value="#B9DCC8"/>
</DataTrigger>
<DataTrigger Binding="{Binding SalaryBand}" Value="中">
<Setter TargetName="SalaryText" Property="Foreground" Value="#8A4B12"/>
<Setter TargetName="RowBorder" Property="Background" Value="#FFF8E8"/>
<Setter TargetName="RowBorder" Property="BorderBrush" Value="#E8D19A"/>
</DataTrigger>
<DataTrigger Binding="{Binding SalaryBand}" Value="低">
<Setter TargetName="SalaryText" Property="Foreground" Value="#9B2C2C"/>
<Setter TargetName="RowBorder" Property="Background" Value="#FDF1F1"/>
<Setter TargetName="RowBorder" Property="BorderBrush" Value="#E9BDBD"/>
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>

这里有两个关键点。

第一,x:Name 是触发器能工作的前提。Setter 的 TargetName 只能指向同一模板内部已经命名过的元素:

1
<Setter TargetName="RowBorder" Property="Background" Value="#F0F8F4"/>

这行表示:找到模板里名叫 RowBorder 的 Border,把它的 Background 设成浅绿。

第二,触发器写在 DataTemplate.Triggers 里,不是写在 Border.Triggers 里。写在模板的触发器集合里,才拿得到模板内部元素的名字。

4.4 把模板用到列表上

1
2
<ItemsControl ItemsSource="{Binding Employees}"
ItemTemplate="{StaticResource TriggeredEmployeeTemplate}"/>

ItemTemplate 指向资源里的模板。运行后可以看到三种底色自动分布。

4.5 触发器的执行顺序和优先级

如果多个触发器同时命中,后面的会覆盖前面的,这和样式规则的「就近优先」是同一种思路。

示例里的三个条件互斥(SalaryBand 只可能是三者之一),所以不会冲突。但如果写出这样的组合:

1
2
3
4
5
6
<DataTrigger Binding="{Binding Department}" Value="研发部">
<Setter TargetName="RowBorder" Property="Background" Value="#EAF1FF"/>
</DataTrigger>
<DataTrigger Binding="{Binding SalaryBand}" Value="高">
<Setter TargetName="RowBorder" Property="Background" Value="#F0F8F4"/>
</DataTrigger>

研发部的高工资员工会得到后面的绿色,因为后声明的触发器覆盖了前面的。要避免这种歧义,最好让条件之间互斥,或者把更「重要」的规则放在后面。

4.6 触发器的三种常见类型

DataTemplate.Triggers 里可以放的不止 DataTrigger:

类型 触发条件 典型用途
DataTrigger 数据值满足比较 按业务数据切换外观
Trigger 依赖属性的值满足比较 按控件自身状态切换(如 IsMouseOver)
MultiDataTrigger 多个数据条件同时满足 组合条件,如「主管 + 高工资」
EventTrigger 发生某个路由事件 播放动画

组合条件可以这样写:

1
2
3
4
5
6
7
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsManager}" Value="True"/>
<Condition Binding="{Binding SalaryBand}" Value="高"/>
</MultiDataTrigger.Conditions>
<Setter TargetName="RowBorder" Property="BorderThickness" Value="2"/>
</MultiDataTrigger>

Condition 用的是 Binding,不是 DataTrigger 的 Binding,但含义相同:全部条件都满足才执行 Setter。

4.7 跟做:用滑块看触发器实时生效

在页签里加一块控制区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<ListBox ItemsSource="{Binding Employees}"
SelectedItem="{Binding SelectedEmployee, Mode=TwoWay}"
DisplayMemberPath="Name"
MaxHeight="150"/>

<StackPanel DataContext="{Binding SelectedEmployee}" Margin="0,14,0,0">
<TextBlock Foreground="#5D6B82">
<Run Text="当前员工:"/>
<Run Text="{Binding Name, Mode=OneWay}"/>
<Run Text=",区间:"/>
<Run Text="{Binding SalaryBand, Mode=OneWay}"/>
</TextBlock>
<Slider Minimum="7000"
Maximum="16000"
TickFrequency="500"
IsSnapToTickEnabled="True"
Margin="0,10,0,0"
Value="{Binding Salary,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>

这里 StackPanel 的 DataContext 直接绑到 SelectedEmployee,内部的 {Binding Salary} 就落在员工对象上,滑块拖动时直接改员工工资。

把工资从 12000 拖到 11000 以下,卡片底色会立刻从浅绿变浅黄;再拖到 9000 以下变浅红。

4.8 为什么不用事件处理做这件事

如果用 ValueChanged 事件,每次拖动都要:

  1. 读取新工资。
  2. 判断落在哪个区间。
  3. 找到对应的视觉元素。
  4. 手动改它的背景色和前景色。

而触发器版本把「什么条件下变成什么样」声明在 XAML 里,数据一变外观自动跟上,代码里一行判断都不用写。

这也是 WPF 的一个核心思路:把「状态与外观的对应关系」声明出来,而不是在代码里一步步地改。

4.9 跟做检查点

检查项 期望结果
运行程序 列表里出现三种底色,分别对应高、中、低
拖动滑块越过 12000 卡片底色和金额颜色立刻切换
把某个 x:Name 写错 运行时抛异常或静默不生效,「输出」窗口有提示
删掉一个 DataTrigger 对应区间的卡片恢复模板的默认外观

如果改颜色完全没反应,先确认三件事:SalaryBand 有没有在 Salary 变化时发出通知;DataTemplate.Triggers 是否写在模板内部;TargetName 与 x:Name 是否完全一致。

4.10 小练习

  1. 增加第四个 DataTrigger:部门为「研发部」时把左边加一条 4 像素的蓝色竖条。
  2. 用 MultiDataTrigger 让「主管 + 高工资」的卡片边框加粗。
  3. 把触发器改成 Trigger,当鼠标悬停在卡片上时改变背景色。

5. 数据模板选择器

本节目标

学完本节,你应该能够:

  • 说清模板选择器和模板触发器的分工。
  • 写一个继承 DataTemplateSelector 的类。
  • 在 XAML 里把两套模板交给选择器。
  • 用 ItemTemplateSelector 替换 ItemTemplate。
  • 处理「选择器不重新求值」这个常见困惑。

5.1 什么时候需要换整套模板

第 4 章的触发器只改属性,模板结构不变。但有些需求是结构层面的:

  • 主管的卡片要显示「主管」标签、蓝色描边、更大的字号。
  • 普通成员的卡片只是简单的白底一行。

结构不同时,用 Setter 一个个改属性会越来越笨重。这时应该准备两套完整的模板,让程序在运行时决定用哪一套。

这正是 DataTemplateSelector 的用途。

5.2 模板选择器和模板触发器的分工

对比项 数据模板触发器 数据模板选择器
模板数量 一套 多套
决策位置 XAML 里声明 C# 里判断
能改什么 已命名元素的属性 整套结构
适合场景 只换颜色、粗细、可见性 结构差异大
判断复杂度 简单相等/组合条件 任意 C# 逻辑

两者可以叠加使用:选择器选出「主管模板」,主管模板内部再用触发器按工资区间换颜色。

5.3 写选择器类

新建 Selectors\EmployeeTemplateSelector.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
using System.Windows;
using System.Windows.Controls;
using WpfTemplatePractice.Models;

namespace WpfTemplatePractice.Selectors;

public class EmployeeTemplateSelector : DataTemplateSelector
{
public DataTemplate? ManagerTemplate { get; set; }

public DataTemplate? MemberTemplate { get; set; }

public override DataTemplate? SelectTemplate(
object item,
DependencyObject container)
{
if (item is Employee employee)
{
return employee.IsManager ? ManagerTemplate : MemberTemplate;
}

return base.SelectTemplate(item, container);
}
}

要点:

  1. 必须继承 DataTemplateSelector,命名空间是 System.Windows.Controls。
  2. 重写 SelectTemplate(object item, DependencyObject container)。
  3. item 就是当前正在渲染的那条数据。
  4. 返回 null 表示「交给 WPF 用默认方式处理」。
  5. 两个 DataTemplate 属性由 XAML 赋值,所以必须写成公开属性。

5.4 准备两套模板

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
<DataTemplate x:Key="ManagerCardTemplate">
<Border Background="#EAF1FF"
BorderBrush="#2F6FED"
BorderThickness="1"
CornerRadius="10"
Padding="16"
Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="主管"
FontSize="13"
FontWeight="SemiBold"
Foreground="#174EA6"/>
<TextBlock Text="{Binding Name}"
Margin="0,6,0,0"
FontSize="17"
FontWeight="SemiBold"
Foreground="#172033"/>
<TextBlock Margin="0,4,0,0" Foreground="#5D6B82">
<Run Text="{Binding Department, Mode=OneWay}"/>
<Run Text=" | "/>
<Run Text="{Binding Salary, Mode=OneWay, StringFormat={}{0:C0}}"/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>

<DataTemplate x:Key="MemberCardTemplate">
<Border Background="White"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="10"
Padding="16"
Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="成员" FontSize="13" Foreground="#5D6B82"/>
<TextBlock Text="{Binding Name}"
Margin="0,6,0,0"
FontSize="17"
FontWeight="SemiBold"
Foreground="#172033"/>
<TextBlock Margin="0,4,0,0" Foreground="#5D6B82">
<Run Text="{Binding Department, Mode=OneWay}"/>
<Run Text=" | "/>
<Run Text="{Binding Salary, Mode=OneWay, StringFormat={}{0:C0}}"/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>

两套模板结构相似但视觉层级明显不同,一眼能分辨主管和成员。

5.5 把选择器接到列表上

1
2
3
<selectors:EmployeeTemplateSelector x:Key="EmployeeTemplateSelector"
ManagerTemplate="{StaticResource ManagerCardTemplate}"
MemberTemplate="{StaticResource MemberCardTemplate}"/>
1
2
<ItemsControl ItemsSource="{Binding Employees}"
ItemTemplateSelector="{StaticResource EmployeeTemplateSelector}"/>

注意用的是 ItemTemplateSelector,不是 ItemTemplate。两者不能同时生效:写了选择器,ItemTemplate 就会被忽略。

5.6 选择器什么时候被调用

这是最容易困惑的一点。SelectTemplate 在每个数据项第一次生成容器时被调用一次,结果会被缓存。

所以:

  • 修改 IsManager 后,界面不会自动重新调用选择器。
  • 集合增删、滚动导致容器重新生成时,才会再次调用。

在示例项目里能看到这个现象:勾选或取消勾选「主管」复选框后,上方的卡片不会立刻换模板。

要让模板立即切换,常用的三种办法:

办法 做法 适用场景
刷新集合视图 CollectionViewSource.GetDefaultView(Employees).Refresh() 想全部重新生成
换掉 ItemsSource 重新赋值同一个集合的包装 数据量不大
改用模板触发器 用 DataTrigger 改属性而非换模板 结构差异小

如果确实需要「改属性就换整套模板」,通常说明这个需求更适合用触发器实现。选择器更适合数据本身相对稳定、结构差异大的场景,比如不同类型的消息卡片、不同类型的节点。

5.7 选择器还能做什么判断

SelectTemplate 里可以写任意 C# 逻辑,不限于布尔属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
{
if (item is not Employee employee)
{
return base.SelectTemplate(item, container);
}

if (employee.IsManager && employee.Salary >= 14000m)
{
return TopManagerTemplate;
}

return employee.IsManager ? ManagerTemplate : MemberTemplate;
}

判断逻辑越复杂,越应该把它从 XAML 移到 C#。这正是选择器存在的价值:给模板切换留一个「能写代码」的出口。

5.8 跟做:搭出第 5 章页签

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
<TabItem Header="5  模板选择器">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="4,16,4,4">
<TextBlock Text="DataTemplateSelector 按数据挑选模板"
FontSize="18"
FontWeight="SemiBold"/>
<TextBlock Text="选择器继承 DataTemplateSelector,重写 SelectTemplate,根据 IsManager 返回主管模板或成员模板。"
Foreground="#5D6B82"
Margin="0,6,0,14"/>

<ItemsControl ItemsSource="{Binding Employees}"
ItemTemplateSelector="{StaticResource EmployeeTemplateSelector}"/>

<TextBlock Text="切换主管身份,观察模板切换时机"
FontSize="18"
FontWeight="SemiBold"
Margin="0,26,0,10"/>
<ListBox ItemsSource="{Binding Employees}"
SelectedItem="{Binding SelectedEmployee, Mode=TwoWay}"
MinHeight="240">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="2,6">
<TextBlock Text="{Binding Name}"
Width="90"
FontWeight="SemiBold"/>
<CheckBox IsChecked="{Binding IsManager, Mode=TwoWay}"
VerticalAlignment="Center"
Content="主管身份"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</ScrollViewer>
</TabItem>

运行后:

  1. 上方卡片自动分成「主管」蓝色样式和「成员」白色样式。
  2. 勾选某个成员的复选框,下方数据变了,但上方卡片不会立刻换模板。
  3. 滚动上方列表或点「新增员工」让容器重新生成,就能看到模板更新。

5.9 跟做检查点

检查项 期望结果
运行程序 张三、孙七、郑十显示为主管样式,其余为成员样式
勾选李四的复选框 数据变为主管,上方卡片暂不变化
触发列表重新生成 李四的卡片变成主管样式
把 MemberTemplate 设为 null 非主管项回退到默认模板

5.10 小练习

  1. 增加第三套模板 TopManagerTemplate,用于「主管且工资不低于 14000」的员工。
  2. 在选择器里加入「部门为空则用占位模板」的逻辑。
  3. 给选择器加一个「按部门配色」的能力,通过构造函数传入部门与颜色的映射。

6. 基于数据集合绑定的直方图案例

本节目标

学完本节,你应该能够:

  • 把原始集合按区间统计成新的集合。
  • 写一个 IValueConverter 把比例换算成像素。
  • 用 ItemsControl + UniformGrid 画出柱状图。
  • 在数据变化后重新统计并刷新图表。
  • 把前五章的知识点串成一个完整案例。

6.1 案例目标

要做的图:把员工工资按四个区间统计人数,用四根柱子表示。柱子高度反映人数多少,人数最多的那根最高。

这个案例正好把前面五章全部用上:

用到的技术 来自哪一章
星号列和 UniformGrid 第 1 章
集合绑定与统计属性 第 3 章
数据模板 第 4 章
命令驱动刷新 第 3 章
派生属性通知 第 2 章

6.2 第一步:定义区间的数据形状

先写一个表示「一个区间」的模型 Models\SalaryBucket.cs:

1
2
3
4
5
6
7
8
9
10
11
namespace WpfTemplatePractice.Models;

public sealed class SalaryBucket
{
public required string RangeLabel { get; init; }

public required int Count { get; init; }

// 0 到 1 之间的比例,直方图用它决定柱子高度。
public required double Ratio { get; init; }
}

三个属性:

  • RangeLabel:区间文字,如「9K - 11K」。
  • Count:这个区间的人数。
  • Ratio:人数除以最大人数,取值 0 到 1。

Ratio 是关键。直方图的柱子高度不直接用人数,而用「相对最大值的比例」,这样人数变化时图形仍然铺满高度空间。

init 和 required 是 C# 的现代写法:属性只能在对象初始化时赋值,编译器还会检查你是否漏写了必要属性。

6.3 第二步:写统计方法

在 MainViewModel 里加:

1
2
3
4
5
private const decimal LowLimit = 9000m;
private const decimal MidLimit = 11000m;
private const decimal HighLimit = 13000m;

public ObservableCollection<SalaryBucket> SalaryBuckets { get; }
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
private void RefreshSalaryBuckets()
{
var buckets = new[]
{
new { Label = "9K 以下", Min = decimal.MinValue, Max = LowLimit },
new { Label = "9K - 11K", Min = LowLimit, Max = MidLimit },
new { Label = "11K - 13K", Min = MidLimit, Max = HighLimit },
new { Label = "13K 以上", Min = HighLimit, Max = decimal.MaxValue }
};

var counts = buckets
.Select(bucket => Employees.Count(employee =>
employee.Salary >= bucket.Min && employee.Salary < bucket.Max))
.ToArray();

var max = counts.Length == 0 ? 0 : counts.Max();

SalaryBuckets.Clear();
for (var i = 0; i < buckets.Length; i++)
{
SalaryBuckets.Add(new SalaryBucket
{
RangeLabel = buckets[i].Label,
Count = counts[i],
Ratio = max == 0 ? 0d : (double)counts[i] / max
});
}

OnPropertyChanged(nameof(EmployeeCount));
OnPropertyChanged(nameof(AverageSalary));
OnPropertyChanged(nameof(TotalSalary));
OnPropertyChanged(nameof(MaxSalary));
}

逐段看:

  1. buckets 用匿名类型描述四个区间的名称和上下界。匿名类型只在方法内使用,写起来最短。
  2. Employees.Count(...) 统计落在 [Min, Max) 区间内的人数。左闭右开保证边界员工只被统计一次。
  3. max 是四个计数里的最大值,用它做分母。
  4. Ratio = 当前人数 / 最大人数,所以最高那根柱子的比例是 1。
  5. SalaryBuckets.Clear() 再逐个 Add,界面会因为集合通知而重画。

这里用 Clear + Add 而不是重建整个 ObservableCollection 对象,是为了让绑定继续有效:如果换掉整个对象,所有绑在它上面的控件都要重新绑定。

6.4 第三步:比例怎样变成像素

Ratio 是 0 到 1 的 double,但 Rectangle.Height 需要像素值。中间需要一个转换器 Converters\RatioToBarHeightConverter.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
using System.Globalization;
using System.Windows.Data;

namespace WpfTemplatePractice.Converters;

public class RatioToBarHeightConverter : IValueConverter
{
private const double DefaultMaxHeight = 230d;
private const double MinHeight = 30d;

public object Convert(
object value,
Type targetType,
object parameter,
CultureInfo culture)
{
var ratio = value is double number ? number : 0d;
var maxHeight = DefaultMaxHeight;

if (parameter is string text
&& double.TryParse(
text,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var parsed))
{
maxHeight = parsed;
}

return Math.Max(MinHeight, ratio * maxHeight);
}

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

三个细节值得说明:

  1. Math.Max(MinHeight, ...) 保证人数为 0 时柱子仍然可见,否则会完全消失,看起来像坏掉了。
  2. ConverterParameter 允许在 XAML 里指定最大高度,同一个转换器可以用在不同尺寸的图上。
  3. ConvertBack 直接抛异常,因为直方图是只读展示,不需要反向转换。明确抛异常比返回空值更容易定位误用。

在 XAML 里注册:

1
<converters:RatioToBarHeightConverter x:Key="RatioToBarHeightConverter"/>

并补上命名空间:

1
xmlns:converters="clr-namespace:WpfTemplatePractice.Converters"

6.5 第四步:用模板画柱子

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
<ItemsControl ItemsSource="{Binding SalaryBuckets}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="14,0">
<Grid Height="250">
<Rectangle Width="72"
RadiusX="6"
RadiusY="6"
VerticalAlignment="Bottom"
Fill="#2F6FED"
Height="{Binding Ratio,
Converter={StaticResource RatioToBarHeightConverter},
ConverterParameter=230}"/>
</Grid>
<TextBlock Text="{Binding Count}"
Margin="0,10,0,0"
HorizontalAlignment="Center"
FontSize="18"
FontWeight="SemiBold"
Foreground="#172033"/>
<TextBlock Text="{Binding RangeLabel}"
Margin="0,4,0,0"
HorizontalAlignment="Center"
Foreground="#5D6B82"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

这段 XAML 里有几个布局技巧:

  1. UniformGrid Rows="1":一行四列,四个区间自动等宽。如果以后改成五个区间,XAML 不用改。
  2. Grid Height="250" 给柱子一个固定高度的「画布」,Rectangle 在这个画布里从底部长起来。
  3. VerticalAlignment="Bottom" 让矩形贴住底部,这是柱状图的关键一行。
  4. RadiusX / RadiusY 给柱子做圆角,视觉上更柔和。
  5. 高度绑定里同时写了 Converter 和 ConverterParameter,参数值 230 与 Grid 的 250 留了 20 像素余量。

6.6 第五步:让图表跟着数据走

只要在数据变化后调用一次统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void AddEmployee()
{
// ... 前面省略
Employees.Add(employee);
SelectedDepartment?.Employees.Add(employee);
SelectedEmployee = employee;
RefreshSalaryBuckets(); // 重新统计
}

private void RaiseSalary(Employee? employee)
{
// ... 前面省略
employee.Salary += 1000m;
OnPropertyChanged(nameof(AverageSalary));
OnPropertyChanged(nameof(TotalSalary));
OnPropertyChanged(nameof(MaxSalary));
RefreshSalaryBuckets(); // 重新统计
}

初始状态下四个区间的计数是 1 / 3 / 2 / 2。点两次「工资 +1000」,如果某个员工从 8800 涨到 10800,区间分布就会变化,柱子高度也随之改变。

6.7 案例复盘:为什么这样分层

把整个案例的数据流画出来:

1
2
3
4
5
6
7
8
9
10
Employees(原始集合)
│ 按区间统计
v
SalaryBuckets(区间集合,含 Count 和 Ratio)
│ 绑定到 ItemsControl
v
数据模板(StackPanel + Grid + Rectangle)
│ Ratio 经转换器变成像素
v
柱子高度

每一层只做一件事:

层 职责 不该做的事
原始集合 保存业务数据 不该存统计结果
统计方法 把原始数据算成区间集合 不该碰界面
数据模板 描述一个区间怎么显示 不该做统计计算
转换器 单位换算(比例到像素) 不该做业务判断

这种分层的好处是每一层都能单独测试和替换。想换配色,只改模板;想改区间划分,只改统计方法;想改柱子最大高度,只改 ConverterParameter。

6.8 跟做检查点

检查项 期望结果
运行程序 四根柱子,计数分别是 1、3、2、2
点「新增员工」 9K 以下区间计数加一,柱子高度重算
选中 8800 的员工点两次「工资 +1000」 该员工进入 9K - 11K 区间,两根柱子高度变化
把 ConverterParameter 改成 150 所有柱子变矮,比例关系不变
把 VerticalAlignment 删掉 柱子变成从上往下长,图形明显错误

6.9 小练习

  1. 增加第五个区间「15K 以上」,观察 UniformGrid Rows="1" 是否需要改动。
  2. 给柱子加上不同颜色:人数最多的区间用深蓝,其余用浅蓝。
  3. 把柱子换成横向条形图:UniformGrid Columns="1",把 Height 改成 Width。
  4. 在柱子顶部显示百分比,用 StringFormat 把 Ratio 格式化成百分数。

完整示例结构

跟做完成后,项目结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
WpfTemplatePractice\
├── App.xaml
├── App.xaml.cs
├── AssemblyInfo.cs
├── Converters\
│ └── RatioToBarHeightConverter.cs
├── Models\
│ ├── Department.cs
│ ├── Employee.cs
│ └── SalaryBucket.cs
├── Selectors\
│ └── EmployeeTemplateSelector.cs
├── ViewModels\
│ ├── MainViewModel.cs
│ └── RelayCommand.cs
├── MainWindow.xaml
├── MainWindow.xaml.cs
└── WpfTemplatePractice.csproj

六个页签与六个章节一一对应:

页签 对应章节 核心演示
1 尺寸均分 第 1 章 星号列、UniformGrid、绑定 Columns
2 多区域整合 第 2 章 SelectedItem、ElementName、局部 DataContext
3 集合绑定 第 3 章 ObservableCollection、DataGrid、命令
4 模板触发器 第 4 章 DataTrigger、Setter TargetName
5 模板选择器 第 5 章 DataTemplateSelector、两套模板
6 直方图 第 6 章 区间统计、IValueConverter、柱子

运行检查表

启动程序后,按这个顺序检查一遍:

步骤 操作 期望结果
1 打开页签「1 尺寸均分」 三块蓝色区域等宽
2 拖动 Columns 滑块 卡片列数在 2 到 6 之间变化
3 打开页签「2 多区域整合」 默认选中研发部,显示张三和孙七
4 点「设计部」 成员变成王五和郑十,合计 $23,000,平均 $11,500
5 打开页签「3 集合绑定」 8 人,平均 $11,150,合计 $89,200,最高 $14,500
6 点「新增员工」 9 人,平均 $10,911,合计 $98,200
7 打开页签「4 模板触发器」 三种底色分别对应高、中、低
8 拖动工资滑块越过 12000 卡片底色立即切换
9 打开页签「5 模板选择器」 三名主管使用蓝色模板
10 打开页签「6 直方图」 四根柱子计数 1、3、2、2

如果某一步结果不对,先在「输出」窗口里找绑定错误。绝大多数「界面没反应」都是绑定路径写错,而不是控件本身有问题。


综合练习

练习一:可搜索的员工看板

要求:

  • 顶部一个搜索框,输入关键字后列表只剩匹配的员工。
  • 关键字同时匹配姓名和部门。
  • 搜索框清空后恢复全部数据。
  • 搜索结果仍然使用第 5 章的选择器渲染。

提示:在 ViewModel 里加 SearchText 属性和 FilteredEmployees 派生属性,SearchText 变化时通知 FilteredEmployees。

练习二:可配置的直方图

要求:

  • 区间边界不再写死在代码里,而是由界面上的两个滑块控制(最低分界、最高分界)。
  • 柱子数量随分界变化自动调整。
  • 柱子颜色按人数多少深浅变化。

提示:把区间划分逻辑抽成一个方法,参数是分界值数组;UniformGrid 会自动适应柱子的数量。

练习三:三层联动的主从视图

要求:

  • 第一层:部门列表。
  • 第二层:该部门的员工列表,按工资降序排列。
  • 第三层:选中员工的详细表单,改动后列表和直方图都同步更新。
  • 顶部显示当前部门的汇总信息。

提示:排序用 CollectionViewSource 或 ListCollectionView 的 SortDescriptions;汇总信息可以放在 Department 上,但要记得手动通知。


结语

这一篇的六章可以压缩成六句话:

章节 一句话
尺寸均分 宽度交给容器算,不要手写像素
多区域整合 一个区域的选中项,就是另一个区域的数据源
集合绑定 集合用 ObservableCollection,元素用 INotifyPropertyChanged
模板触发器 一套模板,按数据值换外观
模板选择器 多套模板,让 C# 决定用哪套
直方图案例 原始集合先统计成区间集合,再交给模板渲染

再往深处走,会碰到虚拟化、CollectionViewSource、数据校验、异步加载等话题。但基础打牢之后,那些内容都只是在这套骨架上的扩展。

动手顺序建议:先把第 1 章的星号列和 UniformGrid 跑通,再加第 2 章的三个区域,然后补第 3 章的集合与命令,最后做第 4、5、6 章的外观和图表。每完成一步都运行一次,看到变化再继续。

祝学习顺利。