面向已经练过依赖属性、数据绑定和数据模板,准备系统学习列表控件与集合呈现的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,以便在当前安装了 .NET 9 SDK 的电脑上直接编译。这里用到的列表控件、模板和绑定机制在 .NET 9 和 .NET 10 中没有变化。 最后更新:2026-10-19
目录
列表控件的子项结构区别
集合子项模板与数据模板的组合使用
数据集合的不同呈现方式
ComboBox 的绑定使用
多级数据联动案例
写在前面 这份笔记解决什么问题 前面五篇笔记走完了一条从属性到界面的路:
笔记
回答的问题
WPF 依赖属性学习笔记
属性怎样参与 WPF 的值系统
WPF 依赖属性进阶笔记
元数据、属性值继承和附加属性
WPF 数据绑定学习笔记
绑定怎样把值和界面连起来
WPF 绑定数据源学习笔记
绑定表达式里的来源对象从哪里来
WPF 数据模板与集合绑定学习笔记
一份数据怎样用不同外观呈现
到这里,你已经能让数据显示出来了。可一旦动手写真正的业务界面,问题马上变成另一批:
1 2 3 4 5 同样是显示一列数据,ItemsControl、ListBox、ComboBox 到底差在哪? 列表每一项外面那层「选中就变蓝」的框,是怎么加上的? 一个部门的下面还要列出它的成员,这种「列表套列表」怎么写? 同一个集合,什么时候该用表格、什么时候该用树、什么时候该用卡片? 下拉框里怎样放勾选框?怎样让第二个下拉框跟着第一个变?
这一篇就是回答这些问题的。五章内容对应五种能力:
章节
解决的问题
主要技术点
第 1 章
控件之间差在哪一层
ItemsControl、ListBoxItem、ComboBoxItem、容器与内容的分工
第 2 章
模板怎样组合起来用
ItemTemplate、ItemContainerStyle、ContentTemplate、HierarchicalDataTemplate
第 3 章
同一份数据怎样换画法
ListView、GridView、TreeView、DataGrid、ItemsPanelTemplate
第 4 章
下拉框怎样绑数据
ItemsSource、SelectedItem、SelectedValuePath、下拉项放 CheckBox
第 5 章
级联关系怎样搭出来
派生属性联动、ElementName 联动、多级重置
五章是一条线索串起来的:先认清结构(第 1 章),再学会拼装(第 2 章),然后才谈得上换外观(第 3 章)和做交互(第 4、5 章)。跳过第 1 章直接抄第 3 章的表格代码,通常会遇到「为什么我加的样式没生效」这类问题,因为不知道样式该加在哪一层。
建议的学习顺序
先运行随附示例,把五个页签逐个点一遍,看清楚每个页签在演示什么。
回到第 1 章,在自己新建的项目里从零敲一遍,不要复制粘贴。
每完成一章,就给自己项目加一个页签,五个页签刚好对应五章。
最后做第 5 章的多级联动,把前面几章的知识点串成一个完整案例。
WPF 的界面代码有一个特点:写错往往不报错,只是没效果。所以每加一段 XAML 就运行一次,用眼睛确认变化,比读十遍语法说明有用。
示例项目位置 1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfListControlDemo
在 PowerShell 中运行:
1 2 3 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfListControlDemo" dotnet build dotnet run
如果使用 Visual Studio:
打开 WpfListControlDemo.csproj。
确认启动项目是 WpfListControlDemo。
按 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 WpfListControlPractice -o .\WpfListControlPractice
生成的目录结构如下:
1 2 3 4 5 6 7 WpfListControlPractice\ ├── App.xaml ├── App.xaml.cs ├── AssemblyInfo.cs ├── MainWindow.xaml ├── MainWindow.xaml.cs └── WpfListControlPractice.csproj
后面每一章的代码,都可以加到这个项目的 MainWindow.xaml 里。为了容纳五个页签,先把窗口调大一些,并把页签骨架搭好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <Window x:Class ="WpfListControlPractice.MainWindow" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" Title ="WPF 列表控件与集合呈现练习" Width ="1320" Height ="920" MinWidth ="1080" MinHeight ="760" WindowStartupLocation ="CenterScreen" Background ="#F4F7FC" > <TabControl Margin ="26" > <TabItem Header ="1 子项结构" > </TabItem > <TabItem Header ="2 模板组合" > </TabItem > <TabItem Header ="3 呈现方式" > </TabItem > <TabItem Header ="4 ComboBox 绑定" > </TabItem > <TabItem Header ="5 多级联动" > </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 using System.ComponentModel;using System.Runtime.CompilerServices;namespace WpfListControlPractice.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 => SetField(ref _salary, value ); } public bool IsManager { get => _isManager; set => SetField(ref _isManager, value ); } public string Level => IsManager ? "主管" : "成员" ; 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 ; PropertyChanged?.Invoke(this , new PropertyChangedEventArgs(propertyName)); return true ; } }
注意 Level 只有 get,没有 set。它不会自己通知界面,只有当 IsManager 变化时由 set 主动通知——这是后面第 5 章要反复用到的写法。
Models\Department.cs(部门模型,第 2、3、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 using System.Collections.ObjectModel;namespace WpfListControlPractice.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\Region.cs(省市区统一模型,第 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 using System.Collections.ObjectModel;namespace WpfListControlPractice.Models ;public class Region { public Region (string name, params Region[] children ) { Name = name; Children = new ObservableCollection<Region>(children); } public string Name { get ; } public ObservableCollection<Region> Children { get ; } public bool HasChildren => Children.Count > 0 ; public override string ToString () => Name; }
这个类值得多看两眼。省、市、区三种东西用了同一个类型:省有子级(市),市有子级(区),区的子级为空。这就是为什么三级联动可以用同一段模板重复三次。
Models\Skill.cs(技能模型,第 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 using System.ComponentModel;using System.Runtime.CompilerServices;namespace WpfListControlPractice.Models ;public class Skill : INotifyPropertyChanged { private bool _isSelected; public Skill (string name ) { Name = name; } public string Name { get ; } public bool IsSelected { get => _isSelected; set { if (_isSelected == value ) { return ; } _isSelected = value ; PropertyChanged?.Invoke( this , new PropertyChangedEventArgs(nameof (IsSelected))); } } public event PropertyChangedEventHandler? PropertyChanged; }
Models\LevelOption.cs(等级模型,第 4 章用):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 namespace WpfListControlPractice.Models ;public sealed class LevelOption { public LevelOption (int id, string name ) { Id = id; Name = name; } public int Id { get ; set ; } public string Name { get ; } }
再新建 ViewModels 文件夹,加入 ViewModel。它同时充当五个页签的数据源,所以内容稍长:
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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 using System.Collections.ObjectModel;using System.ComponentModel;using System.Runtime.CompilerServices;using WpfListControlPractice.Models;namespace WpfListControlPractice.ViewModels ;public class MainViewModel : INotifyPropertyChanged { private Region? _selectedProvince; private Region? _selectedCity; private Region? _selectedDistrict; private Employee? _selectedEmployee; private LevelOption? _selectedLevel; private int _selectedLevelId = 2 ; private string _selectedLevelName = string .Empty; 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 } }; Departments = new ObservableCollection<Department> { new ("研发部" , Employees[0 ], Employees[4 ]), new ("产品部" , Employees[1 ], Employees[6 ]), new ("设计部" , Employees[2 ], Employees[7 ]), new ("测试部" , Employees[3 ], Employees[5 ]) }; Provinces = new ObservableCollection<Region> { new ("江苏省" , new Region("南京市" , new Region("玄武区" ), new Region("鼓楼区" )), new Region("苏州市" , new Region("姑苏区" ), new Region("吴中区" ))), new ("浙江省" , new Region("杭州市" , new Region("西湖区" ), new Region("余杭区" )), new Region("宁波市" , new Region("海曙区" ), new Region("鄞州区" ))), new ("广东省" , new Region("广州市" , new Region("天河区" ), new Region("越秀区" )), new Region("深圳市" , new Region("南山区" ), new Region("福田区" ))) }; Skills = new ObservableCollection<Skill> { new ("C#" ), new ("XAML" ), new ("SQL" ), new ("WPF" ), new ("Git" ) }; foreach (var skill in Skills) { skill.PropertyChanged += (_, args) => { if (args.PropertyName == nameof (Skill.IsSelected)) { OnPropertyChanged(nameof (SelectedSkillText)); OnPropertyChanged(nameof (SelectedSkillCount)); } }; } Levels = new ObservableCollection<LevelOption> { new (1 , "初级" ), new (2 , "中级" ), new (3 , "高级" ), new (4 , "专家" ) }; SelectedProvince = Provinces[0 ]; SelectedCity = SelectedProvince.Children[0 ]; SelectedDistrict = SelectedCity.Children[0 ]; SelectedEmployee = Employees[0 ]; SelectedLevel = Levels[1 ]; } public ObservableCollection<Employee> Employees { get ; } public ObservableCollection<Department> Departments { get ; } public ObservableCollection<Region> Provinces { get ; } public ObservableCollection<Skill> Skills { get ; } public ObservableCollection<LevelOption> Levels { get ; } public Region? SelectedProvince { get => _selectedProvince; set { if (SetField(ref _selectedProvince, value )) { SelectedCity = value ?.Children.FirstOrDefault(); OnPropertyChanged(nameof (Cities)); OnPropertyChanged(nameof (CityCount)); OnPropertyChanged(nameof (LocationText)); } } } public Region? SelectedCity { get => _selectedCity; set { if (SetField(ref _selectedCity, value )) { SelectedDistrict = value ?.Children.FirstOrDefault(); OnPropertyChanged(nameof (Districts)); OnPropertyChanged(nameof (DistrictCount)); OnPropertyChanged(nameof (LocationText)); } } } public Region? SelectedDistrict { get => _selectedDistrict; set { if (SetField(ref _selectedDistrict, value )) { OnPropertyChanged(nameof (LocationText)); } } } public ObservableCollection<Region>? Cities => SelectedProvince?.Children; public ObservableCollection<Region>? Districts => SelectedCity?.Children; public int CityCount => SelectedProvince?.Children.Count ?? 0 ; public int DistrictCount => SelectedCity?.Children.Count ?? 0 ; public string LocationText => $"{SelectedProvince?.Name ?? "未选择" } / {SelectedCity?.Name ?? "未选择" } / {SelectedDistrict?.Name ?? "未选择" } " ; public Employee? SelectedEmployee { get => _selectedEmployee; set => SetField(ref _selectedEmployee, value ); } public LevelOption? SelectedLevel { get => _selectedLevel; set { if (SetField(ref _selectedLevel, value )) { if (value is not null ) { SetField(ref _selectedLevelId, value .Id, nameof (SelectedLevelId)); } SelectedLevelName = value ?.Name ?? string .Empty; } } } public int SelectedLevelId { get => _selectedLevelId; set { if (SetField(ref _selectedLevelId, value )) { var match = Levels.FirstOrDefault(level => level.Id == value ); if (!ReferenceEquals(_selectedLevel, match)) { _selectedLevel = match; OnPropertyChanged(nameof (SelectedLevel)); SelectedLevelName = match?.Name ?? string .Empty; } } } } public string SelectedLevelName { get => _selectedLevelName; set => SetField(ref _selectedLevelName, value ); } public string SelectedSkillText => string .Join("、" , Skills.Where(skill => skill.IsSelected).Select(skill => skill.Name)); public int SelectedSkillCount => Skills.Count(skill => skill.IsSelected); 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 ; PropertyChanged?.Invoke(this , new PropertyChangedEventArgs(propertyName)); return true ; } private void OnPropertyChanged ([CallerMemberName] string ? propertyName = null ) { PropertyChanged?.Invoke(this , new PropertyChangedEventArgs(propertyName)); } }
这里有一个初学者最容易踩的坑,先提前说清楚:Cities 和 Districts 是派生属性,不会自动通知界面。所以在 SelectedProvince 的 set 里手动写了 OnPropertyChanged(nameof(Cities))。第 5 章会详细讲这件事,现在只要记住「派生属性变了要手动喊一声」。
再新建 Converters 文件夹,加两个转换器,界面上会用它们做颜色和显示切换:
Converters\BoolToBrushConverter.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 using System.Globalization;using System.Windows.Data;using System.Windows.Media;namespace WpfListControlPractice.Converters ;public class BoolToBrushConverter : IValueConverter { private static readonly SolidColorBrush SelectedBrush = new (Color.FromRgb(0x2F , 0x6F , 0xED )); private static readonly SolidColorBrush NormalBrush = new (Color.FromRgb(0x5D , 0x6B , 0x82 )); public object Convert ( object value , Type targetType, object parameter, CultureInfo culture ) { if (parameter is string text) { var parts = text.Split('|' ); if (parts.Length == 2 ) { var brush = new SolidColorBrush( (Color)ColorConverter.ConvertFromString( value is true ? parts[0 ] : parts[1 ])!); brush.Freeze(); return brush; } } return value is true ? SelectedBrush : NormalBrush; } public object ConvertBack ( object value , Type targetType, object parameter, CultureInfo culture ) { throw new NotSupportedException(); } }
Converters\CountToVisibilityConverter.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 using System.Globalization;using System.Windows;using System.Windows.Data;namespace WpfListControlPractice.Converters ;public class CountToVisibilityConverter : IValueConverter { public object Convert ( object value , Type targetType, object parameter, CultureInfo culture ) { var count = value is int number ? number : 0 ; var invert = parameter is string text && text.Equals("Invert" , StringComparison.OrdinalIgnoreCase); var visible = invert ? count == 0 : count > 0 ; return visible ? Visibility.Visible : Visibility.Collapsed; } public object ConvertBack ( object value , Type targetType, object parameter, CultureInfo culture ) { throw new NotSupportedException(); } }
最后把 MainWindow.xaml 的骨架搭好:挂上 DataContext,注册两个转换器,再定义几个全局样式。后面每一章的 XAML 都会往这份骨架里加东西,所以这一段先照着抄完整:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 <Window x:Class ="WpfListControlPractice.MainWindow" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:converters ="clr-namespace:WpfListControlPractice.Converters" xmlns:viewModels ="clr-namespace:WpfListControlPractice.ViewModels" Title ="WPF 列表控件与集合呈现练习" Width ="1320" Height ="920" MinWidth ="1080" MinHeight ="760" WindowStartupLocation ="CenterScreen" Background ="#F4F7FC" > <Window.DataContext > <viewModels:MainViewModel /> </Window.DataContext > <Window.Resources > <converters:BoolToBrushConverter x:Key ="BoolToBrushConverter" /> <converters:CountToVisibilityConverter x:Key ="CountToVisibilityConverter" /> <Style TargetType ="TabItem" > <Setter Property ="FontSize" Value ="15" /> <Setter Property ="Padding" Value ="16,10" /> </Style > <Style TargetType ="TextBox" > <Setter Property ="Padding" Value ="9,7" /> <Setter Property ="Margin" Value ="0,4,0,12" /> </Style > <Style TargetType ="Button" > <Setter Property ="Padding" Value ="14,8" /> <Setter Property ="Margin" Value ="0,0,10,10" /> <Setter Property ="MinWidth" Value ="118" /> </Style > <Style TargetType ="ComboBox" > <Setter Property ="Padding" Value ="9,6" /> <Setter Property ="Margin" Value ="0,4,0,12" /> </Style > <Style x:Key ="CardStyle" TargetType ="Border" > <Setter Property ="Background" Value ="White" /> <Setter Property ="BorderBrush" Value ="#DCE5F2" /> <Setter Property ="BorderThickness" Value ="1" /> <Setter Property ="CornerRadius" Value ="12" /> <Setter Property ="Padding" Value ="20" /> </Style > <Style x:Key ="SectionTitleStyle" TargetType ="TextBlock" > <Setter Property ="FontSize" Value ="18" /> <Setter Property ="FontWeight" Value ="SemiBold" /> <Setter Property ="Foreground" Value ="#172033" /> <Setter Property ="Margin" Value ="0,0,0,12" /> </Style > <Style x:Key ="HintStyle" TargetType ="TextBlock" > <Setter Property ="Foreground" Value ="#5D6B82" /> <Setter Property ="TextWrapping" Value ="Wrap" /> <Setter Property ="LineHeight" Value ="24" /> </Style > <Style x:Key ="CodeStyle" TargetType ="TextBlock" > <Setter Property ="FontFamily" Value ="Consolas" /> <Setter Property ="Foreground" Value ="#2F6FED" /> <Setter Property ="TextWrapping" Value ="Wrap" /> </Style > </Window.Resources > <TabControl Margin ="26" > </TabControl > </Window >
按 F5 运行一次。窗口能打开、页签是空的,就说明数据层已经准备好了。
示例文件分工 随附示例项目的文件分工如下,跟做时可以对照:
文件
作用
主要出现在
Models\Employee.cs
员工模型,带属性变化通知
全部章节
Models\Department.cs
部门模型,含成员集合和统计
第 2、3、5 章
Models\Region.cs
省市区统一模型
第 5 章
Models\Skill.cs
可勾选的技能项
第 4 章
Models\LevelOption.cs
带编号的等级项
第 4 章
ViewModels\MainViewModel.cs
全部示例数据与派生属性
全部章节
Converters\BoolToBrushConverter.cs
真假值转颜色
第 1、2 章
Converters\CountToVisibilityConverter.cs
数量转显示或折叠
第 4 章
MainWindow.xaml
五个页签的全部界面
全部章节
MainWindow.xaml.cs
只调用 InitializeComponent()
全部章节
示例项目的代码后台文件只有这些内容:
1 2 3 4 5 6 7 8 9 10 11 using System.Windows;namespace WpfListControlPractice ;public partial class MainWindow : Window { public MainWindow () { InitializeComponent(); } }
这是本篇希望达到的效果:界面逻辑写在 XAML 和 ViewModel 里,代码后台只负责初始化。当你发现代码后台越来越长,通常说明有逻辑该搬到 ViewModel 去了。
1. 列表控件的子项结构区别 本节目标 学完本节,你应该能够:
说清 ItemsControl、ListBox、ComboBox 在子项结构上的差别。
解释「容器」和「内容」各负责什么,为什么改样式老是没效果。
知道 ItemTemplate 加在哪一层、ItemContainerStyle 加在哪一层。
面对一个需求时,判断该用「不能选中」「可单选」还是「可下拉」的控件。
1.1 先看一个具体问题 同一个 Employees 集合,用三种控件显示,界面效果差别很大:
1 2 3 <ItemsControl ItemsSource ="{Binding Employees}" /> <ListBox ItemsSource ="{Binding Employees}" /> <ComboBox ItemsSource ="{Binding Employees}" />
三个控件都能把八名员工列出来,但:
ItemsControl 里的项点不动,鼠标划过去没有任何反应。
ListBox 里的项点一下会变蓝,而且只能选一个。
ComboBox 平时只显示当前项,点右边箭头才弹出一长条。
这不是三个控件「功能不同」,而是它们在每一项外面包的层数不同 。理解这件事,后面所有列表样式的问题都能自己解释。
图 1:同一份数据,外面包的容器层数不同,能做的事也不同。
1.2 ItemsControl:最薄的子项结构 ItemsControl 是最基础的列表控件。它做的事情只有一件:把集合里的每个对象,按 ItemTemplate 画一遍,然后摆到面板里。
它的子项结构是:
1 2 3 4 ItemsControl ├── 子项 1:ItemTemplate 画出来的那棵可视树 ├── 子项 2:ItemTemplate 画出来的那棵可视树 └── ...
注意这里没有「容器」这一层。数据对象直接喂给 ItemTemplate,模板画成什么样,界面上就是什么样。
这带来两个结果:
每一项没有选中状态,SelectedItem 这类属性在 ItemsControl 上不存在。
想给每一项加边框、加悬停变色,只能写在 ItemTemplate 里面。
1.3 ListBox:在模板外面多包一层 ListBox 在 ItemsControl 的基础上加了一个能力:选中 。为了记录「哪一项被选中」,它必须在每一项外面套一层可以存状态的容器,这个容器就是 ListBoxItem。
结构变成:
1 2 3 4 ListBox └── ListBoxItem(容器:记录 IsSelected、IsMouseOver、IsEnabled) └── ContentPresenter(呈现:把内容画到屏幕上) └── ItemTemplate 画出来的那棵可视树
多出来的这一层 ListBoxItem 是个真正的控件,它自己有一堆依赖属性:IsSelected、IsMouseOver、IsEnabled、Background、BorderBrush、Padding。默认样式里那个「选中变蓝」的效果,就是主题样式在 ListBoxItem 的 IsSelected 触发器里设了背景色。
所以当你写:
1 2 <ListBox ItemsSource ="{Binding Employees}" Background ="#F7F9FD" />
你会发现每一项的底色没变。因为 Background 是 ListBox 自己的背景,不是每一项目外面的 ListBoxItem 的背景。想改每一项的底色,要用 ItemContainerStyle(第 2 章详细讲)。
1.4 ComboBox:再把子项折叠起来 ComboBox 的子项容器叫 ComboBoxItem,结构和 ListBoxItem 几乎一样:
1 2 3 4 5 6 ComboBox ├── 收起状态下显示的 ContentPresenter(显示当前选中项) └── Popup(下拉浮层) └── ComboBoxItem(容器) └── ContentPresenter └── ItemTemplate 画出来的那棵可视树
多出来的是 Popup 这一层浮层。下拉框展开时,里面的项在一个独立的浮层窗口里渲染,它不属于窗口的正常布局。这就是为什么下拉项能盖住别的控件,也是为什么给下拉项设 Margin 时位置会和列表里不太一样。
1.5 容器和内容的分工 把上面的结构总结成一张表:
控件
子项容器类型
能不能选中
容器上能做的样式
内容模板加在哪
ItemsControl
无(直接是内容)
否
只能写在 ItemTemplate 里
ItemTemplate
ListBox
ListBoxItem
单选
ItemContainerStyle
ItemTemplate
ListView
ListViewItem
单选
ItemContainerStyle
ItemTemplate 或 GridView 列
ComboBox
ComboBoxItem
单选
ItemContainerStyle
ItemTemplate
TreeView
TreeViewItem
单选
ItemContainerStyle
ItemTemplate
这张表要记的是分工:
1 2 容器负责:能不能被选中、选中时什么样子、鼠标悬停什么样子、是否禁用 模板负责:每一项里面画什么、怎么排版
两者互不干扰。同一份数据换容器(ItemsControl 换成 ListBox),ItemTemplate 可以原样复制过去,一行都不用改。
1.6 DisplayMemberPath 其实是自动生成的模板 写列表时还有一个高频写法:
1 <ListBox ItemsSource ="{Binding Employees}" DisplayMemberPath ="Name" />
它能让每一项只显示姓名。这背后发生的事情是:WPF 自动生成了一个「只显示 Name 字段」的 DataTemplate。
因此它和 ItemTemplate 是互斥的。如果你两个都写:
1 2 3 <ListBox ItemsSource ="{Binding Employees}" DisplayMemberPath ="Name" ItemTemplate ="{StaticResource EmployeeRowTemplate}" />
DisplayMemberPath 会被忽略,界面上显示的是 ItemTemplate 的效果。记住这一点可以省掉一次「为什么我设了 DisplayMemberPath 没生效」的排查。
同理,DisplayMemberPath 和 ItemTemplate 只能二选一,SelectedValuePath 和 SelectedItem 的用法区别放在第 4 章讲。
1.7 跟做:搭出第 1 章页签 第一步,把这一章要用的子项模板加到 MainWindow.xaml 的 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 <DataTemplate x:Key ="EmployeeRowTemplate" > <Grid Margin ="2,6" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="Auto" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="Auto" /> </Grid.ColumnDefinitions > <Border Grid.Column ="0" Width ="8" Height ="8" CornerRadius ="4" VerticalAlignment ="Center" Background ="{Binding IsManager, Converter={StaticResource BoolToBrushConverter}, ConverterParameter=#2F6FED|#C8D2E0}" /> <TextBlock Grid.Column ="1" Text ="{Binding Name}" Margin ="10,0,0,0" VerticalAlignment ="Center" FontWeight ="SemiBold" /> <TextBlock Grid.Column ="2" Text ="{Binding Department}" VerticalAlignment ="Center" Foreground ="#5D6B82" /> </Grid > </DataTemplate >
这段模板里用到了 BoolToBrushConverter,它的参数写法是「选中色|未选中色」。主管的圆点显示深蓝,普通成员显示浅灰。
第二步,把第 1 章的页签内容填进 TabControl:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 <TabItem Header ="1 子项结构" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="4,16,4,4" > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="三种列表控件的子项结构" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="ItemsControl 的子项就是数据模板本身;ListBox 在模板外面多包一层可选中容器;ComboBox 再把它折叠进下拉框。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,12,0" > <TextBlock Text ="ItemsControl" FontWeight ="SemiBold" Foreground ="#172033" /> <TextBlock Text ="子项 = DataTemplate" Style ="{StaticResource CodeStyle}" Margin ="0,4,0,10" /> <Border Background ="#F7F9FD" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" Padding ="8" > <ItemsControl ItemsSource ="{Binding Employees}" ItemTemplate ="{StaticResource EmployeeRowTemplate}" /> </Border > </StackPanel > <StackPanel Grid.Column ="1" Margin ="0,0,12,0" > <TextBlock Text ="ListBox" FontWeight ="SemiBold" Foreground ="#172033" /> <TextBlock Text ="子项 = ListBoxItem + DataTemplate" Style ="{StaticResource CodeStyle}" Margin ="0,4,0,10" /> <Border Background ="#F7F9FD" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" Padding ="8" > <ListBox ItemsSource ="{Binding Employees}" ItemTemplate ="{StaticResource EmployeeRowTemplate}" BorderThickness ="0" Background ="Transparent" MaxHeight ="260" /> </Border > </StackPanel > <StackPanel Grid.Column ="2" > <TextBlock Text ="ComboBox" FontWeight ="SemiBold" Foreground ="#172033" /> <TextBlock Text ="子项 = ComboBoxItem + DataTemplate" Style ="{StaticResource CodeStyle}" Margin ="0,4,0,10" /> <Border Background ="#F7F9FD" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" Padding ="8" > <ComboBox ItemsSource ="{Binding Employees}" ItemTemplate ="{StaticResource EmployeeRowTemplate}" HorizontalAlignment ="Stretch" /> </Border > </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="容器和内容各管一件事" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="容器负责「能不能被选中、选中时什么样子」;内容模板负责「里面画什么」。同一份数据换容器,内容模板可以原样复用。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <ItemsControl ItemsSource ="{Binding Employees}" > <ItemsControl.ItemsPanel > <ItemsPanelTemplate > <WrapPanel /> </ItemsPanelTemplate > </ItemsControl.ItemsPanel > <ItemsControl.ItemTemplate > <DataTemplate > <Border Width ="150" Margin ="0,0,10,10" Padding ="10" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <ContentPresenter Content ="{Binding}" ContentTemplate ="{StaticResource EmployeeRowTemplate}" /> </Border > </DataTemplate > </ItemsControl.ItemTemplate > </ItemsControl > </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
这段 XAML 里出现了两个新东西:
ItemsControl.ItemsPanel:换掉列表用的排布面板。默认是竖着的 StackPanel,这里换成 WrapPanel,就变成一行放不下自动换行的卡片墙。
ContentPresenter:把「一份内容」摆到界面上的控件。它上面可以挂 ContentTemplate,告诉它这份内容该按哪个模板画。
ContentPresenter 是 WPF 里出现频率极高的一个控件,它本身不画任何东西,只负责「取内容、找模板、交给模板去画」。列表控件的每一项内部,其实也都藏着一个 ContentPresenter。
按 F5 运行,应该能看到左侧三列、下方一排卡片。
1.8 跟做检查点
检查项
期望结果
运行程序,打开页签 1
三列并排:ItemsControl、ListBox、ComboBox
在左列 ItemsControl 上点一项
没有任何选中反应
在中列 ListBox 上点「王五」
该项出现蓝色边框和浅蓝底色
点中列 ComboBox 的箭头
弹出下拉列表,里面每项都是同一套模板
观察主管
张三、孙七、郑十左侧圆点是深蓝,其余是浅灰
拖动窗口宽度
下方卡片墙自动换行,行数随宽度变化
如果 ListBox 里点一项没有任何反应,先检查 ItemsSource 是否绑到了 Employees。绑错了不会报错,只是列表空白。
1.9 小练习
把中列的 ListBox 换成 ItemsControl,其余不变。观察「选中变蓝」消失,验证这一层容器确实是 ListBox 提供的。
给 EmployeeRowTemplate 里的姓名 TextBlock 加 TextTrimming="CharacterEllipsis",然后把 Grid.ColumnDefinitions 中间那列改成 Width="60",观察文字变成省略号。
给中列 ListBox 加 SelectionMode="Multiple",试试按住 Ctrl 能不能选多项。
把 WrapPanel 换成 UniformGrid Rows="2",观察卡片被排成固定两行。
2. 集合子项模板与数据模板的组合使用 本节目标 学完本节,你应该能够:
分清 ItemTemplate 和 ItemContainerStyle 各自能改什么。
给列表项加选中态、悬停态的样式,而不是改整个列表的背景。
用 ContentControl + ContentTemplate 给「单个对象」套模板。
写出「列表里再套列表」的结构,显示部门和成员。
知道 HierarchicalDataTemplate 适合什么场景。
2.1 上一章留下的问题 第 1 章最后,ListBox 里的项点一下会变蓝,但那是主题自带的样式,很朴素。真实界面里通常要的是:
1 2 3 4 选中:左边一条蓝色粗边框 + 浅蓝底 悬停:更浅的蓝色底 默认:接近白色的浅灰底 每一项之间留一点间距
这些效果全都作用在 ListBoxItem 上,所以要用 ItemContainerStyle。
2.2 两个属性,两件事 先把分工摆清楚:
属性
作用对象
能改什么
不能改什么
ItemTemplate
每一项内部的可视树
内部排版、文字、颜色、图标
选中态、悬停态、项间距
ItemContainerStyle
每一项的容器控件
选中态、悬停态、内边距、外边距、边框、底色
内部排版
一句话记法:
1 ItemTemplate 管「里面」,ItemContainerStyle 管「外面那一圈」。
2.3 ItemContainerStyle 的目标类型必须写对 这是初学者最常犯的错误:
1 2 3 4 <Style x:Key ="WrongStyle" TargetType ="ListBox" > <Setter Property ="Padding" Value ="10,6" /> </Style >
Style 的 TargetType 必须和容器类型一致。列表的容器是 ListBoxItem,写成 ListBox 就完全不会生效,而且不报错。
正确写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <Style x:Key ="EmployeeContainerStyle" TargetType ="ListBoxItem" > <Setter Property ="Padding" Value ="10,6" /> <Setter Property ="Margin" Value ="0,0,0,6" /> <Setter Property ="BorderThickness" Value ="2" /> <Setter Property ="BorderBrush" Value ="Transparent" /> <Setter Property ="Background" Value ="#F7F9FD" /> <Style.Triggers > <Trigger Property ="IsSelected" Value ="True" > <Setter Property ="BorderBrush" Value ="#2F6FED" /> <Setter Property ="Background" Value ="#EAF1FF" /> </Trigger > <Trigger Property ="IsMouseOver" Value ="True" > <Setter Property ="Background" Value ="#EDF3FF" /> </Trigger > </Style.Triggers > </Style >
注意 BorderThickness 一开始就设成 2,只是颜色是 Transparent。这样选中时只换颜色不换厚度,文字不会因为边框突然变粗而抖动。这是做选中效果时的一个实用小技巧。
不同容器的目标类型对照:
控件
ItemContainerStyle 的 TargetType
ListBox
ListBoxItem
ListView
ListViewItem
ComboBox
ComboBoxItem
TreeView
TreeViewItem
DataGrid
DataGridRow
2.4 触发器写在容器样式里 上面的 Style.Triggers 用的是 Trigger,它的 Property 必须写容器自己有的属性。容器上常见的可触发属性:
属性
什么时候为 True
IsSelected
这一项被选中
IsMouseOver
鼠标停在这一项上
IsEnabled
这一项没被禁用
IsKeyboardFocusWithin
焦点在这一项里
如果想按数据 而不是按容器状态来变色(比如「工资超过 12000 就用红色」),那要用 DataTrigger,它写在 ItemTemplate 里的元素上,写法是:
1 2 3 4 5 6 7 8 <DataTemplate > <TextBlock x:Name ="SalaryText" Text ="{Binding Salary, StringFormat={}{0:C0}}" /> <DataTemplate.Triggers > <DataTrigger Binding ="{Binding IsManager}" Value ="True" > <Setter TargetName ="SalaryText" Property ="Foreground" Value ="#2F6FED" /> </DataTrigger > </DataTemplate.Triggers > </DataTemplate >
区别可以记成:
1 2 Trigger:由控件自己的状态触发(选中、悬停、禁用) DataTrigger:由绑定的数据触发(某个字段等于某个值)
2.5 ContentControl + ContentTemplate:给单个对象套模板 列表是把模板用在集合上。如果手上只有一个对象,还想用同一套外观,可以用 ContentControl:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <ContentControl Content ="{Binding SelectedItem, ElementName=TemplateSourceList}" > <ContentControl.ContentTemplate > <DataTemplate > <StackPanel > <TextBlock Text ="{Binding Name}" FontSize ="20" FontWeight ="SemiBold" Foreground ="#172033" /> <TextBlock Margin ="0,8,0,0" Foreground ="#5D6B82" > <Run Text ="部门:" /> <Run Text ="{Binding Department, Mode=OneWay}" /> </TextBlock > <TextBlock Margin ="0,4,0,0" Foreground ="#5D6B82" > <Run Text ="工资:" /> <Run Text ="{Binding Salary, Mode=OneWay, StringFormat={}{0:C0}}" /> </TextBlock > <TextBlock Margin ="0,4,0,0" Foreground ="#5D6B82" > <Run Text ="身份:" /> <Run Text ="{Binding Level, Mode=OneWay}" /> </TextBlock > </StackPanel > </DataTemplate > </ContentControl.ContentTemplate > </ContentControl >
这里有三点值得注意:
Content 是「内容」,ContentTemplate 是「怎么画」。这正好对应第 1 章说的容器与内容分工。
绑定路径里的 ElementName=TemplateSourceList 表示「取界面上叫这个名字的控件的属性」,这里取的是左边列表的 SelectedItem。这是第 4 篇笔记里讲过的 ElementName 用法。
Run 的绑定要加 Mode=OneWay。Run.Text 默认想写回,遇到只读属性会报绑定错误。加上 OneWay 就明确了方向。
2.6 列表里再套列表 「每一项里还带一行小标签」这类需求,写法是把 ItemsControl 嵌进 ItemTemplate 里:
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 <ItemsControl ItemsSource ="{Binding Departments}" > <ItemsControl.ItemsPanel > <ItemsPanelTemplate > <WrapPanel /> </ItemsPanelTemplate > </ItemsControl.ItemsPanel > <ItemsControl.ItemTemplate > <DataTemplate > <Border Width ="300" Margin ="0,0,12,12" Padding ="14" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" > <StackPanel > <TextBlock Text ="{Binding Name}" FontSize ="16" FontWeight ="SemiBold" Foreground ="#172033" /> <ItemsControl ItemsSource ="{Binding Employees}" Margin ="0,10,0,0" > <ItemsControl.ItemTemplate > <DataTemplate > <Border Padding ="6,4" Margin ="0,0,0,4" Background ="#F7F9FD" CornerRadius ="6" > <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="Auto" /> </Grid.ColumnDefinitions > <TextBlock Text ="{Binding Name}" /> <TextBlock Grid.Column ="1" Text ="{Binding Salary, StringFormat={}{0:C0}}" Foreground ="#2F6FED" /> </Grid > </Border > </DataTemplate > </ItemsControl.ItemTemplate > </ItemsControl > </StackPanel > </Border > </DataTemplate > </ItemsControl.ItemTemplate > </ItemsControl >
关键在于内层的 DataContext ** 已经换了**。外层模板的每一项,它的 DataContext 是当前的 Department 对象。所以内层写 {Binding Employees},取到的是「这个部门自己的员工集合」,不是总集合。
这一点是嵌套列表的全部秘密:
1 2 每一层模板的 DataContext,都是它当前正在画的那个数据对象。 外层画到哪个部门,内层的 {Binding Employees} 就是那个部门的员工。
顺带一提,这里内层写 {Binding Employees} 之所以能直接取到,是因为 Department.Employees 是公开属性。如果取不到,先在「输出」窗口看绑定错误,九成是属性名拼错或大小写不一致。
2.7 集合子项也可以是层级结构 上面是「两层写死的嵌套」。如果层级不确定有多少层(目录树、省市区、组织架构),写死嵌套就不行了,这时用 HierarchicalDataTemplate:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <TreeView ItemsSource ="{Binding Departments}" > <TreeView.ItemTemplate > <HierarchicalDataTemplate ItemsSource ="{Binding Employees}" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" Foreground ="#172033" /> </HierarchicalDataTemplate > </TreeView.ItemTemplate > <TreeView.ItemContainerStyle > <Style TargetType ="TreeViewItem" > <Setter Property ="IsExpanded" Value ="True" /> </Style > </TreeView.ItemContainerStyle > </TreeView >
它和 DataTemplate 的区别只有一点:多了一个 ItemsSource,用来告诉 TreeView「这个对象的子节点是谁」。有了这一句,TreeView 就能一层一层往下递归。
HierarchicalDataTemplate 只对 TreeView 和 Menu 这类层级控件生效,放在 ListBox 上没用。第 3 章会用它显示部门树。
2.8 跟做:搭出第 2 章页签 第一步,把容器样式加到 Window.Resources:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <Style x:Key ="EmployeeContainerStyle" TargetType ="ListBoxItem" > <Setter Property ="Padding" Value ="10,6" /> <Setter Property ="Margin" Value ="0,0,0,6" /> <Setter Property ="BorderThickness" Value ="2" /> <Setter Property ="BorderBrush" Value ="Transparent" /> <Setter Property ="Background" Value ="#F7F9FD" /> <Style.Triggers > <Trigger Property ="IsSelected" Value ="True" > <Setter Property ="BorderBrush" Value ="#2F6FED" /> <Setter Property ="Background" Value ="#EAF1FF" /> </Trigger > <Trigger Property ="IsMouseOver" Value ="True" > <Setter Property ="Background" Value ="#EDF3FF" /> </Trigger > </Style.Triggers > </Style >
第二步,把页签 2 的内容填进去:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 <TabItem Header ="2 模板组合" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="4,16,4,4" > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="ItemTemplate 决定每一项画什么" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="ItemContainerStyle 决定每一项外面那层容器长什么样。两者可以同时使用,互不干扰。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <ListBox ItemsSource ="{Binding Employees}" ItemTemplate ="{StaticResource EmployeeRowTemplate}" ItemContainerStyle ="{StaticResource EmployeeContainerStyle}" BorderBrush ="#DCE5F2" MinHeight ="300" /> </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="ContentControl + ContentTemplate" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="单个对象也能套模板。把对象放到 Content,再用 ContentTemplate 指定怎么画,效果和列表项一致。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="220" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <ListBox x:Name ="TemplateSourceList" ItemsSource ="{Binding Employees}" DisplayMemberPath ="Name" BorderBrush ="#DCE5F2" MaxHeight ="240" /> <Border Grid.Column ="1" Margin ="14,0,0,0" Padding ="16" Background ="#F7F9FD" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <ContentControl Content ="{Binding SelectedItem, ElementName=TemplateSourceList}" > <ContentControl.ContentTemplate > <DataTemplate > <StackPanel > <TextBlock Text ="{Binding Name}" FontSize ="20" FontWeight ="SemiBold" Foreground ="#172033" /> <TextBlock Margin ="0,8,0,0" Foreground ="#5D6B82" > <Run Text ="部门:" /> <Run Text ="{Binding Department, Mode=OneWay}" /> </TextBlock > <TextBlock Margin ="0,4,0,0" Foreground ="#5D6B82" > <Run Text ="工资:" /> <Run Text ="{Binding Salary, Mode=OneWay, StringFormat={}{0:C0}}" /> </TextBlock > <TextBlock Margin ="0,4,0,0" Foreground ="#5D6B82" > <Run Text ="身份:" /> <Run Text ="{Binding Level, Mode=OneWay}" /> </TextBlock > </StackPanel > </DataTemplate > </ContentControl.ContentTemplate > </ContentControl > </Border > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="集合子项也可以是集合" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="ItemsControl 里再放一个 ItemsControl,就能画出「每一项里还带一行标签」的效果。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <ItemsControl ItemsSource ="{Binding Departments}" > <ItemsControl.ItemsPanel > <ItemsPanelTemplate > <WrapPanel /> </ItemsPanelTemplate > </ItemsControl.ItemsPanel > <ItemsControl.ItemTemplate > <DataTemplate > <Border Width ="300" Margin ="0,0,12,12" Padding ="14" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" > <StackPanel > <TextBlock Text ="{Binding Name}" FontSize ="16" FontWeight ="SemiBold" Foreground ="#172033" /> <ItemsControl ItemsSource ="{Binding Employees}" Margin ="0,10,0,0" > <ItemsControl.ItemTemplate > <DataTemplate > <Border Padding ="6,4" Margin ="0,0,0,4" Background ="#F7F9FD" CornerRadius ="6" > <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="Auto" /> </Grid.ColumnDefinitions > <TextBlock Text ="{Binding Name}" /> <TextBlock Grid.Column ="1" Text ="{Binding Salary, StringFormat={}{0:C0}}" Foreground ="#2F6FED" /> </Grid > </Border > </DataTemplate > </ItemsControl.ItemTemplate > </ItemsControl > </StackPanel > </Border > </DataTemplate > </ItemsControl.ItemTemplate > </ItemsControl > </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
2.9 跟做检查点
检查项
期望结果
打开页签 2,看第一张卡片
列表项之间有间距,每项是浅灰底
点任意一项
该项出现蓝色粗边框和浅蓝底,文字不抖动
鼠标划过其他项
底色变成更浅的蓝,离开后恢复
在左边小列表里点「赵六」
右边立刻显示赵六的姓名、部门、工资、身份
点「孙七」
右边工资显示为 $14,500,身份显示「主管」
看下方部门卡片
四个部门,每个下面列出 2 名成员和工资
换窗口宽度
部门卡片自动换行,卡片内部结构不变
如果 ItemContainerStyle 完全没有效果,先确认 TargetType 写的是 ListBoxItem 而不是 ListBox。
2.10 小练习
给容器样式加一个 IsEnabled 为 False 的触发器,把背景设成灰色,然后把某一项的 IsEnabled 绑到 IsManager 上(提示:用 ItemContainerStyle 里的 Setter Property="IsEnabled" Value="{Binding IsManager}")。
把 ContentControl 的 ContentTemplate 提成一个具名资源 EmployeeDetailTemplate,然后在两处引用它。
给部门卡片加上「合计工资」:在 Department 上已经有 TotalSalary,直接绑上去即可。
把内层 ItemsControl 换成 ListBox,观察内层列表项也能被选中了。思考:此时内层的选中项和外层会互相影响吗。
3. 数据集合的不同呈现方式 本节目标 学完本节,你应该能够:
用同一份 Employees 写出五种完全不同的界面。
说清 ListView + GridView、TreeView、DataGrid 各自的适用场景。
用 ItemsPanelTemplate 换掉列表的排布方式,做出卡片墙。
知道列表外层的 ScrollViewer 该不该加,什么时候会出问题。
面对一个界面需求,用三个问题决定选哪个控件。
3.1 换容器,不换数据 第 1 章说过,容器和内容分工明确。这一章把这句话用到底:数据一行不改,只换容器和面板 。
图 2:同一个集合,五种容器画出五种界面。
五种画法分别是:
控件
结构
适合
不适合
ListBox
平铺一列
单项选择、简单列表
需要多列对齐
ListView + GridView
多列带列头
字段固定的清单
层级数据
TreeView
层级可展开
目录、组织结构
需要横向比较字段
DataGrid
表格、可编辑
大量行、需要排序编辑
每项外观差异大
ItemsControl + WrapPanel
卡片墙
看板、缩略图
需要选中、需要列头
3.2 ListView + GridView:多列对齐 ListView 本身和 ListBox 几乎一样,多出来的是 View 属性。把 View 设成 GridView,它就变成带列头的表格:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <ListView ItemsSource ="{Binding Employees}" BorderBrush ="#DCE5F2" MinHeight ="240" > <ListView.View > <GridView > <GridViewColumn Header ="姓名" DisplayMemberBinding ="{Binding Name}" Width ="110" /> <GridViewColumn Header ="部门" DisplayMemberBinding ="{Binding Department}" Width ="110" /> <GridViewColumn Header ="工资" DisplayMemberBinding ="{Binding Salary, StringFormat={}{0:C0}}" Width ="110" /> </GridView > </ListView.View > </ListView >
三个要点:
GridViewColumn 的 DisplayMemberBinding 是简写形式,等价于给这一列指定一个只显示某个字段的模板。想在一列里放多个元素,就用 GridViewColumn.CellTemplate 写完整的 DataTemplate。
Width 支持固定像素和星号,写法与 Grid 列宽一致。窗口变宽时想让姓名列自动撑开,把它改成 Width="*"。
GridView 的行仍然是 ListViewItem,所以第 2 章的容器样式可以直接拿过来用,把 TargetType 改成 ListViewItem 即可。
3.3 TreeView:层级数据 部门下面挂着成员,这种数据用 TreeView 最自然:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <TreeView ItemsSource ="{Binding Departments}" BorderThickness ="0" Background ="Transparent" MinHeight ="240" > <TreeView.ItemTemplate > <HierarchicalDataTemplate ItemsSource ="{Binding Employees}" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" Foreground ="#172033" /> </HierarchicalDataTemplate > </TreeView.ItemTemplate > <TreeView.ItemContainerStyle > <Style TargetType ="TreeViewItem" > <Setter Property ="IsExpanded" Value ="True" /> </Style > </TreeView.ItemContainerStyle > </TreeView >
两个细节值得注意:
节点默认是折叠的。用 ItemContainerStyle 把 IsExpanded 设成 True,一打开就全部展开。想只展开第一层,把 IsExpanded 绑到数据上的某个字段,按需控制。
HierarchicalDataTemplate 里的 ItemsSource 只需要写「子节点是谁」。部门写 Employees,Employee 没有子节点集合,递归自然就停在第二层。
如果数据是三层(部门 → 小组 → 成员),只需要让 Department 的子节点也是 Department 类型,模板一行都不用改。模板写一次,层级由数据决定 ,这是层级控件的最大好处。
3.4 DataGrid:表格与编辑 DataGrid 是功能最重的一个。默认它连列都会自动生成,但自动生成的列无法控制顺序和宽度,所以实际项目里通常关掉它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <DataGrid ItemsSource ="{Binding Employees}" SelectedItem ="{Binding SelectedEmployee, Mode=TwoWay}" AutoGenerateColumns ="False" CanUserAddRows ="False" HeadersVisibility ="Column" GridLinesVisibility ="Horizontal" AlternatingRowBackground ="#F7F9FD" BorderBrush ="#DCE5F2" MinHeight ="240" > <DataGrid.Columns > <DataGridTextColumn Header ="编号" Binding ="{Binding Id}" Width ="70" /> <DataGridTextColumn Header ="姓名" Binding ="{Binding Name}" Width ="*" /> <DataGridTextColumn Header ="部门" Binding ="{Binding Department}" Width ="*" /> <DataGridTextColumn Header ="工资" Binding ="{Binding Salary, StringFormat={}{0:C0}}" Width ="110" /> </DataGrid.Columns > </DataGrid >
几个关键属性:
属性
作用
建议值
AutoGenerateColumns
是否按数据自动生成列
关掉,自己写列
CanUserAddRows
末尾是否留一个空行
关掉,除非真需要新增
IsReadOnly
整表只读
只展示就设 True
SelectedItem
选中行对象
双向绑到 ViewModel
SelectionMode
单选还是多选
默认 Single
关于编辑:DataGridTextColumn 里的绑定默认是双向的,只要 Employee 的属性实现了 INotifyPropertyChanged,在表格里改数字,界面上其它绑定同一属性的地方会立刻跟着变。
这一点可以马上验证:在 DataGrid 里把张三的工资改成 20000,如果上方还有一处显示 SelectedEmployee.Salary,它会同步更新。
3.5 ItemsControl + WrapPanel:卡片墙 把 ItemsControl 的 ItemsPanel 换成 WrapPanel,就得到一排自动换行的卡片:
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 <ItemsControl ItemsSource ="{Binding Employees}" > <ItemsControl.ItemsPanel > <ItemsPanelTemplate > <WrapPanel /> </ItemsPanelTemplate > </ItemsControl.ItemsPanel > <ItemsControl.ItemTemplate > <DataTemplate > <Border Width ="150" Margin ="0,0,10,10" Padding ="12" Background ="#F7F9FD" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <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 >
ItemsPanelTemplate 能换的不只 WrapPanel:
面板
效果
StackPanel(默认)
竖着一列
StackPanel Orientation="Horizontal"
横着一行
WrapPanel
放不下就换行
UniformGrid Rows="2"
固定两行等分
Grid(需配合行列表)
自定义行列
注意 ItemsControl 的默认面板带虚拟化(VirtualizingStackPanel),换成 WrapPanel 之后虚拟化就没了。数据量大到几万条时,卡片墙会明显卡顿。这是后面要专门处理的话题,现在先记住这个代价。
初学者经常写成这样:
1 2 3 4 <ScrollViewer > <ListBox ItemsSource ="{Binding Employees}" Height ="400" /> </ScrollViewer >
这段代码有两个问题:
ListBox 自己就有滚动条。外面再套一层,会出现「两层滚动条」,用户拖动时体验很怪。
外层的 ScrollViewer 会给 ListBox 无限高度,ListBox 于是把所有项都渲染出来,虚拟化彻底失效。
正确做法:
1 2 列表控件自己负责滚动,不要在外面再包一层。 只有「一整页内容需要一起滚动」时,才在最外层用 ScrollViewer。
本章示例项目里,每个页签最外层有一个 ScrollViewer,那是为了让整页内容一起滚动。列表控件本身没有再套第二层,所以它们的滚动条仍然工作正常。
3.7 跟做:搭出第 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 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 <TabItem Header ="3 呈现方式" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="4,16,4,4" > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="同一份 Employees,五种画法" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="数据不变,只换容器和面板。ListView 适合多列对齐,TreeView 适合层级,DataGrid 适合表格编辑。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <Grid.RowDefinitions > <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> </Grid.RowDefinitions > <StackPanel Margin ="0,0,12,16" > <TextBlock Text ="ListView + GridView:多列对齐" FontWeight ="SemiBold" Margin ="0,0,0,8" /> <ListView ItemsSource ="{Binding Employees}" BorderBrush ="#DCE5F2" MinHeight ="240" > <ListView.View > <GridView > <GridViewColumn Header ="姓名" DisplayMemberBinding ="{Binding Name}" Width ="110" /> <GridViewColumn Header ="部门" DisplayMemberBinding ="{Binding Department}" Width ="110" /> <GridViewColumn Header ="工资" DisplayMemberBinding ="{Binding Salary, StringFormat={}{0:C0}}" Width ="110" /> </GridView > </ListView.View > </ListView > </StackPanel > <StackPanel Grid.Column ="1" Margin ="0,0,0,16" > <TextBlock Text ="TreeView:层级数据" FontWeight ="SemiBold" Margin ="0,0,0,8" /> <Border BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" Background ="White" > <TreeView ItemsSource ="{Binding Departments}" BorderThickness ="0" Background ="Transparent" MinHeight ="240" > <TreeView.ItemTemplate > <HierarchicalDataTemplate ItemsSource ="{Binding Employees}" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" Foreground ="#172033" /> </HierarchicalDataTemplate > </TreeView.ItemTemplate > <TreeView.ItemContainerStyle > <Style TargetType ="TreeViewItem" > <Setter Property ="IsExpanded" Value ="True" /> </Style > </TreeView.ItemContainerStyle > </TreeView > </Border > </StackPanel > <StackPanel Grid.Row ="1" Margin ="0,0,12,0" > <TextBlock Text ="DataGrid:可编辑表格" FontWeight ="SemiBold" Margin ="0,0,0,8" /> <DataGrid ItemsSource ="{Binding Employees}" SelectedItem ="{Binding SelectedEmployee, Mode=TwoWay}" AutoGenerateColumns ="False" CanUserAddRows ="False" HeadersVisibility ="Column" GridLinesVisibility ="Horizontal" AlternatingRowBackground ="#F7F9FD" BorderBrush ="#DCE5F2" MinHeight ="240" > <DataGrid.Columns > <DataGridTextColumn Header ="编号" Binding ="{Binding Id}" Width ="70" /> <DataGridTextColumn Header ="姓名" Binding ="{Binding Name}" Width ="*" /> <DataGridTextColumn Header ="部门" Binding ="{Binding Department}" Width ="*" /> <DataGridTextColumn Header ="工资" Binding ="{Binding Salary, StringFormat={}{0:C0}}" Width ="110" /> </DataGrid.Columns > </DataGrid > </StackPanel > <StackPanel Grid.Row ="1" Grid.Column ="1" > <TextBlock Text ="ItemsControl + WrapPanel:卡片墙" FontWeight ="SemiBold" Margin ="0,0,0,8" /> <Border BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" Background ="White" Padding ="10" MinHeight ="240" > <ItemsControl ItemsSource ="{Binding Employees}" > <ItemsControl.ItemsPanel > <ItemsPanelTemplate > <WrapPanel /> </ItemsPanelTemplate > </ItemsControl.ItemsPanel > <ItemsControl.ItemTemplate > <DataTemplate > <Border Width ="150" Margin ="0,0,10,10" Padding ="12" Background ="#F7F9FD" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <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 > </Border > </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="几种呈现方式的取舍" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="先问自己三件事:要不要选中、要不要多列、有没有层级。答案出来,控件就选出来了。" Style ="{StaticResource HintStyle}" /> </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
3.8 跟做检查点
检查项
期望结果
打开页签 3
四块内容呈 2×2 排列
看左上 ListView
有「姓名 / 部门 / 工资」三个列头,八行数据
看右上 TreeView
四个部门全部展开,每个部门下有两名成员
点 TreeView 的三角箭头
节点能折叠和展开
看左下 DataGrid
有列头,偶数行有浅灰底
双击 DataGrid 里的工资
进入编辑状态,可以改数字
看右下卡片墙
卡片自动换行
拖动窗口变窄
卡片墙行数增加,DataGrid 列宽跟着变
如果 DataGrid 的工资列显示成了 12000 而不是 $12,000,检查 StringFormat 是否写成了 StringFormat={}{0:C0}。开头那两个大括号不能省,因为 { 在 XAML 里是转义字符,第一个空大括号是转义标记。
3.9 小练习
把 ListView 的部门列改成 Width="*",拖动窗口观察哪一列在变宽。
把 DataGrid 的 IsReadOnly 设成 True,试试还能不能双击编辑。
给卡片墙的 WrapPanel 加上 ItemWidth="170",观察卡片宽度是否被强制统一。
给 Department 加一个 IsExpanded 属性,把 TreeViewItem 的 IsExpanded 绑到它上面,试试只展开部分部门。
4. ComboBox 的绑定使用 本节目标 学完本节,你应该能够:
说清 ItemsSource、SelectedItem、DisplayMemberPath、SelectedValuePath 各自管什么。
用 SelectedItem 做双向绑定,让界面和代码同步。
用 SelectedValuePath 实现「显示名称、写回编号」。
在下拉项里放 CheckBox,做出可多选的 ComboBox。
知道 SelectedIndex、SelectedValue、SelectedItem 三者的区别。
4.1 四个属性,各管一件事 ComboBox 的绑定就围绕四个属性转,先把它们摆清楚:
属性
管什么
方向
ItemsSource
选项从哪个集合来
单向往控件
SelectedItem
当前选中的整个对象
双向
SelectedValuePath
选中项里取出哪个字段
配合 SelectedValue
DisplayMemberPath
每个选项显示哪个字段
单向往控件
图 3:选项从哪来、选中项写到哪、下级数据跟谁走。
图中那句话值得记下来:
1 ItemsSource 是入口,SelectedItem 是出口,SelectedValuePath 是出口的窄门。
4.2 ItemsSource:选项从集合来 最简单的用法是直接绑一个集合:
1 <ComboBox ItemsSource ="{Binding Employees}" />
这时下拉项会显示每个 Employee 对象的类型名(形如 WpfListControlPractice.Models.Employee),因为 ComboBox 不知道该显示哪个字段。这不是错误,只是缺了显示规则。补上 DisplayMemberPath 就好了:
1 2 <ComboBox ItemsSource ="{Binding Employees}" DisplayMemberPath ="Name" />
想显示得更丰富,用 ItemTemplate:
1 2 3 4 5 6 7 8 <DataTemplate x:Key ="DepartmentComboTemplate" > <StackPanel Orientation ="Horizontal" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" /> <TextBlock Text ="(" Foreground ="#5D6B82" /> <TextBlock Text ="{Binding Count}" Foreground ="#2F6FED" /> <TextBlock Text =" 人)" Foreground ="#5D6B82" /> </StackPanel > </DataTemplate >
1 2 3 <ComboBox ItemsSource ="{Binding Departments}" ItemTemplate ="{StaticResource DepartmentComboTemplate}" SelectedIndex ="0" />
注意这里同时用了 ItemTemplate 和 SelectedIndex。SelectedIndex 是「选中第几项」,从 0 开始数,写 0 就是默认选中第一项。它的好处是不用关心对象是谁;坏处是集合顺序一变,选中的东西就跟着变了。所以只在「初始占位」时用它,真正的选择结果仍然用 SelectedItem 绑定。
4.3 SelectedItem:双向绑定 这是最常用的一种写法:
1 2 3 <ComboBox ItemsSource ="{Binding Employees}" SelectedItem ="{Binding SelectedEmployee, Mode=TwoWay}" DisplayMemberPath ="Name" />
三个变化同时发生:
界面加载时,SelectedEmployee 里已经有张三,ComboBox 自动选中张三。
用户选李四,SelectedEmployee 被写成李四。
代码里改 SelectedEmployee,界面上的选中项跟着变。
第 3 条就是 TwoWay 的意义。如果只写 {Binding SelectedEmployee},默认方向要看控件,SelectedItem 默认是双向的,但显式写 Mode=TwoWay 更不容易出错,也更方便别人阅读。
选中项一变,界面上其它地方立刻跟着更新:
1 2 3 4 5 6 7 8 <TextBlock Foreground ="#5D6B82" > <Run Text ="当前选中:" /> <Run Text ="{Binding SelectedEmployee.Name, Mode=OneWay}" /> <Run Text =" / " /> <Run Text ="{Binding SelectedEmployee.Department, Mode=OneWay}" /> <Run Text =" / " /> <Run Text ="{Binding SelectedEmployee.Salary, Mode=OneWay, StringFormat={}{0:C0}}" /> </TextBlock >
SelectedEmployee.Name 是「属性路径」,中间用点连起来。WPF 会一层层取值,并在每一层都挂上变化通知,所以只要 SelectedEmployee 变了,整串都会重新取。
4.4 SelectedValuePath:显示名称,写回编号 数据库里存的是编号,界面上要显示名称,这是最常见的场景。SelectedValuePath 就是为它准备的:
1 2 3 4 <ComboBox ItemsSource ="{Binding Levels}" DisplayMemberPath ="Name" SelectedValuePath ="Id" SelectedValue ="{Binding SelectedLevelId, Mode=TwoWay}" />
数据是:
1 2 3 4 5 6 7 Levels = new ObservableCollection<LevelOption> { new (1 , "初级" ), new (2 , "中级" ), new (3 , "高级" ), new (4 , "专家" ) };
下拉项里显示「中级」,写回 ViewModel 的却是 2。SelectedValuePath="Id" 的作用就是:选中时先取出该项的 Id,再把它赋给 SelectedValue。
两个绑定对比着看:
1 2 SelectedItem → 给出整个对象(LevelOption) SelectedValue → 给出对象上的某个字段(Id 的值 2)
用哪个?看 ViewModel 那边需要什么。要整个对象就用 SelectedItem;只要一个编号(比如要存进数据库)就用 SelectedValuePath + SelectedValue。
需要注意的一点:SelectedValuePath 只有在同时写了 SelectedValue 或 SelectedItem 时才有意义。只写 SelectedValuePath 不写这两个,它不产生任何效果。
4.5 下拉项里放 CheckBox:可多选的 ComboBox ComboBox 本身只能单选,但把 CheckBox 放进 ItemTemplate,就能当成一个「可展开的勾选面板」:
1 2 3 4 5 6 7 <DataTemplate x:Key ="SkillCheckTemplate" > <StackPanel Orientation ="Horizontal" Margin ="2,4" > <CheckBox IsChecked ="{Binding IsSelected, Mode=TwoWay}" VerticalAlignment ="Center" /> <TextBlock Text ="{Binding Name}" Margin ="8,0,0,0" /> </StackPanel > </DataTemplate >
1 2 3 4 <ComboBox ItemsSource ="{Binding Skills}" ItemTemplate ="{StaticResource SkillCheckTemplate}" HorizontalAlignment ="Stretch" VerticalAlignment ="Top" />
这里的关键是 CheckBox 的 IsChecked 绑到了数据对象自己的 IsSelected 上,而不是绑到 ComboBox 的选中状态上。所以:
勾选状态存在每个 Skill 对象里,不在 ComboBox 里。
ComboBox 自己的 SelectedItem 在这套用法里没有意义,可以不绑。
想统计「勾了几个」,需要数据对象在 IsSelected 变化时通知 ViewModel。
最后一条就是 Skill 类为什么要实现 INotifyPropertyChanged:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public bool IsSelected{ get => _isSelected; set { if (_isSelected == value ) { return ; } _isSelected = value ; PropertyChanged?.Invoke( this , new PropertyChangedEventArgs(nameof (IsSelected))); } }
ViewModel 里给每个 Skill 挂上监听,勾选一变就重新算汇总:
1 2 3 4 5 6 7 8 9 10 11 foreach (var skill in Skills){ skill.PropertyChanged += (_, args) => { if (args.PropertyName == nameof (Skill.IsSelected)) { OnPropertyChanged(nameof (SelectedSkillText)); OnPropertyChanged(nameof (SelectedSkillCount)); } }; }
1 2 3 4 public string SelectedSkillText => string .Join("、" , Skills.Where(skill => skill.IsSelected).Select(skill => skill.Name)); public int SelectedSkillCount => Skills.Count(skill => skill.IsSelected);
SelectedSkillText 和 SelectedSkillCount 是派生属性,它们不会自己通知界面,所以上面手动写了 OnPropertyChanged。第 5 章还会反复用到这个套路。
4.6 三种「选中」的区别 初学者容易混的是这三个:
属性
含义
类型
典型用途
SelectedIndex
选中第几项
int
初始占位、按位置定位
SelectedItem
选中的对象
数据对象
主从视图、联动
SelectedValue
选中对象的某个字段
字段类型
存编号、传外键
还有两个容易踩的点:
集合为空时 SelectedIndex 是 -1,SelectedItem 是 null。绑定到 int 属性时要考虑 -1 的情况。
如果绑定的 SelectedItem 对象不在 ItemsSource 里,ComboBox 会显示空白,也不报错。这种问题通常出在「重新构造了集合」或者「用了两个不同的对象实例」上。
4.7 跟做:搭出第 4 章页签 第一步,把两个模板加到 Window.Resources:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <DataTemplate x:Key ="DepartmentComboTemplate" > <StackPanel Orientation ="Horizontal" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" /> <TextBlock Text ="(" Foreground ="#5D6B82" /> <TextBlock Text ="{Binding Count}" Foreground ="#2F6FED" /> <TextBlock Text =" 人)" Foreground ="#5D6B82" /> </StackPanel > </DataTemplate > <DataTemplate x:Key ="SkillCheckTemplate" > <StackPanel Orientation ="Horizontal" Margin ="2,4" > <CheckBox IsChecked ="{Binding IsSelected, Mode=TwoWay}" VerticalAlignment ="Center" /> <TextBlock Text ="{Binding Name}" Margin ="8,0,0,0" /> </StackPanel > </DataTemplate >
第二步,把页签 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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 <TabItem Header ="4 ComboBox 绑定" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="4,16,4,4" > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="ItemsSource 与 SelectedItem" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="ItemsSource 提供选项,SelectedItem 双向绑到 ViewModel 属性。选中项一变,界面和代码同时更新。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,12,0" > <TextBlock Text ="选中员工" Foreground ="#5D6B82" /> <ComboBox ItemsSource ="{Binding Employees}" SelectedItem ="{Binding SelectedEmployee, Mode=TwoWay}" DisplayMemberPath ="Name" /> <TextBlock Foreground ="#5D6B82" > <Run Text ="当前选中:" /> <Run Text ="{Binding SelectedEmployee.Name, Mode=OneWay}" /> <Run Text =" / " /> <Run Text ="{Binding SelectedEmployee.Department, Mode=OneWay}" /> <Run Text =" / " /> <Run Text ="{Binding SelectedEmployee.Salary, Mode=OneWay, StringFormat={}{0:C0}}" /> </TextBlock > </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="用 ItemTemplate 自定义显示" Foreground ="#5D6B82" /> <ComboBox ItemsSource ="{Binding Departments}" ItemTemplate ="{StaticResource DepartmentComboTemplate}" SelectedIndex ="0" /> <TextBlock Text ="下拉项里直接显示部门人数,这靠的是 ItemTemplate,不是 DisplayMemberPath。" Style ="{StaticResource HintStyle}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="SelectedValuePath:显示名称,传出编号" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="列表里显示「中级」,但绑定写回的是它的 Id。适合数据库里存编号、界面显示名称的场景。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,12,0" > <TextBlock Text ="SelectedValuePath 绑定" Foreground ="#5D6B82" /> <ComboBox ItemsSource ="{Binding Levels}" DisplayMemberPath ="Name" SelectedValuePath ="Id" SelectedValue ="{Binding SelectedLevelId, Mode=TwoWay}" /> <TextBlock Foreground ="#5D6B82" > <Run Text ="写回的编号:" /> <Run Text ="{Binding SelectedLevelId, Mode=OneWay}" /> </TextBlock > </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="SelectedItem 绑定" Foreground ="#5D6B82" /> <ComboBox ItemsSource ="{Binding Levels}" DisplayMemberPath ="Name" SelectedItem ="{Binding SelectedLevel, Mode=TwoWay}" /> <TextBlock Foreground ="#5D6B82" > <Run Text ="整个对象:" /> <Run Text ="{Binding SelectedLevel.Name, Mode=OneWay}" /> </TextBlock > <TextBlock Text ="SelectedItem 给出整个对象,SelectedValuePath 只给出其中一个字段,按需选择。" Style ="{StaticResource HintStyle}" Margin ="0,8,0,0" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="下拉项里放 CheckBox:多选 ComboBox" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="勾选状态写在数据对象上,ComboBox 只负责显示。选中项本身没有意义,真正生效的是每一项的 IsSelected。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="300" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <ComboBox ItemsSource ="{Binding Skills}" ItemTemplate ="{StaticResource SkillCheckTemplate}" HorizontalAlignment ="Stretch" VerticalAlignment ="Top" /> <StackPanel Grid.Column ="1" Margin ="16,0,0,0" > <TextBlock Foreground ="#5D6B82" Visibility ="{Binding SelectedSkillCount, Converter={StaticResource CountToVisibilityConverter}}" > <Run Text ="已选技能数量:" /> <Run Text ="{Binding SelectedSkillCount, Mode=OneWay}" /> </TextBlock > <TextBlock Margin ="0,6,0,0" Foreground ="#2F6FED" TextWrapping ="Wrap" Text ="{Binding SelectedSkillText}" Visibility ="{Binding SelectedSkillCount, Converter={StaticResource CountToVisibilityConverter}}" /> <TextBlock Margin ="0,6,0,0" Foreground ="#5D6B82" Text ="还没有勾选任何技能,点开左边的下拉框试试。" Visibility ="{Binding SelectedSkillCount, Converter={StaticResource CountToVisibilityConverter}, ConverterParameter=Invert}" /> <TextBlock Margin ="0,10,0,0" Style ="{StaticResource HintStyle}" Text ="注意:这里的 ComboBox 不是用来「选一项」的,而是当作一个可展开的勾选面板。这种用法需要每一项自己发出属性变化通知。" /> </StackPanel > </Grid > </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
这里用到了 CountToVisibilityConverter:数量大于 0 时显示已选内容,等于 0 时显示提示文字。它的 ConverterParameter 传 Invert 就表示「数量为 0 才显示」,所以两个 TextBlock 正好互斥。
4.8 跟做检查点
检查项
期望结果
打开页签 4,看左上下拉框
默认选中张三
换成「郑十」
下方文字立刻变成「郑十 / 设计部 / $13,200」
看右上下拉框
下拉项显示「研发部(2 人)」这样的格式
看中间等级下拉框
显示「中级」,「写回的编号」显示 2
换成「专家」
编号变成 4
点开技能下拉框,勾选 C# 和 SQL
右侧显示「已选技能数量:2」和「C#、SQL」
取消全部勾选
右侧显示「还没有勾选任何技能」的提示
如果等级下拉框一打开是空白的,检查 Levels 集合是否有数据,以及 SelectedLevelId 的初值 2 是否对应得上 Levels 里的某一项。
4.9 小练习
给员工下拉框加 IsEditable="True",试试能不能手动输入文字。注意输入的内容不会自动变成选中项。
把技能下拉框的 ComboBox 换成 ItemsControl + Expander,比较两者的展开体验。
加一个「全选」按钮:点击后把所有 Skill.IsSelected 设成 True。提示:遍历 Skills 赋值即可,通知由每个 Skill 自己发出。
把 SelectedValuePath 改成 Name,观察「写回的编号」变成文字。想清楚这种写法在什么场景下有意义。
5. 多级数据联动案例 本节目标 学完本节,你应该能够:
用 ViewModel 派生属性搭出省市区三级联动。
说清「上一级一变,下面各级为什么要重置」。
用纯 XAML 的 ElementName 写法实现同样的联动。
比较两条实现路线的优缺点,按场景选择。
知道联动时哪些属性需要手动通知。
5.1 联动的本质 三级联动的界面看起来是三件事,本质上只有一句话:
省决定市,市决定区。写代码时就是三件事反复出现:
上一级的选中项变了。
下一级的选项集合跟着变。
下一级的选中项重置为新集合的第一项(或清空)。
第 3 步最容易被忽略。如果不重置,会出现「省份换成浙江省,市级还停在上一个省选的城市」这种错乱状态。
5.2 数据准备:一个类搞定三级 先看模型。省、市、区用同一个类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class Region { public Region (string name, params Region[] children ) { Name = name; Children = new ObservableCollection<Region>(children); } public string Name { get ; } public ObservableCollection<Region> Children { get ; } public bool HasChildren => Children.Count > 0 ; public override string ToString () => Name; }
这样构造数据:
1 2 3 4 5 6 7 8 9 10 11 12 Provinces = new ObservableCollection<Region> { new ("江苏省" , new Region("南京市" , new Region("玄武区" ), new Region("鼓楼区" )), new Region("苏州市" , new Region("姑苏区" ), new Region("吴中区" ))), new ("浙江省" , new Region("杭州市" , new Region("西湖区" ), new Region("余杭区" )), new Region("宁波市" , new Region("海曙区" ), new Region("鄞州区" ))), new ("广东省" , new Region("广州市" , new Region("天河区" ), new Region("越秀区" )), new Region("深圳市" , new Region("南山区" ), new Region("福田区" ))) };
用同一个类型有个直接好处:三段 XAML 可以完全一样,只是绑定的属性名不同。如果省、市、区各写一个类,就要写三套模板。
ToString() 的重写是为了兜底:如果哪天忘了写 DisplayMemberPath,ComboBox 会退而调用对象的 ToString(),至少显示的是名称而不是类型名。
5.3 ViewModel:派生属性 + 手动通知 三个选中属性,每个都在 set 里做「重置下一级 + 通知」:
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 public Region? SelectedProvince{ get => _selectedProvince; set { if (SetField(ref _selectedProvince, value )) { SelectedCity = value ?.Children.FirstOrDefault(); OnPropertyChanged(nameof (Cities)); OnPropertyChanged(nameof (CityCount)); OnPropertyChanged(nameof (LocationText)); } } } public Region? SelectedCity{ get => _selectedCity; set { if (SetField(ref _selectedCity, value )) { SelectedDistrict = value ?.Children.FirstOrDefault(); OnPropertyChanged(nameof (Districts)); OnPropertyChanged(nameof (DistrictCount)); OnPropertyChanged(nameof (LocationText)); } } } public Region? SelectedDistrict{ get => _selectedDistrict; set { if (SetField(ref _selectedDistrict, value )) { OnPropertyChanged(nameof (LocationText)); } } }
下一级的选项是派生属性,没有自己的字段:
1 2 3 4 5 6 7 8 9 10 public ObservableCollection<Region>? Cities => SelectedProvince?.Children;public ObservableCollection<Region>? Districts => SelectedCity?.Children;public int CityCount => SelectedProvince?.Children.Count ?? 0 ;public int DistrictCount => SelectedCity?.Children.Count ?? 0 ;public string LocationText => $"{SelectedProvince?.Name ?? "未选择" } / {SelectedCity?.Name ?? "未选择" } / {SelectedDistrict?.Name ?? "未选择" } " ;
这就是整节最重要的一句话 :
1 2 派生属性不会自己通知界面。 它的值变了,界面不知道,必须由人来手动喊一声。
注意 SelectedProvince 的 set 里,先写 SelectedCity = ...,再写 OnPropertyChanged(nameof(Cities))。顺序有讲究:
改 SelectedCity 会触发它自己的 set,顺带把 Districts 通知出去。
但如果先通知 Cities 再改 SelectedCity,界面可能先拿到「旧城市的新省」这个中间状态,显示会闪一下。
所以规则是:先把状态改完,再统一通知 。
5.4 界面:三段几乎一样的 XAML 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,12,0" > <TextBlock Text ="省级" Foreground ="#5D6B82" /> <ComboBox x:Name ="ProvinceBox" ItemsSource ="{Binding Provinces}" SelectedItem ="{Binding SelectedProvince, Mode=TwoWay}" DisplayMemberPath ="Name" /> </StackPanel > <StackPanel Grid.Column ="1" Margin ="0,0,12,0" > <TextBlock Text ="市级" Foreground ="#5D6B82" /> <ComboBox ItemsSource ="{Binding Cities}" SelectedItem ="{Binding SelectedCity, Mode=TwoWay}" DisplayMemberPath ="Name" /> </StackPanel > <StackPanel Grid.Column ="2" > <TextBlock Text ="区级" Foreground ="#5D6B82" /> <ComboBox ItemsSource ="{Binding Districts}" SelectedItem ="{Binding SelectedDistrict, Mode=TwoWay}" DisplayMemberPath ="Name" /> </StackPanel > </Grid >
三段代码只有两处不同:ItemsSource 绑的属性名,以及 SelectedItem 绑的属性名。这就是「数据形状统一」带来的好处。
x:Name="ProvinceBox" 这一处命名,是为后面的 ElementName 版本留的对照,本身不是必需的。
5.5 纯 XAML 路线:ElementName 同样的联动可以不写 ViewModel 代码,直接让后一级去取前一级控件的属性:
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 <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,12,0" > <TextBlock Text ="第一级:部门" Foreground ="#5D6B82" /> <ListBox x:Name ="DeptList" ItemsSource ="{Binding Departments}" BorderBrush ="#DCE5F2" MaxHeight ="200" > <ListBox.ItemTemplate > <DataTemplate > <StackPanel Margin ="2,5" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" /> <TextBlock Foreground ="#5D6B82" > <Run Text ="{Binding Count, Mode=OneWay}" /> <Run Text =" 人" /> </TextBlock > </StackPanel > </DataTemplate > </ListBox.ItemTemplate > </ListBox > </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="第二级:成员(来源是左边的选中项)" Foreground ="#5D6B82" /> <ListBox ItemsSource ="{Binding SelectedItem.Employees, ElementName=DeptList}" SelectedItem ="{Binding SelectedEmployee, Mode=TwoWay}" BorderBrush ="#DCE5F2" MaxHeight ="200" > <ListBox.ItemTemplate > <DataTemplate > <StackPanel Margin ="2,5" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" /> <TextBlock Text ="{Binding Salary, StringFormat={}{0:C0}}" Foreground ="#5D6B82" /> </StackPanel > </DataTemplate > </ListBox.ItemTemplate > </ListBox > </StackPanel > </Grid >
核心只有一行:
1 ItemsSource="{Binding SelectedItem.Employees, ElementName=DeptList}"
读法是:找到界面上叫 DeptList 的控件,取它的 SelectedItem,再取那个对象的 Employees 属性。ElementName 已经在第 4 篇笔记里详细讲过,这里只是把它用在联动上。
还可以用局部 DataContext 缩短路径,把「部门统计」整块挂到当前部门上:
1 2 3 4 5 6 7 8 9 10 11 <StackPanel DataContext ="{Binding SelectedItem, ElementName=DeptList}" Margin ="0,16,0,0" > <TextBlock Foreground ="#5D6B82" > <Run Text ="部门人数:" /> <Run Text ="{Binding Count, Mode=OneWay}" /> <Run Text =" 工资合计:" /> <Run Text ="{Binding TotalSalary, Mode=OneWay, StringFormat={}{0:C0}}" /> <Run Text =" 平均:" /> <Run Text ="{Binding AverageSalary, Mode=OneWay, StringFormat={}{0:C0}}" /> </TextBlock > </StackPanel >
DataContext 一换,内部所有绑定都以「当前部门」为起点,路径从 SelectedItem.Count 缩短成了 Count。
5.6 两条路线怎么选
对比项
ViewModel 派生属性
纯 XAML(ElementName)
代码量
多一点(要写属性和通知)
少,只在 XAML 里
逻辑位置
集中在 ViewModel
藏在界面里
方便加校验
方便,可以在 set 里判断
不方便
方便复用
高,换个界面照样能用
低,换界面要重写
适合场景
层级深、后续要处理数据
层级浅、纯展示联动
实际项目里的常见做法是:
1 2 两级以内、纯展示:ElementName 就够了。 三级以上、要参与计算或校验:放到 ViewModel 里。
两种写法也能混用。比如省市区用 ViewModel,界面上一块「当前部门统计」用 ElementName + 局部 DataContext。只要数据流向清楚,混用没有问题。
5.7 联动最容易出的三个问题 问题一:换了省,市没变。
原因通常是 SelectedProvince 的 set 里没写 SelectedCity = value?.Children.FirstOrDefault();。只通知了 Cities,但没重置选中项。
问题二:市变了,区还是空的。
检查 SelectedCity 的 set 是否也做了重置。三级联动要写两处重置,不是一处。
问题三:界面完全不更新。
九成是派生属性忘了通知。记住这句话:Cities、Districts、LocationText、CityCount、DistrictCount 都没有自己的字段,值变了界面不会知道,必须在上一级的 set 里手动 OnPropertyChanged。
排查这类问题的第一站永远是 Visual Studio 的「输出」窗口,把来源切到「调试」,绑定错误会打印在那里,例如「找不到属性 Cities」或「路径上的对象为 null」。
5.8 跟做:搭出第 5 章页签 把下面的内容填进页签 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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 <TabItem Header ="5 多级联动" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="4,16,4,4" > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="省市区三级联动" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="每个下拉框的数据来源是上一级的选中项。换上一级,下面两级自动重置。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,12,0" > <TextBlock Text ="省级" Foreground ="#5D6B82" /> <ComboBox x:Name ="ProvinceBox" ItemsSource ="{Binding Provinces}" SelectedItem ="{Binding SelectedProvince, Mode=TwoWay}" DisplayMemberPath ="Name" /> </StackPanel > <StackPanel Grid.Column ="1" Margin ="0,0,12,0" > <TextBlock Text ="市级" Foreground ="#5D6B82" /> <ComboBox ItemsSource ="{Binding Cities}" SelectedItem ="{Binding SelectedCity, Mode=TwoWay}" DisplayMemberPath ="Name" /> </StackPanel > <StackPanel Grid.Column ="2" > <TextBlock Text ="区级" Foreground ="#5D6B82" /> <ComboBox ItemsSource ="{Binding Districts}" SelectedItem ="{Binding SelectedDistrict, Mode=TwoWay}" DisplayMemberPath ="Name" /> </StackPanel > </Grid > <Border Margin ="0,16,0,0" Padding ="14" Background ="#EAF1FF" CornerRadius ="8" > <TextBlock Foreground ="#174EA6" FontWeight ="SemiBold" Text ="{Binding LocationText}" /> </Border > <Grid Margin ="0,16,0,0" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <TextBlock Foreground ="#5D6B82" > <Run Text ="当前省的下级数量:" /> <Run Text ="{Binding CityCount, Mode=OneWay}" /> </TextBlock > <TextBlock Grid.Column ="1" Foreground ="#5D6B82" > <Run Text ="当前市的下级数量:" /> <Run Text ="{Binding DistrictCount, Mode=OneWay}" /> </TextBlock > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,18" > <StackPanel > <TextBlock Text ="用 ElementName 实现联动(不写代码的版本)" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="后一级的 ItemsSource 直接指向前一级控件的 SelectedItem,联动关系完全交给 XAML。" Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,12,0" > <TextBlock Text ="第一级:部门" Foreground ="#5D6B82" /> <ListBox x:Name ="DeptList" ItemsSource ="{Binding Departments}" BorderBrush ="#DCE5F2" MaxHeight ="200" > <ListBox.ItemTemplate > <DataTemplate > <StackPanel Margin ="2,5" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" /> <TextBlock Foreground ="#5D6B82" > <Run Text ="{Binding Count, Mode=OneWay}" /> <Run Text =" 人" /> </TextBlock > </StackPanel > </DataTemplate > </ListBox.ItemTemplate > </ListBox > </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="第二级:成员(来源是左边的选中项)" Foreground ="#5D6B82" /> <ListBox ItemsSource ="{Binding SelectedItem.Employees, ElementName=DeptList}" SelectedItem ="{Binding SelectedEmployee, Mode=TwoWay}" BorderBrush ="#DCE5F2" MaxHeight ="200" > <ListBox.ItemTemplate > <DataTemplate > <StackPanel Margin ="2,5" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" /> <TextBlock Text ="{Binding Salary, StringFormat={}{0:C0}}" Foreground ="#5D6B82" /> </StackPanel > </DataTemplate > </ListBox.ItemTemplate > </ListBox > </StackPanel > </Grid > <StackPanel DataContext ="{Binding SelectedItem, ElementName=DeptList}" Margin ="0,16,0,0" > <TextBlock Foreground ="#5D6B82" > <Run Text ="部门人数:" /> <Run Text ="{Binding Count, Mode=OneWay}" /> <Run Text =" 工资合计:" /> <Run Text ="{Binding TotalSalary, Mode=OneWay, StringFormat={}{0:C0}}" /> <Run Text =" 平均:" /> <Run Text ="{Binding AverageSalary, Mode=OneWay, StringFormat={}{0:C0}}" /> </TextBlock > </StackPanel > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="联动关系的两条实现路线" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Text ="两种做法都能实现联动。纯 XAML 版本代码最少,但联动关系藏在界面里;ViewModel 版本代码多一些,但逻辑集中、方便加校验和统计。" Style ="{StaticResource HintStyle}" /> </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
5.9 跟做检查点
检查项
期望结果
打开页签 5
默认显示「江苏省 / 南京市 / 玄武区」
把省换成「浙江省」
市自动变成「杭州市」,区变成「西湖区」
把市换成「宁波市」
区自动变成「海曙区」
底部文字
跟着变成「浙江省 / 宁波市 / 海曙区」
「当前省的下级数量」
始终显示 2
看下方部门列表
默认选中研发部,右侧显示张三和孙七
点「设计部」
右侧变成王五和郑十,统计显示 2 人、合计 $23,000、平均 $11,500
点「测试部」
右侧变成赵六和周八
如果换省后市没有重置,回到 MainViewModel 检查 SelectedProvince 的 set 里那行 SelectedCity = value?.Children.FirstOrDefault(); 是否漏写。
5.10 小练习
在 Region 上加一个「地区编码」字段,让下拉框显示「南京市(NJ)」这样的格式。
把省市区三级联动的重置逻辑抽成一个方法 ResetCityAndDistrict(),观察 set 里能不能少写几行。
加第四个下拉框「街道」,数据从 District.Children 取。这时会暴露一个问题:现有数据里区下面是空的,需要先把数据补上。
给三级联动加一个「不允许跨省」的校验:如果用户选的市不属于当前省,就把它清空并提示。想想这个校验该写在 set 里还是写在别的地方。
完整示例结构 跟做完成后,项目结构大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 WpfListControlPractice\ ├── App.xaml ├── App.xaml.cs ├── AssemblyInfo.cs ├── Converters\ │ ├── BoolToBrushConverter.cs │ └── CountToVisibilityConverter.cs ├── Models\ │ ├── Department.cs │ ├── Employee.cs │ ├── LevelOption.cs │ ├── Region.cs │ └── Skill.cs ├── ViewModels\ │ └── MainViewModel.cs ├── MainWindow.xaml ├── MainWindow.xaml.cs └── WpfListControlPractice.csproj
五个页签与五个章节一一对应:
页签
对应章节
核心演示
1 子项结构
第 1 章
ItemsControl、ListBox、ComboBox 三列并排
2 模板组合
第 2 章
ItemContainerStyle、ContentTemplate、列表套列表
3 呈现方式
第 3 章
ListView、TreeView、DataGrid、卡片墙
4 ComboBox 绑定
第 4 章
SelectedItem、SelectedValuePath、下拉项里放 CheckBox
5 多级联动
第 5 章
三级区域联动、ElementName 联动、部门主从视图
运行检查表 启动程序后,按这个顺序检查一遍:
步骤
操作
期望结果
1
打开页签「1 子项结构」
三列并排:ItemsControl、ListBox、ComboBox
2
点左列 ItemsControl 里的一项
没有任何选中反应
3
点中列 ListBox 里的「王五」
该项出现蓝色边框和浅蓝底
4
点中列 ComboBox 的箭头
弹出下拉列表,每项用的是同一套模板
5
打开页签「2 模板组合」
列表项之间有间距,点一项出现蓝色粗边框
6
在左列小列表里点「孙七」
右侧工资显示 $14,500,身份显示「主管」
7
看下方部门卡片
四个部门,每个下面列出 2 名成员
8
打开页签「3 呈现方式」
四块内容呈 2×2:ListView、TreeView、DataGrid、卡片墙
9
双击 DataGrid 里的工资
进入编辑状态,可以改数字
10
打开页签「4 ComboBox 绑定」
默认选中张三,等级下拉框显示「中级」,写回编号是 2
11
勾选技能里的「C#」和「SQL」
右侧显示「已选技能数量:2」和「C#、SQL」
12
取消全部勾选
显示「还没有勾选任何技能」的提示
13
打开页签「5 多级联动」
默认显示「江苏省 / 南京市 / 玄武区」
14
把省换成「浙江省」
市自动变「杭州市」,区自动变「西湖区」
15
点部门列表里的「设计部」
右侧变成王五和郑十,合计 $23,000,平均 $11,500
如果某一步结果不对,先在「输出」窗口里找绑定错误。列表相关的「界面没反应」,绝大多数是 ItemsSource 或者绑定路径写错,而不是控件本身有问题。
综合练习 练习一:可筛选的员工选择器 要求:
页签里加一个搜索框,输入关键字后列表只剩匹配的员工。
关键字同时匹配姓名和部门。
搜索框清空后恢复全部数据。
列表项仍然使用第 1 章的 EmployeeRowTemplate。
提示:在 MainViewModel 里加 SearchText 属性和 FilteredEmployees 派生属性,SearchText 变化时通知 FilteredEmployees。界面上的列表把 ItemsSource 换成 FilteredEmployees 即可,模板一个字都不用改。
练习二:带汇总的下拉框 要求:
部门下拉框的选项显示「研发部(2 人) $26,500」这样的格式。
选中部门后,下方列表显示该部门成员。
成员工资改动后,下拉框里的合计跟着变。
提示:Department 上的 Count 和 TotalSalary 是只读派生属性,不会自动通知。先把人数显示出来,再思考怎样让合计也实时更新。
练习三:三层主从视图 要求:
第一层:部门列表。
第二层:该部门员工列表,按工资降序排列。
第三层:选中员工的详细表单,改动后前两层同步更新。
右侧技能下拉框支持多选,选中结果实时显示。
提示:排序用 CollectionViewSource 或者 ListCollectionView 的 SortDescriptions;技能多选直接沿用第 4 章的 SkillCheckTemplate。
结语 这一篇的五章可以压缩成五句话:
章节
一句话
子项结构区别
控件之间的差别,是子项外面多包了几层容器
模板组合使用
内容模板管画法,容器样式管外观和状态
集合呈现方式
数据不动,换一个 ItemsPanel 或者换一个控件就换了画法
ComboBox 绑定
ItemsSource 给选项,SelectedItem 记选中,SelectedValuePath 决定写回什么
多级联动
前一级的选中项,就是后一级的 ItemsSource
再往深处走,会碰到虚拟化、分组、排序、筛选、CollectionViewSource、异步加载、大数据量渲染这些话题。但基础打牢之后,那些内容都只是在这套骨架上的扩展。
动手顺序建议:先把第 1 章的三种控件并排跑通,再加第 2 章的容器样式和列表套列表,然后补第 3 章的四种呈现方式,最后做第 4、5 章的下拉框与联动。每完成一步都运行一次,看到变化再继续。
祝学习顺利。