面向已经完成基础数据绑定练习,准备学习尺寸均分、多区域联动、集合呈现和数据模板的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,以便在当前安装了 .NET 9 SDK 的电脑上直接编译。这里用到的布局、绑定和模板机制在 .NET 9 和 .NET 10 中没有变化。 最后更新:2026-10-19
目录
基于界面控件绑定的尺寸均分处理
多区域整合绑定的数据关系
集合数据类型的绑定与数据呈现
数据模板触发器
数据模板选择器
基于数据集合绑定的直方图案例
写在前面 这份笔记解决什么问题 前面四篇笔记走完了一条完整的路:
笔记
回答的问题
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 章,在自己新建的项目 里从零敲一遍。
每完成一章,就给自己项目增加一个页签,六个页签刚好对应六章。
最后做第 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:
打开 WpfDataTemplateDemo.csproj。
确认启动项目是 WpfDataTemplateDemo。
按 F5 以调试方式启动。
打开“输出”窗口,把显示来源切换为“调试”。绑定写错时,错误信息会出现在这里,而不是弹窗报错。
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 尺寸均分" > </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 ? "主管" : "成员" ; public string SalaryBand => Salary >= 12000 m ? "高" : Salary >= 9500 m ? "中" : "低" ; 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 ? 0 m : 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 ; } 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 >
这段代码有三个问题:
窗口变宽时,右边会空出一大块。
窗口变窄时,会出现横向滚动条。
想把三列改成四列,必须改三个 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 有两个前提:
组名必须完全一致,大小写敏感。
必须有一个祖先元素设置 Grid.IsSharedSizeScope="True",它管着这一片区域的共享计算。
注意:参与共享的列必须是 Auto。把 * 放进共享组不会得到期望的结果,因为星号列本来就要吸收剩余空间。
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 = 12000 m, IsManager = true }, new () { Id = 2 , Name = "李四" , Department = "产品部" , Salary = 10500 m }, new () { Id = 3 , Name = "王五" , Department = "设计部" , Salary = 9800 m }, new () { Id = 4 , Name = "赵六" , Department = "测试部" , Salary = 9200 m }, new () { Id = 5 , Name = "孙七" , Department = "研发部" , Salary = 14500 m, IsManager = true }, new () { Id = 6 , Name = "周八" , Department = "测试部" , Salary = 8800 m }, new () { Id = 7 , Name = "吴九" , Department = "产品部" , Salary = 11200 m }, new () { Id = 8 , Name = "郑十" , Department = "设计部" , Salary = 13200 m, 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=" *" 时,可用宽度被平均分成三份。" 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 小练习
做一个左右分栏:左侧固定 260 像素,右侧自适应,拖动窗口时只有右侧变化。
做一个三行两列的 UniformGrid,六个格子里放不同的部门名称。
把 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 ** 属性,作为本列表的数据源**。
这样写的好处是链式变化全部自动完成:
用户点「设计部」。
DepartmentList.SelectedItem 变成设计部对象。
WPF 通知所有引用它的绑定。
EmployeeList.ItemsSource 重新取值为设计部的成员集合。
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 的计算属性。它们本身不会发通知。
所以在两种情况下统计会不刷新:
成员集合里增删了员工。
某个员工的工资改了。
在随附示例里,第 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 += 1000 m; 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 小练习
在三个区域的基础上加第四个区域:部门成员人数超过 2 时显示「人数较多」提示。
把统计区的「平均工资」改成百分比进度条,用 IValueConverter 把平均值映射成 0 到 1 的比例。
去掉区域二的 ElementName,改成在 SelectedDepartment 变化时手动刷新集合,比较两种做法的代码量。
3. 集合数据类型的绑定与数据呈现 本节目标 学完本节,你应该能够:
说清 ObservableCollection<T> 和 List<T> 在绑定上的区别。
用 ItemsControl、ListBox、DataGrid 呈现同一份集合。
用 ICommand 让按钮驱动集合变化。
理解「集合通知」和「对象通知」是两件事。
在集合增删改后让统计数字同步刷新。
3.1 为什么必须用 ObservableCollection 把 List<Employee> 绑到 ListBox,界面能显示数据,但新增一项不会出现在界面上 。原因是 List<T> 没有变化通知机制。
1 2 3 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 )) { 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 = 9000 m }; 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 += 1000 m; 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 ? 0 m : Employees.Average(employee => employee.Salary); public decimal TotalSalary => Employees.Sum(employee => employee.Salary);public decimal MaxSalary => Employees.Count == 0 ? 0 m : 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 小练习
增加「按部门筛选」下拉框,用 CollectionViewSource 或 FilteredEmployees 实现。
给 DataGrid 的工资列加排序,观察排序是否会改变源集合顺序。
把「新增员工」改成从文本框中取姓名,练习 TextBox 双向绑定和命令参数配合。
4. 数据模板触发器 本节目标 学完本节,你应该能够:
在 DataTemplate.Triggers 里写 DataTrigger。
用 Setter 的 TargetName 精确控制模板内部的控件。
说明为什么「按数据值切换外观」要放在模板里,而不是写事件处理。
知道触发器适合什么、不适合什么。
用滑块改数据,实时看到触发器生效。
4.1 需求:同一套模板,不同外观 假设员工列表要求:工资高的行偏绿,中的偏黄,低的偏红。
一种做法是给每种情况各写一套 DataTemplate,再用选择器切换。但这里三种情况除了颜色完全一样,复制三份模板会造成维护负担。
更好的做法是只写一套模板,在里面声明「当数据满足某条件时,改这些属性」:
图 3:模板触发器改一套模板的外观,模板选择器换整套模板,直方图把统计结果画出来。
4.2 先给数据一个可比较的字段 DataTrigger 的 Binding 可以绑定任意属性,Value 与它做相等比较。
最容易踩的坑是直接绑定 Salary 然后写 Value="12000":这样只有工资恰好等于 12000 时才生效,工资是 12001 就不匹配了。触发器做的是相等比较 ,不是范围比较。
所以正确做法是在模型里先算好一个区间字符串:
1 2 public string SalaryBand => Salary >= 12000 m ? "高" : Salary >= 9500 m ? "中" : "低" ;
触发器只需要比较三个字符串:"高"、"中"、"低"。
这样做还有一个好处:区间规则集中在 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 事件,每次拖动都要:
读取新工资。
判断落在哪个区间。
找到对应的视觉元素。
手动改它的背景色和前景色。
而触发器版本把「什么条件下变成什么样」声明在 XAML 里,数据一变外观自动跟上,代码里一行判断都不用写。
这也是 WPF 的一个核心思路:把「状态与外观的对应关系」声明出来,而不是在代码里一步步地改 。
4.9 跟做检查点
检查项
期望结果
运行程序
列表里出现三种底色,分别对应高、中、低
拖动滑块越过 12000
卡片底色和金额颜色立刻切换
把某个 x:Name 写错
运行时抛异常或静默不生效,「输出」窗口有提示
删掉一个 DataTrigger
对应区间的卡片恢复模板的默认外观
如果改颜色完全没反应,先确认三件事:SalaryBand 有没有在 Salary 变化时发出通知;DataTemplate.Triggers 是否写在模板内部;TargetName 与 x:Name 是否完全一致。
4.10 小练习
增加第四个 DataTrigger:部门为「研发部」时把左边加一条 4 像素的蓝色竖条。
用 MultiDataTrigger 让「主管 + 高工资」的卡片边框加粗。
把触发器改成 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); } }
要点:
必须继承 DataTemplateSelector,命名空间是 System.Windows.Controls。
重写 SelectTemplate(object item, DependencyObject container)。
item 就是当前正在渲染的那条数据。
返回 null 表示「交给 WPF 用默认方式处理」。
两个 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 >= 14000 m) { 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 >
运行后:
上方卡片自动分成「主管」蓝色样式和「成员」白色样式。
勾选某个成员的复选框,下方数据变了,但上方卡片不会立刻换模板。
滚动上方列表或点「新增员工」让容器重新生成,就能看到模板更新。
5.9 跟做检查点
检查项
期望结果
运行程序
张三、孙七、郑十显示为主管样式,其余为成员样式
勾选李四的复选框
数据变为主管,上方卡片暂不变化
触发列表重新生成
李四的卡片变成主管样式
把 MemberTemplate 设为 null
非主管项回退到默认模板
5.10 小练习
增加第三套模板 TopManagerTemplate,用于「主管且工资不低于 14000」的员工。
在选择器里加入「部门为空则用占位模板」的逻辑。
给选择器加一个「按部门配色」的能力,通过构造函数传入部门与颜色的映射。
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 ; } 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 = 9000 m;private const decimal MidLimit = 11000 m;private const decimal HighLimit = 13000 m;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 ? 0 d : (double )counts[i] / max }); } OnPropertyChanged(nameof (EmployeeCount)); OnPropertyChanged(nameof (AverageSalary)); OnPropertyChanged(nameof (TotalSalary)); OnPropertyChanged(nameof (MaxSalary)); }
逐段看:
buckets 用匿名类型描述四个区间的名称和上下界。匿名类型只在方法内使用,写起来最短。
Employees.Count(...) 统计落在 [Min, Max) 区间内的人数。左闭右开保证边界员工只被统计一次。
max 是四个计数里的最大值,用它做分母。
Ratio = 当前人数 / 最大人数,所以最高那根柱子的比例是 1。
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 = 230 d; private const double MinHeight = 30 d; public object Convert ( object value , Type targetType, object parameter, CultureInfo culture ) { var ratio = value is double number ? number : 0 d; 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(); } }
三个细节值得说明:
Math.Max(MinHeight, ...) 保证人数为 0 时柱子仍然可见,否则会完全消失,看起来像坏掉了。
ConverterParameter 允许在 XAML 里指定最大高度,同一个转换器可以用在不同尺寸的图上。
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 里有几个布局技巧:
UniformGrid Rows="1":一行四列,四个区间自动等宽。如果以后改成五个区间,XAML 不用改。
Grid Height="250" 给柱子一个固定高度的「画布」,Rectangle 在这个画布里从底部长起来。
VerticalAlignment="Bottom" 让矩形贴住底部,这是柱状图的关键一行。
RadiusX / RadiusY 给柱子做圆角,视觉上更柔和。
高度绑定里同时写了 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 += 1000 m; 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 小练习
增加第五个区间「15K 以上」,观察 UniformGrid Rows="1" 是否需要改动。
给柱子加上不同颜色:人数最多的区间用深蓝,其余用浅蓝。
把柱子换成横向条形图:UniformGrid Columns="1",把 Height 改成 Width。
在柱子顶部显示百分比,用 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 章的外观和图表。每完成一步都运行一次,看到变化再继续。
祝学习顺利。