面向已经练过数据绑定和数据源,准备把绑定写稳、写准、写顺的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,以便在当前安装了 .NET 9 SDK 的电脑上直接编译。这里用到的绑定机制在 .NET 9 和 .NET 10 中没有变化。 最后更新:2026-10-20
目录
绑定常见问题处理:相对源与多级联动问题
绑定对象的 Mode 属性
绑定对象的 UpdateSourceTrigger 与 Delay 属性
绑定对象的 StringFormat 属性
绑定对象的 Converter 属性
写在前面 这份笔记解决什么问题 前面几篇笔记把绑定的主干讲完了:值和界面怎么连、来源从哪里来、模板怎么用、列表怎么呈现。到这一步,大部分界面都能写出来了。
但真正开始写业务界面以后,问题往往不是「绑不上」,而是「绑上了但不对劲」:
1 2 3 4 5 模板里想用外面的数据,路径写对了却一直空白,为什么? 同一个属性,有时候界面改不动来源,有时候又改得动,差别在哪? 文本框每敲一个字就触发一次查询,太频繁了,能不能等一下再触发? 金额显示成 1234.5,想要 $1,234.50,该在哪里改? 界面要显示「高级」「中级」,数据里只有数字,怎么翻译?
这些问题全都落在绑定对象自己的属性 上。一个 Binding 除了 Path 和 Source,还能配五个关键选项:
选项
回答的问题
RelativeSource 等相对源
来源对象怎么找,尤其是模板内部怎样找到外面那层
Mode
值往哪个方向流,哪一边说了算
UpdateSourceTrigger 与 Delay
什么时候把界面的值写回来源,写多快
StringFormat
数字、日期在界面上长什么样
Converter
数据与界面之间需要翻译时,谁来翻译
本篇就是把这五个选项一个一个拆开。它们不是五个孤立的知识点,而是同一个绑定表达式上的五个参数。
图 1:本篇五章,就是同一个 Binding 上的五个可配置项。
建议的学习顺序
先运行随附示例,把五个页签逐个点一遍,看清楚每个页签在演示什么。
回到第 1 章,先弄明白「为什么模板里的路径会失效」,这是最容易卡住初学者的地方。
第 2、3 章一起练:先搞清方向,再搞清时机,这两件事经常一起出错。
第 4、5 章属于「让界面好看」的部分,可以放在最后慢慢调。
每完成一章,就在自己的跟做项目里加一个页签。
示例项目位置 1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfBindingDetailsDemo
在 PowerShell 中运行:
1 2 3 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfBindingDetailsDemo" dotnet build dotnet run
如果使用 Visual Studio:
打开 WpfBindingDetailsDemo.csproj。
确认启动项目是 WpfBindingDetailsDemo。
按 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# 代码不需要任何修改。
先建一个自己的跟做项目 打开 PowerShell,执行:
1 2 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码" dotnet new wpf -n WpfBindingDetailsPractice -o .\WpfBindingDetailsPractice
为了容纳五个页签,先把窗口调大一些,并把页签骨架搭好:
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 ="WpfBindingDetailsPractice.MainWindow" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" Title ="WPF 绑定细节练习" Width ="1340" Height ="940" MinWidth ="1100" MinHeight ="760" WindowStartupLocation ="CenterScreen" Background ="#F4F7FC" > <TabControl Margin ="26" > <TabItem Header ="1 相对源与联动" > </TabItem > <TabItem Header ="2 Mode 属性" > </TabItem > <TabItem Header ="3 写回时机与 Delay" > </TabItem > <TabItem Header ="4 StringFormat 格式化" > </TabItem > <TabItem Header ="5 Converter 转换器" > </TabItem > </TabControl > </Window >
先按 F5 跑一次。看到五个空页签,就说明环境没问题。
先准备好两份数据模型 五章的示例共用同一套数据。先在项目里新建 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 using System.ComponentModel;using System.Runtime.CompilerServices;namespace WpfBindingDetailsPractice.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 { if (SetField(ref _isManager, value )) { OnPropertyChanged(nameof (Level)); } } } 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 ; } private void OnPropertyChanged (string propertyName ) { PropertyChanged?.Invoke(this , new PropertyChangedEventArgs(propertyName)); } }
注意 IsManager 的 set 里多了一句 OnPropertyChanged(nameof(Level))。Level 只有 get、没有 set,它自己不会通知界面。凡是「由别的属性算出来的属性」,都要像这样在源属性变化时手动通知一次。
Models\Department.cs(部门模型,第 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 using System.Collections.ObjectModel;namespace WpfBindingDetailsPractice.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); }
先准备好三个转换器 新建 Converters 文件夹,添加三个转换器。它们主要在第 5 章用到,先一次性准备好。
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 50 51 52 using System.Globalization;using System.Windows.Data;using System.Windows.Media;namespace WpfBindingDetailsPractice.Converters ;public class BoolToBrushConverter : IValueConverter { private static readonly Brush TrueBrush = CreateBrush("#2F6FED" ); private static readonly Brush FalseBrush = CreateBrush("#5D6B82" ); public object Convert ( object value , Type targetType, object parameter, CultureInfo culture ) { var flag = value is true ; if (parameter is string text) { var parts = text.Split('|' ); if (parts.Length == 2 ) { return CreateBrush(flag ? parts[0 ] : parts[1 ]); } } return flag ? TrueBrush : FalseBrush; } public object ConvertBack ( object value , Type targetType, object parameter, CultureInfo culture ) { throw new NotSupportedException(); } private static Brush CreateBrush (string colorText ) { var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colorText)!); brush.Freeze(); return brush; } }
Converters\SalaryToGradeConverter.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 using System.Globalization;using System.Windows.Data;using System.Windows.Media;namespace WpfBindingDetailsPractice.Converters ;public class SalaryToGradeConverter : IValueConverter { private static readonly Brush SeniorBrush = CreateBrush("#174EA6" ); private static readonly Brush MiddleBrush = CreateBrush("#2F6FED" ); private static readonly Brush JuniorBrush = CreateBrush("#5D6B82" ); public object Convert ( object value , Type targetType, object parameter, CultureInfo culture ) { var salary = value switch { decimal number => number, double number => (decimal )number, int number => number, _ => 0 m }; var wantsBrush = parameter is string text && text.Equals("Brush" , StringComparison.OrdinalIgnoreCase); if (salary >= 13000 m) { return wantsBrush ? SeniorBrush : "高级" ; } if (salary >= 10500 m) { return wantsBrush ? MiddleBrush : "中级" ; } return wantsBrush ? JuniorBrush : "初级" ; } public object ConvertBack ( object value , Type targetType, object parameter, CultureInfo culture ) { throw new NotSupportedException(); } private static Brush CreateBrush (string colorText ) { var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colorText)!); brush.Freeze(); return brush; } }
Converters\ColorNameToBrushConverter.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 using System.Globalization;using System.Windows.Data;using System.Windows.Media;namespace WpfBindingDetailsPractice.Converters ;public class ColorNameToBrushConverter : IValueConverter { public object Convert ( object value , Type targetType, object parameter, CultureInfo culture ) { var name = value as string ; if (string .IsNullOrWhiteSpace(name)) { return Binding.DoNothing; } try { var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(name)!); brush.Freeze(); return brush; } catch (FormatException) { return Binding.DoNothing; } } public object ConvertBack ( object value , Type targetType, object parameter, CultureInfo culture ) { return value is SolidColorBrush brush ? brush.Color.ToString() : Binding.DoNothing; } }
先准备好视图模型 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 232 233 234 235 236 237 238 239 240 241 using System.Collections.ObjectModel;using System.ComponentModel;using System.Runtime.CompilerServices;using WpfBindingDetailsPractice.Models;namespace WpfBindingDetailsPractice.ViewModels ;public class MainViewModel : INotifyPropertyChanged { private double _sliderValue = 40 ; private string _mirrorText = string .Empty; private string _lostFocusText = "起始文本" ; private string _liveText = "起始文本" ; private string _explicitText = "起始文本" ; private string _delayedText = "起始文本" ; private int _lostFocusWriteCount; private int _liveWriteCount; private int _delayedWriteCount; private string _oneWayToSourceText = string .Empty; private double _amount = 1234.5 ; private double _ratio = 0.732 ; private DateTime _signDate = new (2026 , 10 , 20 ); private int _quantity = 1234567 ; private bool _managerFlag = true ; private double _gradeSalary = 9800 ; private string _colorName = "SteelBlue" ; private Employee? _selectedEmployee; private Department? _selectedDepartment; 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 ]) }; SelectedEmployee = Employees[0 ]; SelectedDepartment = Departments[0 ]; OneTimeValue = _sliderValue; } public ObservableCollection<Employee> Employees { get ; } public ObservableCollection<Department> Departments { get ; } public double SliderValue { get => _sliderValue; set => SetField(ref _sliderValue, value ); } public double OneTimeValue { get ; } public string MirrorText { get => _mirrorText; set => SetField(ref _mirrorText, value ); } public string LostFocusText { get => _lostFocusText; set { if (SetField(ref _lostFocusText, value )) { LostFocusWriteCount++; } } } public int LostFocusWriteCount { get => _lostFocusWriteCount; private set => SetField(ref _lostFocusWriteCount, value ); } public string LiveText { get => _liveText; set { if (SetField(ref _liveText, value )) { LiveWriteCount++; } } } public int LiveWriteCount { get => _liveWriteCount; private set => SetField(ref _liveWriteCount, value ); } public string ExplicitText { get => _explicitText; set => SetField(ref _explicitText, value ); } public string DelayedText { get => _delayedText; set { if (SetField(ref _delayedText, value )) { DelayedWriteCount++; } } } public int DelayedWriteCount { get => _delayedWriteCount; private set => SetField(ref _delayedWriteCount, value ); } public string OneWayToSourceText { get => _oneWayToSourceText; set => SetField(ref _oneWayToSourceText, value ); } public void ResetCounters () { LostFocusWriteCount = 0 ; LiveWriteCount = 0 ; DelayedWriteCount = 0 ; } public double Amount { get => _amount; set => SetField(ref _amount, value ); } public double Ratio { get => _ratio; set => SetField(ref _ratio, value ); } public DateTime SignDate { get => _signDate; set => SetField(ref _signDate, value ); } public int Quantity { get => _quantity; set => SetField(ref _quantity, value ); } public bool ManagerFlag { get => _managerFlag; set => SetField(ref _managerFlag, value ); } public double GradeSalary { get => _gradeSalary; set => SetField(ref _gradeSalary, value ); } public string ColorName { get => _colorName; set => SetField(ref _colorName, value ); } public Employee? SelectedEmployee { get => _selectedEmployee; set => SetField(ref _selectedEmployee, value ); } public Department? SelectedDepartment { get => _selectedDepartment; set => SetField(ref _selectedDepartment, 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 ; PropertyChanged?.Invoke(this , new PropertyChangedEventArgs(propertyName)); return true ; } }
最后把界面骨架搭好 把 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 74 75 76 77 78 79 80 81 82 83 84 85 <Window x:Class ="WpfBindingDetailsPractice.MainWindow" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:converters ="clr-namespace:WpfBindingDetailsPractice.Converters" xmlns:viewModels ="clr-namespace:WpfBindingDetailsPractice.ViewModels" Title ="WPF 绑定细节练习" Width ="1340" Height ="940" MinWidth ="1100" MinHeight ="760" WindowStartupLocation ="CenterScreen" Background ="#F4F7FC" > <Window.DataContext > <viewModels:MainViewModel /> </Window.DataContext > <Window.Resources > <converters:BoolToBrushConverter x:Key ="BoolToBrushConverter" /> <converters:SalaryToGradeConverter x:Key ="SalaryToGradeConverter" /> <converters:ColorNameToBrushConverter x:Key ="ColorNameToBrushConverter" /> <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 ="Slider" > <Setter Property ="Margin" Value ="0,6,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" /> <Setter Property ="LineHeight" Value ="22" /> </Style > <Style x:Key ="LabelStyle" TargetType ="TextBlock" > <Setter Property ="Foreground" Value ="#5D6B82" /> <Setter Property ="Margin" Value ="0,0,0,2" /> </Style > <Style x:Key ="ValueStyle" TargetType ="TextBlock" > <Setter Property ="Foreground" Value ="#174EA6" /> <Setter Property ="FontWeight" Value ="SemiBold" /> <Setter Property ="Margin" Value ="0,0,0,10" /> </Style > </Window.Resources > <TabControl Margin ="26" > </TabControl > </Window >
代码后台只保留初始化,第 3 章会往里面加两个事件处理:
1 2 3 4 5 6 7 8 9 10 11 using System.Windows;namespace WpfBindingDetailsPractice ;public partial class MainWindow : Window { public MainWindow () { InitializeComponent(); } }
按 F5 运行一次。窗口能打开、页签是空的,就说明数据层已经准备好了。
示例文件分工 随附示例项目的文件分工如下,跟做时可以对照:
文件
作用
主要出现在
Models\Employee.cs
员工模型,带属性变化通知
第 1、4、5 章
Models\Department.cs
部门模型,含成员集合和统计
第 1 章
ViewModels\MainViewModel.cs
全部示例数据与派生属性
全部章节
Converters\BoolToBrushConverter.cs
真假值转颜色,支持参数换配色
第 5 章
Converters\SalaryToGradeConverter.cs
数字转等级文字或颜色
第 5 章
Converters\ColorNameToBrushConverter.cs
颜色名与画刷双向互译
第 5 章
MainWindow.xaml
五个页签的全部界面
全部章节
MainWindow.xaml.cs
只放 Explicit 和 OneWayToSource 需要的那几行代码
第 2、3 章
注意 MainWindow.xaml.cs 里那几行代码不是「习惯性写法」,而是第 2 章和第 3 章各有一个场景必须 用代码才能演示。除此之外,界面逻辑全部写在 XAML 和 ViewModel 里。
1. 绑定常见问题处理:相对源与多级联动问题 本节目标 学完本节,你应该能够:
解释为什么同一个路径在窗口里能用、进了模板就失效。
说清 DataContext 在模板内部到底换成了什么。
熟练使用 RelativeSource 的四种模式:Self、FindAncestor、TemplatedParent、PreviousData。
会用 AncestorType 和 AncestorLevel 精确指定要找哪一层。
独立写出「列表套列表」并让内层的选中项回传到 ViewModel。
1.1 先看一个会让人困惑的现象 新建一个窗口,DataContext 是 MainViewModel,里面有两个属性:Employees 和 SelectedEmployee。
下面这段 XAML 摆在窗口里,一切正常:
1 <TextBlock Text ="{Binding SelectedEmployee.Name}" />
但如果把同样的路径搬进列表的 ItemTemplate:
1 2 3 4 5 6 7 <ListBox ItemsSource ="{Binding Employees}" > <ListBox.ItemTemplate > <DataTemplate > <TextBlock Text ="{Binding SelectedEmployee.Name}" /> </DataTemplate > </ListBox.ItemTemplate > </ListBox >
八行文字全部空白,而且不报错 。程序照常运行,只是那里什么都没有。
这是初学者遇到最多的一类问题。它的原因不在路径拼写,而在数据上下文换了人 。
1.2 模板内部的数据上下文换了人 ItemTemplate 的作用是「把集合里的每一个对象画一遍」。既然每个对象都要画一遍,模板内部就得有个东西告诉它「现在画的是谁」。这个「谁」就是当前的数据项。
所以进入 ItemTemplate 之后,DataContext 已经不是窗口那个 MainViewModel 了,而是当前这一行的员工对象 。
图 2:模板内部的数据上下文逐层收窄,从窗口换成了当前数据项。
对照着看这两句:
位置
DataContext 是什么
{Binding Name} 取到谁
{Binding SelectedEmployee.Name} 取到谁
窗口里的 TextBlock
MainViewModel
没有这个属性
取到 ViewModel 的选中员工
模板里的 TextBlock
当前 Employee
当前员工的名字
取不到,员工身上没这个属性
第二行就是问题所在。Employee 类上并没有 SelectedEmployee 这个属性,所以绑定找不到东西,安静地返回空值。
一句话记住: {Binding X} ** 里的 X ** 永远是相对 DataContext ** 去解析的, DataContext ** 一变,同一个 X ** 的含义就变了。**
1.3 四种相对源写法 要在模板内部访问外面那层数据,就必须显式告诉绑定「别从当前 DataContext 找,换个地方找」。这件事由 RelativeSource 负责。
RelativeSource 不是直接写对象名,而是描述从哪里找来源对象 。它有四种模式:
模式
含义
典型用途
Self
来源就是目标元素自己
元素自己的两个属性互相绑定
FindAncestor
沿着可视化树向上找某一类祖先
模板内部访问外层控件或窗口
TemplatedParent
来源是套用了这个模板的控件
控件模板内部
PreviousData
来源是列表里的前一个数据项
相邻行比较、增量显示
图 3:RelativeSource 的四种模式,差别只在「去哪里找来源对象」。
写法上,最常用的是 FindAncestor:
1 2 <TextBlock Text ="{Binding DataContext.SelectedEmployee.Name, RelativeSource={RelativeSource AncestorType=Window}}" />
这句话读作:不要从当前的 DataContext ** 找,向上找第一个 Window ** 类型的祖先,拿它的 DataContext ,再从中取 SelectedEmployee.Name 。
注意这里的路径是两段:DataContext.SelectedEmployee.Name。
DataContext 是相对于找到的那个 Window 说的,也就是窗口上的 DataContext,即 MainViewModel。
SelectedEmployee.Name 才是从 MainViewModel 往下取的属性。
初学者最常犯的错就是这里少写一段,写成:
1 2 3 <TextBlock Text ="{Binding SelectedEmployee.Name, RelativeSource={RelativeSource AncestorType=Window}}" />
Window 是控件,MainViewModel 才是它的 DataContext,这两者不是一回事。跨过控件去拿它身上的数据,中间必须补上 DataContext 这一段。
1.4 AncestorType 里该写什么 AncestorType 写的是类型名 ,不是控件实例名。
1 2 3 4 5 6 7 8 RelativeSource={RelativeSource AncestorType=Window} RelativeSource={RelativeSource AncestorType=ListBox} RelativeSource={RelativeSource AncestorType={x:Type local:MyPanel}}
前两种是框架自带的类型,可以直接写名字。第三种是自定义类型,必须用 {x:Type ...} 包起来,否则 XAML 解析器不知道这个名字该去哪里找。
一个常见的困惑是:为什么写了 AncestorType=ListBox ** 反而找不到东西?**
因为「祖先」指的是可视化树里的上级。如果目标元素就是 ListBox 的直接子项模板里的内容,那么从模板内部往上看,先遇到的是 ListBoxItem,再往上是 ListBox。这时 AncestorType=ListBox 是能找到的。
但如果模板里还有一层 ContentControl,或者你的元素压根不在 ListBox 里面,那 ListBox 就不是祖先,自然找不到。
图 4:向上查找时先遇到的层级,决定了 AncestorType 和 AncestorLevel 该写多少。
1.5 AncestorLevel:找第几层 如果向上看有很多个同类型的祖先,默认只找最近的那一个 。想指定第几层,就加 AncestorLevel。
1 2 3 4 5 RelativeSource={RelativeSource AncestorType=Border} RelativeSource={RelativeSource AncestorType=Border, AncestorLevel=2}
AncestorLevel 从 1 开始数,1 就是「最近的那个」,也是默认值。
这个参数在实际项目里用得不多,但在嵌套卡片这类结构里会碰到:外层卡片套内层卡片,两个都是 Border,这时想绑定到外层卡片的属性,就必须用 AncestorLevel=2。
1.6 Self、TemplatedParent、PreviousData Self :来源就是目标元素自己。
1 2 3 <Border Width ="200" Height ="{Binding ActualWidth, RelativeSource={RelativeSource Self}}" />
Self 在写自定义控件和做自引用效果时很有用,但初学阶段用到的机会不多。它有一个隐含前提:被绑定的属性不能是正在计算的那个属性,否则会绕成死循环。
TemplatedParent :来源是「套用了这个模板的控件」。
它只应该在控件模板 (ControlTemplate)里使用。控件模板和数据模板是两种不同的东西:
ControlTemplate 改的是控件自己的外观,比如把 Button 画成一个圆角块。
DataTemplate 改的是数据长什么样,比如把 Employee 画成一张卡片。
在 ControlTemplate 里,模板内部的元素想知道「我属于哪个控件」,就用 TemplatedParent:
1 2 3 4 5 6 <ControlTemplate TargetType ="Button" > <Border Background ="{TemplateBinding Background}" > <ContentPresenter /> </Border > </ControlTemplate >
顺带一提,TemplateBinding 就是 {Binding RelativeSource={RelativeSource TemplatedParent}} 的简写,只是它只支持单向、不参与转换器。
PreviousData :来源是列表里的前一个数据项。
1 2 3 4 5 6 7 8 9 10 11 <ItemsControl ItemsSource ="{Binding Employees}" > <ItemsControl.ItemTemplate > <DataTemplate > <StackPanel > <TextBlock Text ="{Binding Name}" /> <TextBlock Foreground ="#5D6B82" Text ="{Binding Name, RelativeSource={RelativeSource PreviousData}}" /> </StackPanel > </DataTemplate > </ItemsControl.ItemTemplate > </ItemsControl >
每一项都会显示「自己的名字」和「上一项的名字」。第一项没有上一项,所以那行是空的。这个模式适合做「和上一条对比」这类展示。
1.7 多级联动为什么会断 多级联动的典型场景是「省 → 市 → 区」:后一级的选项,来自前一级的选中项。
1 2 3 <ComboBox x:Name ="ProvinceBox" ItemsSource ="{Binding Provinces}" DisplayMemberPath ="Name" /> <ComboBox ItemsSource ="{Binding SelectedItem.Children, ElementName=ProvinceBox}" DisplayMemberPath ="Name" />
这里用了另一种相对源:ElementName。它按名字找控件,然后从那个控件上取属性。
ElementName 和 RelativeSource 解决的是同一类问题——来源不在当前 DataContext ** 上**——只是找法不同:
写法
怎么找来源
什么时候用
ElementName
按 x:Name 精确找某个控件
来源是一个已知的、有名字的控件
RelativeSource
按位置关系找(自己、祖先、模板宿主)
来源的位置有规律,但名字拿不到
两者都会踩到同一个坑:名字范围 。
x:Name 只在同一个名字范围(NameScope)里唯一。窗口是一个名字范围,DataTemplate 内部是另一个。所以在模板内部用 ElementName 去找窗口里的控件,通常找不到,因为那是另一个名字范围里的名字。
这种情况下 RelativeSource 才是对的工具。这也解释了为什么模板内部的跨层绑定几乎都用 RelativeSource。
另外要注意联动的链条会逐级重置 。省一换,市要重新取;市一换,区要重新取。如果只写了第一级的联动,第二级不会自动跟着刷新,因为第二级的 ItemsSource 换了以后,它的 SelectedItem 还是旧对象,而旧对象已经不在新列表里了。
图 5:多级联动断掉的典型样子——上级换了,下级还停在旧对象上。
1.8 列表套列表的联动写法 最典型的联动需求是:外层一个部门列表,每个部门卡片里再列一遍自己的员工,并且点员工要能让外面的详情面板跟着变。
外层模板是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <ItemsControl ItemsSource ="{Binding Departments}" > <ItemsControl.ItemTemplate > <DataTemplate > <StackPanel > <TextBlock Text ="{Binding Name}" /> <ListBox ItemsSource ="{Binding Employees}" SelectedItem ="{Binding DataContext.SelectedEmployee, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}" /> </StackPanel > </DataTemplate > </ItemsControl.ItemTemplate > </ItemsControl >
内层 ListBox 有两处绑定,来源完全不同,必须分清:
绑定
来源
为什么
ItemsSource="{Binding Employees}"
当前 DataContext,即 Department
Employees 是部门自己的属性
SelectedItem="{Binding DataContext.SelectedEmployee, RelativeSource=...Window}"
窗口的 DataContext,即 MainViewModel
选中项要写回 ViewModel,不能停在部门身上
第二行如果偷懒写成 {Binding SelectedEmployee},那么每个部门卡片都会各自记住「我这张卡片里选了谁」,而且 ViewModel 永远不知道。外面的详情面板也就永远不会变。
判断来源的方法 :看这个属性应该属于谁 。属于当前数据项的,直接写路径;属于更外层某个对象的,用 RelativeSource 或 ElementName 指过去。
1.9 路径写法速查 把上面几种情况整理成一张表,写绑定卡住时可以对着查:
想绑的东西
写法
当前数据项自己的属性
{Binding Name}
外层窗口 DataContext 上的属性
{Binding DataContext.X, RelativeSource={RelativeSource AncestorType=Window}}
外层某个 ListBox 上的属性
{Binding X, RelativeSource={RelativeSource AncestorType=ListBox}}
外层第 2 个 Border 上的属性
{Binding X, RelativeSource={RelativeSource AncestorType=Border, AncestorLevel=2}}
元素自己的另一个属性
{Binding X, RelativeSource={RelativeSource Self}}
控件模板宿主控件的属性
{Binding X, RelativeSource={RelativeSource TemplatedParent}}
列表里的前一个数据项
{Binding X, RelativeSource={RelativeSource PreviousData}}
某个有名字的控件上的属性
{Binding X, ElementName=控件名}
再补三条经验:
路径里出现 DataContext. 时,说明你在跨控件取数据。这时前半段是控件,后半段才是数据,不要混在一起写。
AncestorType 写错类型不会报错,只会让绑定取不到值。所以改完要看界面,不要只看有没有编译错误。
链条太长时,先用一个 TextBlock 只绑中间那一段,确认这一段能取到,再接下一段。一次写到底很难判断是哪一级断的。
1.10 跟做:搭出第 1 章页签 把下面的内容放进第 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 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 <TabItem Header ="1 相对源与联动" > <ScrollViewer VerticalScrollBarVisibility ="Auto" Padding ="0,16,0,0" > <StackPanel > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="同一个模板,两种写法,结果完全不同" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" Text ="两个列表用的是同一份 Employees、同一个 ItemTemplate 结构。左列第二行写 {Binding SelectedEmployee.Name},右列第二行用 RelativeSource 向上找窗口。左列那一行是空的,因为它找的是「员工自己身上的 SelectedEmployee 属性」,而员工身上根本没有这个属性。" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,14,0" > <TextBlock Text ="写法一:直接写属性名(失败)" Style ="{StaticResource LabelStyle}" /> <ListBox ItemsSource ="{Binding Employees}" BorderBrush ="#F0C7C7" Background ="#FFF7F7" MaxHeight ="260" > <ListBox.ItemTemplate > <DataTemplate > <StackPanel Margin ="2,5" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" /> <TextBlock Foreground ="#B03A3A" Text ="{Binding SelectedEmployee.Name}" /> </StackPanel > </DataTemplate > </ListBox.ItemTemplate > </ListBox > </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="写法二:RelativeSource 向上找(成功)" Style ="{StaticResource LabelStyle}" /> <ListBox ItemsSource ="{Binding Employees}" BorderBrush ="#BFD4F5" Background ="#F7FAFF" MaxHeight ="260" > <ListBox.ItemTemplate > <DataTemplate > <StackPanel Margin ="2,5" > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" /> <TextBlock Foreground ="#2F6FED" Text ="{Binding DataContext.SelectedEmployee.Name, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}" /> </StackPanel > </DataTemplate > </ListBox.ItemTemplate > </ListBox > </StackPanel > </Grid > <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,14,0,0" Text ="注意左列不是「没写对路径」,而是路径本身在这层不成立。ItemTemplate 内部的数据上下文已经换成了当前员工对象,{Binding SelectedEmployee} 就去员工对象上找了。" /> </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="为什么一进模板,DataContext 就换了人" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource CodeStyle}" Text ="ListBox(DataContext = MainViewModel) └── ListBoxItem(DataContext = 某个 Employee) └── ItemTemplate 内部(DataContext 仍然是 Employee) └── {Binding Name} → 员工自己的 Name,能找到 └── {Binding SelectedEmployee} → 员工身上没这个属性,找不到" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,14,0,0" Text ="这不是绑定出错,而是作用域变了。任何「在模板内部还想访问外面那层数据」的需求,都要显式指定来源,RelativeSource 就是最常用的一种。" /> </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="列表套列表:把内层选中项写回 ViewModel" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,16" Text ="部门卡片是外层模板,里面的员工列表是内层。内层要选中员工并让 ViewModel 知道,只能借 RelativeSource 跨层取到窗口的 DataContext。点一张卡片里的员工试试,下方详情会跟着变。" /> <ItemsControl ItemsSource ="{Binding Departments}" > <ItemsControl.ItemsPanel > <ItemsPanelTemplate > <WrapPanel /> </ItemsPanelTemplate > </ItemsControl.ItemsPanel > <ItemsControl.ItemTemplate > <DataTemplate > <Border Background ="#F7F9FD" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" Padding ="14" Margin ="0,0,14,14" Width ="296" > <StackPanel > <TextBlock Text ="{Binding Name}" FontWeight ="SemiBold" FontSize ="15" /> <TextBlock Foreground ="#5D6B82" Margin ="0,2,0,0" > <Run Text ="{Binding Count, Mode=OneWay}" /> <Run Text =" 人 合计 " /> <Run Text ="{Binding TotalSalary, Mode=OneWay, StringFormat={}{0:C0}}" /> </TextBlock > <ListBox ItemsSource ="{Binding Employees}" SelectedItem ="{Binding DataContext.SelectedEmployee, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}" BorderBrush ="#DCE5F2" Margin ="0,10,0,0" MaxHeight ="150" > <ListBox.ItemTemplate > <DataTemplate > <StackPanel Margin ="2,4" > <TextBlock Text ="{Binding Name}" /> <TextBlock Foreground ="#5D6B82" Text ="{Binding Salary, StringFormat={}{0:C0}}" /> </StackPanel > </DataTemplate > </ListBox.ItemTemplate > </ListBox > </StackPanel > </Border > </DataTemplate > </ItemsControl.ItemTemplate > </ItemsControl > <Border Background ="#EAF1FF" CornerRadius ="8" Padding ="14" Margin ="0,6,0,0" > <StackPanel > <TextBlock Foreground ="#174EA6" FontWeight ="SemiBold" > <Run Text ="当前选中:" /> <Run Text ="{Binding SelectedEmployee.Name, Mode=OneWay, TargetNullValue=(还没选)}" /> </TextBlock > <TextBlock Foreground ="#174EA6" Margin ="0,4,0,0" > <Run Text ="部门 " /> <Run Text ="{Binding SelectedEmployee.Department, Mode=OneWay, TargetNullValue=未知}" /> <Run Text =" 工资 " /> <Run Text ="{Binding SelectedEmployee.Salary, Mode=OneWay, StringFormat={}{0:C0}}" /> <Run Text =" 身份 " /> <Run Text ="{Binding SelectedEmployee.Level, Mode=OneWay, TargetNullValue=未知}" /> </TextBlock > </StackPanel > </Border > </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
运行之后,把左列和右列对着看:右列每个员工下面都跟着当前选中员工的名字,左列全是空白。这就把「作用域」这件事变成了看得见的东西。
1.11 跟做检查点
检查项
期望结果
打开页签 1
左右两个列表并排,都是 8 名员工
看左列每行第二行
空白,没有文字
看右列每行第二行
都显示「张三」,因为初始选中项是张三
在下方部门卡片里点「王五」
右列所有行变成「王五」,底部详情跟着变
在下方卡片里点「孙七」
右列变成「孙七」,底部工资显示 $14,500、身份「主管」
看部门卡片的「合计」
设计部显示 $23,000,研发部显示 $26,500
如果右列也是空白,检查两处:AncestorType 是否写的是 Window,以及路径前面有没有漏掉 DataContext.。
1.12 小练习
把右列的 AncestorType 从 Window 改成 ListBox,观察结果。想一想为什么这次也能成功。
在下方部门卡片外面再套一个 Border,然后用 AncestorLevel=2 去绑外层 Border 的 Tag,验证层级数法。
用 PreviousData 做一个「工资对比」列表:每行显示当前工资,以及比上一行多多少。
把 SelectedItem 那一句改成 {Binding SelectedEmployee},观察联动消失。然后再改回来,体会这两者的差别。
2. 绑定对象的 Mode 属性 本节目标 学完本节,你应该能够:
说清 OneWay、TwoWay、OneTime、OneWayToSource 四种模式的值流向。
判断某个控件属性不写 Mode 时,默认会是哪一种。
解释为什么文本框能改来源、而 TextBlock 改不了。
知道什么时候必须写 Mode,什么时候可以省略。
用 OneTime 做「只读一次」的展示,避免无意义的刷新。
2.1 四个模式,一句话一个 Mode 管的是值往哪个方向流 。一个绑定有两个端点:来源(数据对象)和目标(界面元素上的属性)。四个模式就是四种流向:
模式
流向
界面改了会怎样
来源改了会怎样
OneWay
来源 → 目标
界面上的改动被丢弃,写不回去
界面跟着更新
TwoWay
双向
写回来源
界面跟着更新
OneTime
来源 → 目标,只读一次
界面上的改动被丢弃
界面不 更新
OneWayToSource
目标 → 来源
写回来源
界面不 更新
图 6:四个模式的差别只在两件事上——写不写回去、跟不跟着变。
初学者最容易混的是 OneWay 和 OneTime。它们的区别不在方向,而在更新次数 :
OneWay:来源变一次,界面跟着变一次,一直跟着。
OneTime:只在绑定的那一刻读一次,之后来源怎么变都不管。
OneTime 适合那种「一辈子不会变」的数据,比如程序版本号、启动时间。它比 OneWay 省一点开销,因为不需要监听来源的变化通知。
2.2 不写 Mode 时,默认是哪个 这个问题不能靠背,要看目标属性自己的元数据 。每个依赖属性在注册时都会声明两件事:
默认是不是双向(BindsTwoWayByDefault)。
默认什么时候写回来源(DefaultUpdateSourceTrigger)。
常用的几个属性实测结果如下:
目标属性
默认双向
默认写回时机
TextBox.Text
是
LostFocus
TextBlock.Text
否
PropertyChanged
CheckBox.IsChecked
是
PropertyChanged
Slider.Value
是
PropertyChanged
ComboBox.SelectedItem
是
PropertyChanged
Run.Text
是
PropertyChanged
图 7:常用目标属性的默认方向和默认写回时机,实测结果。
这张表解释了四个常见的困惑:
为什么 TextBlock.Text ** 不写 Mode ** 也改不动? 因为它的 BindsTwoWayByDefault 是 False,默认走 OneWay。而且 TextBlock 本来就不能编辑,界面根本没法改它。
为什么 TextBox.Text ** 不写 Mode ** 就能双向? 因为它的 BindsTwoWayByDefault 是 True,默认就是 TwoWay。
为什么 TextBox.Text ** 的写回时机不是每敲一个字?** 因为它的默认 DefaultUpdateSourceTrigger 是 LostFocus,要离开焦点才写回。这是第 3 章的重点。
为什么 Run.Text ** 要显式写 Mode=OneWay** ? 因为它的默认是 TwoWay。当你只想显示不想写回时,得手动关掉双向,否则会白白建立一条反向通道。
所以「要不要写 Mode」的判断方法是:
先看目标属性默认是单向还是双向。
如果你想要的流向和默认一致,可以省略。
如果不一致,必须显式写出来。
初学阶段建议一律显式写清楚 。写出来虽然啰嗦,但看代码的人(包括几个月后的你自己)不用再去查默认值。
2.3 TwoWay 的最小演示 下面这段是本章的核心演示,滑块和文本框绑同一个属性,模式都是 TwoWay:
1 2 <Slider Minimum ="0" Maximum ="100" Value ="{Binding SliderValue, Mode=TwoWay}" /> <TextBox Text ="{Binding SliderValue, Mode=TwoWay}" />
拖动滑块,文本框里的数字跟着变;在文本框里输入数字,滑块也跟着动。两边互为因果,这就是 TwoWay。
这里有个细节值得注意:Slider.Value 是 double,TextBox.Text 是 string。双向绑定时框架会自动做类型转换,把 "40" 变成 40.0。但如果输入的是「abc」,转换失败,绑定会报一个错误并保持原值,不会崩。
2.4 OneWay:只读展示 把同一个属性绑到 TextBox,但模式改成 OneWay:
1 2 <Slider Minimum ="0" Maximum ="100" Value ="{Binding SliderValue, Mode=TwoWay}" /> <TextBox Text ="{Binding SliderValue, Mode=OneWay}" />
现在拖动滑块,文本框仍然跟着变;但在文本框里输入内容,滑块不会 动。因为这条管道只有一个方向:来源可以流向界面,界面写不回去。
一个容易踩的坑:OneWay 的文本框看起来可以输入,输入的字也确实显示在框里,但一旦来源发生变化,这些输入就会被来源的值覆盖掉。能输入不代表能写回 ,这是两件事。
2.5 OneTime:读一次就停 OneTime 的演示需要准备一个「只有 get、从不通知」的属性:
1 2 public double OneTimeValue { get ; }
在构造函数里给它一个初值:
1 OneTimeValue = _sliderValue;
界面上同时显示「当前滑块值」和「OneTime 读到的值」:
1 2 3 4 5 <Slider Minimum ="0" Maximum ="100" Value ="{Binding SliderValue, Mode=TwoWay}" /> <TextBlock Text ="{Binding SliderValue, Mode=OneWay, StringFormat={}{0:F1}}" /> <TextBlock Text ="{Binding OneTimeValue, Mode=OneTime, StringFormat={}{0:F1}}" />
运行后拖动滑块:上面那个数字一直在变,下面那个永远停在 40.0。
为什么?因为 OneTime 只在绑定的那一刻读了一次值,之后就不再监听来源。而且 OneTimeValue 本身是个自动属性,压根没有 INotifyPropertyChanged,它就算想通知也通知不了。
这个演示顺便说明了一件事:OneWay ** 能跟着变,前提是来源会发通知**。如果一个属性从来不通知变化,那么用 OneWay 和 OneTime 的效果其实一样——都不会更新。区别在于 OneTime 是主动放弃监听,省掉了这部分开销。
2.6 OneWayToSource:只出不进 这个模式比较少见,但在「把界面上的输入收集到某个对象」时有用:
1 2 3 4 5 <TextBox Text ="{Binding OneWayToSourceText, Mode=OneWayToSource, UpdateSourceTrigger=PropertyChanged}" /><TextBlock Text ="{Binding OneWayToSourceText, Mode=OneWay, TargetNullValue=(空)}" />
在文本框里输入,下面立刻显示同样的内容,说明值从界面流到了来源。
现在点一个按钮,在代码里改来源:
1 2 3 4 5 6 7 8 9 private void OnWriteSourceClick (object sender, RoutedEventArgs e ){ if (ViewModel is null ) { return ; } ViewModel.OneWayToSourceText = $"代码写入 {DateTime.Now:HH:mm:ss} " ; }
点完之后,下面的显示变了,但上面的文本框没变 ,还是你刚才手输的内容。
这就把 OneWayToSource 和 TwoWay 的差别摆出来了:TwoWay 是两边互相覆盖,OneWayToSource 只允许界面往来源写,来源永远盖不回界面。
2.7 什么时候真的需要 OneWayToSource 初学时容易觉得这个模式没什么用,其实它有两个实际场景:
场景一:收集界面状态。 比如有一个只用于显示、不需要被程序改动的控件,你希望把用户在它上面的操作记录下来,但又不想让程序去改它。
场景二:避免反向覆盖。 如果来源是程序内部不断计算的值,而界面上的输入只是「用户的一次性意见」,用 OneWayToSource 可以避免程序算出来的值把用户的输入冲掉。
反过来说,如果你发现某个绑定用了 OneWayToSource 但界面却不跟着来源更新,那不是 bug,而是这个模式的正常行为。
2.8 跟做:搭出第 2 章页签 把下面的内容放进第 2 个页签。它把四种模式分别演示一遍,OneWayToSource 那一块需要一个按钮事件配合:
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 <TabItem Header ="2 Mode 属性" > <ScrollViewer VerticalScrollBarVisibility ="Auto" Padding ="0,16,0,0" > <StackPanel > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="四种 Mode,一句话记一种" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Text ="OneWay:只从来源流向目标。TwoWay:两边互相影响。OneTime:只读一次,之后不再更新。OneWayToSource:只从界面流向来源,来源盖不回界面。" /> </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="TwoWay:两边互相影响" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,12" Text ="拖动滑块,文本框跟着变;在文本框里改数字,滑块也会跟着动。这就是 TwoWay。" /> <Slider Minimum ="0" Maximum ="100" Value ="{Binding SliderValue, Mode=TwoWay}" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,14,0" > <TextBlock Text ="来源的值(滑块)" Style ="{StaticResource LabelStyle}" /> <TextBlock Style ="{StaticResource ValueStyle}" Text ="{Binding SliderValue, Mode=OneWay, StringFormat={}{0:F1}}" /> </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="界面的值(文本框,TwoWay)" Style ="{StaticResource LabelStyle}" /> <TextBox Text ="{Binding SliderValue, Mode=TwoWay}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="OneWay:界面改了不影响来源" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,12" Text ="这个文本框绑的是同一个属性,但模式是 OneWay。在里面输入内容,滑块不会动,因为写不回去。" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,14,0" > <TextBlock Text ="界面的值(文本框,OneWay)" Style ="{StaticResource LabelStyle}" /> <TextBox Text ="{Binding SliderValue, Mode=OneWay}" /> </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="来源的值(滑块)" Style ="{StaticResource LabelStyle}" /> <Slider Minimum ="0" Maximum ="100" Value ="{Binding SliderValue, Mode=TwoWay}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="OneTime:读一次就停" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,12" Text ="OneTimeValue 是个只有 get 的属性,程序启动时是 40。下面拖动滑块,这个数字不会变,因为它只在绑定的那一刻读过一次。" /> <Slider Minimum ="0" Maximum ="100" Value ="{Binding SliderValue, Mode=TwoWay}" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,14,0" > <TextBlock Text ="当前滑块值(会变)" Style ="{StaticResource LabelStyle}" /> <TextBlock Style ="{StaticResource ValueStyle}" Text ="{Binding SliderValue, Mode=OneWay, StringFormat={}{0:F1}}" /> </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="OneTime 读到的值(不变)" Style ="{StaticResource LabelStyle}" /> <TextBlock Style ="{StaticResource ValueStyle}" Text ="{Binding OneTimeValue, Mode=OneTime, StringFormat={}{0:F1}}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="OneWayToSource:只出不进" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,12" Text ="在文本框里输入内容,下面的显示会跟着变,说明值从界面流到了来源。反过来点按钮在代码里改来源的值,上面那个文本框不会变,因为这条管道是单向的。" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,14,0" > <TextBlock Text ="文本框(OneWayToSource)" Style ="{StaticResource LabelStyle}" /> <TextBox Text ="{Binding OneWayToSourceText, Mode=OneWayToSource, UpdateSourceTrigger=PropertyChanged}" /> </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="来源的值(OneWay 读出来看)" Style ="{StaticResource LabelStyle}" /> <TextBlock Style ="{StaticResource ValueStyle}" Text ="{Binding OneWayToSourceText, Mode=OneWay, TargetNullValue=(空)}" /> </StackPanel > </Grid > <Button Content ="在代码里改来源的值" HorizontalAlignment ="Left" Click ="OnWriteSourceClick" /> <TextBlock Style ="{StaticResource HintStyle}" Text ="点了按钮之后,文本框里仍然是刚才手输的内容,没有被来源覆盖,这就是 OneWayToSource 与 TwoWay 的区别。" /> </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
同时把代码后台补上这一段:
1 2 3 4 5 6 7 8 9 10 11 private MainViewModel? ViewModel => DataContext as MainViewModel;private void OnWriteSourceClick (object sender, RoutedEventArgs e ){ if (ViewModel is null ) { return ; } ViewModel.OneWayToSourceText = $"代码写入 {DateTime.Now:HH:mm:ss} " ; }
2.9 跟做检查点
检查项
期望结果
打开页签 2
四块演示从上到下排列
拖动第一块的滑块
左边数字和右边文本框同时变
在右边文本框里输入 80
滑块跳到 80,左边数字变成 80.0
在第二块的文本框里输入内容
滑块不动,右边数字不变
拖动第三块的滑块
上边数字变,下边「OneTime 读到的值」始终是 40.0
在第四块文本框里输入文字
右边「来源的值」立刻跟着变
点「在代码里改来源的值」
右边显示时间,左边文本框内容不变
如果第二块里输入内容后滑块也动了,检查那个文本框是否漏写了 Mode=OneWay。
2.10 小练习
把第三块的 OneTime 改成 OneWay,观察数字开始跟着滑块变。
给 OneTimeValue 加上 INotifyPropertyChanged 通知,看看 OneTime 模式下它会不会更新。
把第一块的 TwoWay 去掉不写,验证 Slider.Value 默认就是双向。
用一个 TextBlock 绑 SliderValue 且不写 Mode,验证它默认是 OneWay。
3. 绑定对象的 UpdateSourceTrigger 与 Delay 属性 本节目标 学完本节,你应该能够:
说清 UpdateSourceTrigger 的四个取值分别在什么时候写回来源。
知道 TextBox.Text 不写这个属性时,默认是 LostFocus。
用 Delay 降低写回频率,避免每敲一个字就触发一次重活。
说出 Delay 的三个限制条件,知道它什么时候不起作用。
会用 Explicit 加 UpdateSource 做「确认后才提交」的表单。
3.1 方向定了,还要定时机 第 2 章的 Mode 决定值往哪流。但如果流向是「界面 → 来源」,就还有一个问题:什么时候写过去?
用户在一个文本框里输入「张三」,这个动作其实是三次输入:张、三。如果每敲一个字符就写回一次,来源会被写三次;如果等用户点别处再写,就只写一次。
这件事由 UpdateSourceTrigger 控制:
取值
什么时候写回来源
适合什么
Default
交给目标属性自己的默认值
不想操心时用
PropertyChanged
属性值一变就写,也就是每敲一个字符
实时搜索、实时校验、实时预览
LostFocus
元素失去焦点时写
普通表单,减少无谓写回
Explicit
不自动写,必须代码调用 UpdateSource
确认后才提交
图 8:输入「张」和「三」两个字符时,四种时机各自写回几次。
3.2 Default 到底是什么 Default 不是一个具体的时机,它的意思是「问目标属性自己」。每个依赖属性在注册时都会声明自己的默认时机。
实测结果:TextBox.Text 的默认时机是 LostFocus。这就是为什么你在文本框里敲字时,绑定的属性往往没变,非要点到别的地方才变。
这个默认值是经过考虑的:早期 WPF 表单里,如果每敲一个字就写回一次,做校验和格式化会非常费。所以 TextBox 选了保守的 LostFocus。
但现在实时搜索、实时预览的需求多了,PropertyChanged 反而更常见。所以实际写代码时,建议把这个属性显式写出来 ,不要依赖默认值。理由和第 2 章一样:读代码的人不用去查。
3.3 PropertyChanged:每敲一个字符就写 1 2 3 <TextBox Text ="{Binding LiveText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
每输入一个字符,LiveText 立刻更新,界面上绑定同一个属性的显示也跟着变。
它的好处是「实时」。搜索框输入「张」,列表立刻只剩姓张的人;做校验时,输入的过程中就能把不合法的字符标红。
它的代价是「频繁」。如果写回的处理器里有耗时操作——查数据库、请求接口、重新排序几千条数据——那么每敲一个字符就会卡一下,体验反而更差。
这时候就该用 Delay。
3.4 Delay:停下来才写 Delay 是给 PropertyChanged 加一个「冷静期」:
1 2 3 4 <TextBox Text ="{Binding DelayedText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, Delay=800}" />
含义是:值变了以后不马上写回,先等 800 毫秒;如果这期间又变了,就重新计时;直到安静满 800 毫秒,才真正写回一次。
用一个探针程序实测(连打 3 次,每次间隔 100 毫秒,Delay 设为 400 毫秒):
配置
写回次数
PropertyChanged 不带 Delay
3
PropertyChanged 加 Delay=400
1
LostFocus 加 Delay=400
0
图 9:Delay 把连续三次输入合并成一次写回。
这组数字把三件事一起说清了:
第一, Delay ** 确实能合并连续输入。** 三次输入被合并成一次写回,处理器里的耗时操作从执行三次变成执行一次。
第二, Delay ** 只在 PropertyChanged ** 下有效。 第三行是 LostFocus 加 Delay,写回次数是 0。因为文本框全程没有失去焦点,LostFocus 根本没有触发,Delay 也就无从生效。Delay 是给「已经决定要写回」的场景降频的,不是用来改变触发时机的。
第三, Delay ** 的单位是毫秒,而且必须是写死的数字。** 它是个 int,不能写成绑定,也不能写成 {Binding SomeDelay}。所以像「让用户自己调延迟时间」这种需求,做不到。
3.5 Delay 的三个限制 把上面这几点集中一下,避免踩坑:
只对 PropertyChanged ** 有意义。** 配 LostFocus 或 Explicit 基本没有效果。
值必须是数字字面量。 写 Delay=800 可以,写 Delay="{Binding X}" 不行。
它改变的是写回时刻,不是写回内容。 最终写回来源的值仍然是文本框里最后的内容,中间那些被打断的输入不会留下痕迹。
顺带一提,Delay 也可以配 OneWay 之外的场景吗?不行,Delay 只影响「目标到来源」这个方向。OneWay 本来就没有这个方向,所以配了也没意义。
3.6 Explicit:完全手动 最后一种时机是「我全都要自己控制」:
1 2 3 4 <TextBox x:Name ="ExplicitBox" Text ="{Binding ExplicitText, Mode=TwoWay, UpdateSourceTrigger=Explicit}" />
用这个模式,文本框里怎么改,来源都不会动。要写回,必须在代码里显式调用:
1 2 var expression = BindingOperations.GetBindingExpression(ExplicitBox, TextBox.TextProperty);expression?.UpdateSource();
GetBindingExpression 取出那个文本框上的绑定表达式,UpdateSource() 命令它「现在写回一次」。
这种模式适合「确认后才生效」的场景:表单里改了一堆字段,点「保存」才一次性提交;点「取消」就什么都不写。用 PropertyChanged 或 LostFocus 都做不到这件事,因为它们总会写。
一个细节:Explicit 模式下,界面上显示的仍然是文本框里的内容(用户自己敲的),但来源里的值还是旧的。所以你会看到「文本框显示新值,右边的来源显示旧值」,这是正常的,不是同步失败。
3.7 三种时机的取舍 放到一起对比:
需求
该选哪个
理由
实时搜索、输入即筛选
PropertyChanged
要的就是即时反应
实时搜索 + 接口请求
PropertyChanged + Delay
既即时又不过度请求
普通表单字段
LostFocus
减少无谓写回,符合填表习惯
数据校验提示
PropertyChanged
边输边提示才有意义
点保存才提交
Explicit + UpdateSource()
完全由代码控制
大文本编辑框
LostFocus 或 Delay
避免每次击键都触发重活
图 10:按需求倒推该选哪种写回时机。
再补一条经验:LostFocus ** 在单字段表单里有一个隐藏问题**。如果用户改完最后一个字段就直接点了「提交」按钮,而按钮点击不会让文本框失焦(在某些布局下),那么最后那个字段的值可能还没写回。稳妥的做法是提交前先让焦点移开,或者干脆用 PropertyChanged。
3.8 跟做:搭出第 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 <TabItem Header ="3 写回时机与 Delay" > <ScrollViewer VerticalScrollBarVisibility ="Auto" Padding ="0,16,0,0" > <StackPanel > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="UpdateSourceTrigger 管的是「什么时候写回来源」" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Text ="Default:交给目标属性的默认值,TextBox.Text 默认是 LostFocus。PropertyChanged:每敲一个字符就写回一次。LostFocus:离开这个文本框才写回。Explicit:不自动写回,需要代码调用 UpdateSource。" /> </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="同样的输入,写回次数差多少" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,14" Text ="在下面两个文本框里分别输入几个字,注意先点一下别处让焦点离开,再对比两边的写回次数。左边每敲一个字就写一次,右边要等你点到别的地方才写一次。" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,16,0" > <TextBlock Text ="PropertyChanged(边敲边写)" Style ="{StaticResource LabelStyle}" /> <TextBox Text ="{Binding LiveText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <TextBlock Foreground ="#5D6B82" > <Run Text ="写回次数:" /> <Run Text ="{Binding LiveWriteCount, Mode=OneWay}" /> </TextBlock > <TextBlock Foreground ="#174EA6" FontWeight ="SemiBold" Margin ="0,4,0,0" Text ="{Binding LiveText, Mode=OneWay}" /> </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="LostFocus(离开才写)" Style ="{StaticResource LabelStyle}" /> <TextBox Text ="{Binding LostFocusText, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" /> <TextBlock Foreground ="#5D6B82" > <Run Text ="写回次数:" /> <Run Text ="{Binding LostFocusWriteCount, Mode=OneWay}" /> </TextBlock > <TextBlock Foreground ="#174EA6" FontWeight ="SemiBold" Margin ="0,4,0,0" Text ="{Binding LostFocusText, Mode=OneWay}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="Delay:停下来才写,打字打一半不会触发" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,14" Text ="这个文本框也是 PropertyChanged,但加了 Delay=800。快速连打几个字,写回次数涨得比上面那个慢;停手半秒左右,它才补上一次。Delay 只在 PropertyChanged 下起作用,单位是毫秒,要写死数字,不能绑到属性上。" /> <TextBox Text ="{Binding DelayedText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, Delay=800}" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,16,0" > <TextBlock Foreground ="#5D6B82" > <Run Text ="Delay 文本框的写回次数:" /> <Run Text ="{Binding DelayedWriteCount, Mode=OneWay}" /> </TextBlock > </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Foreground ="#174EA6" FontWeight ="SemiBold" Text ="{Binding DelayedText, Mode=OneWay}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="Explicit:完全手动,点按钮才写" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,14" Text ="在文本框里随便改点内容,然后点下面的按钮。在点之前,右边显示的还是旧值;点完之后才变成新值。这种模式适合「确认后才提交」的表单。" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,16,0" > <TextBlock Text ="文本框(Explicit)" Style ="{StaticResource LabelStyle}" /> <TextBox x:Name ="ExplicitBox" Text ="{Binding ExplicitText, Mode=TwoWay, UpdateSourceTrigger=Explicit}" /> </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="来源的值" Style ="{StaticResource LabelStyle}" /> <TextBlock Style ="{StaticResource ValueStyle}" Text ="{Binding ExplicitText, Mode=OneWay}" /> </StackPanel > </Grid > <StackPanel Orientation ="Horizontal" > <Button Content ="手动写回一次" Click ="OnUpdateSourceClick" /> <Button Content ="计数器清零" Click ="OnResetCountersClick" /> </StackPanel > <TextBlock x:Name ="ExplicitStateText" Style ="{StaticResource HintStyle}" Text ="还没手动写回过。" /> </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
代码后台补上这两个事件,还有清零计数器的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private void OnUpdateSourceClick (object sender, RoutedEventArgs e ){ var expression = BindingOperations.GetBindingExpression(ExplicitBox, TextBox.TextProperty); if (expression is null ) { ExplicitStateText.Text = "没找到 ExplicitBox 上的绑定表达式,检查 Text 是否写了绑定。" ; return ; } expression.UpdateSource(); ExplicitStateText.Text = $"已手动写回一次,来源现在的值是:{ViewModel?.ExplicitText} " ; } private void OnResetCountersClick (object sender, RoutedEventArgs e ){ ViewModel?.ResetCounters(); ExplicitStateText.Text = "三个写回计数器已经清零,可以重新对比。" ; }
需要的 using 有三个:System.Windows、System.Windows.Controls、System.Windows.Data。
3.9 跟做检查点
检查项
期望结果
打开页签 3
四块演示从上到下排列
在左边文本框里连打 5 个字符
写回次数立刻变成 5
在右边文本框里连打 5 个字符(不离开焦点)
写回次数仍然是 0
点一下别处让焦点离开右边文本框
写回次数变成 1
在 Delay 文本框里连打 5 个字符
写回次数不立刻变,停在原来的数
停手等 1 秒
写回次数加 1
在 Explicit 文本框里改成「测试内容」
右边「来源的值」仍然是旧值
点「手动写回一次」
右边变成「测试内容」,下方提示显示来源当前值
点「计数器清零」
前三个计数器都归 0
如果 Delay 那块点完就立刻涨数,检查 Delay 是否写成了别的属性名,或者是否误加了 Mode 冲突。
3.10 小练习
把 Delay 从 800 改成 2000,再连打几个字,感受合并效果的差别。
给 Delay 写一个绑定表达式(比如 Delay="{Binding DelayMilliseconds}"),确认它编译报错。
把 LostFocus 那块也加上 Delay=800,验证写回次数仍然是 0。
把 Explicit 那块改成 LostFocus,观察「改完不点按钮、直接点别处」会不会写回。
在 LiveText 的 set 里加一行 Debug.WriteLine,从「输出」窗口看每次写回的时机。
本节目标 学完本节,你应该能够:
用 StringFormat 把数字、日期格式化成想要的样子。
说出 StringFormat={}{0:C2} 里那个空大括号为什么必须写。
分清 StringFormat 和 Converter 的分工,知道它们同时写时谁管什么。
知道 StringFormat 只影响显示,不会改动数据本身。
避开 StringFormat 最常见的四个错误。
数据里存的是 1234.5,界面上想显示 $1,234.50。这两个是同一个数的两种写法。
StringFormat 干的就是这件事:它不碰数据,只在值从来源送到目标的那一刻,把值格式化成另一种文字形式。
1 2 3 4 5 <TextBlock Text ="{Binding Amount, Mode=OneWay}" /> <TextBlock Text ="{Binding Amount, Mode=OneWay, StringFormat={}{0:C2}}" />
Amount 这个属性的值始终是 1234.5,一个字节都没变。变的是界面上那串文字。
4.2 那个空大括号是什么 第一次看到 StringFormat={}{0:C2},几乎所有人都会问:前面那两个大括号是干什么的?
答案在 XAML 的语法上。XAML 里 { 是个特殊字符,{Binding ...} 这样的标记扩展就是靠它识别的。如果 StringFormat 的值直接以 { 开头:
XAML 解析器看到 { 就以为要开始一个标记扩展,然后发现 {0:C2} 里没有合法扩展名,直接报错。
解决办法是加一个空的大括号 {} 做转义,告诉解析器「后面这串不是标记扩展,就是普通文字」:
{} 是转义标记,{0:C2} 才是真正的格式字符串。
图 11: StringFormat={}{0:C2} * 拆开看,前面的大括号是转义标记。*
如果格式字符串不是以 { 开头,就可以不写这个转义。比如:
1 2 3 4 5 StringFormat=月薪:{0:C0} StringFormat={}{0:C0}
4.3 常用格式速查 StringFormat 里的格式串用的是 .NET 标准的格式语法,{0:...} 中的 0 表示「第一个参数」,后面是格式说明符。
写法
显示结果(Amount = 1234.5,Ratio = 0.732,Quantity = 1234567)
说明
{}{0:C2}
$1,234.50
货币,两位小数
{}{0:C0}
$1,234
货币,不要小数
{}{0:N0}
1,234,567
千位分隔,不要小数
{}{0:N3}
1,234.500
千位分隔,三位小数
{}{0:F4}
0.7320
定点小数,不分组
{}{0:P1}
73.2%
百分比,一位小数
{}{0:yyyy-MM-dd}
2026-10-20
日期格式
月薪:{0:C0}
月薪:$1,234
可以带前缀文字
这张表里的结果是在 zh-CN 区域设置下实测的。货币符号和千位分隔符会跟着系统区域变,如果换成英文系统,C2 会显示成 $1,234.50 或 ¥1,234.50 之类的形式。
4.4 一个格式字符串只能管一个值 StringFormat 里的 {0} 只能引用一个值——就是当前这个绑定绑的那个值。想在同一行里拼好几个值,得用多个 Run:
1 2 3 4 5 6 7 8 9 <TextBlock FontSize ="15" Foreground ="#174EA6" > <Run Text ="{Binding SelectedEmployee.Name, Mode=OneWay, TargetNullValue=(未选择)}" /> <Run Text =" | " /> <Run Text ="{Binding SelectedEmployee.Department, Mode=OneWay, TargetNullValue=未知}" /> <Run Text =" | 月薪 " /> <Run Text ="{Binding SelectedEmployee.Salary, Mode=OneWay, StringFormat={}{0:C0}}" /> <Run Text =" | 身份 " /> <Run Text ="{Binding SelectedEmployee.Level, Mode=OneWay, TargetNullValue=未知}" /> </TextBlock >
每个 Run 各自绑一个值,各自写自己的格式。Run 之间用 Run Text=" | " 这样的固定文字连接。
注意这里的 Run.Text 都写了 Mode=OneWay。第 2 章说过,Run.Text 默认是 TwoWay,只用于显示时应该关掉双向。
这是本章最值得实测的一件事,因为网上的说法互相矛盾。
实测结果很明确:两者都会生效,顺序是先转换、再格式化。
用一个转换器把 1234.5 减半,同时写货币格式:
1 2 3 <TextBlock Text ="{Binding Amount, Converter={StaticResource Half}, StringFormat={}{0:C2}}" />
显示结果是 $617.25。
拆开看这个过程:
来源值 1234.5 交给转换器,Convert 返回 617.25。
这个 617.25 再交给 StringFormat,格式化成 $617.25。
最后送到界面。
对照的三种情况:
配置
显示结果
结论
只有 Converter(减半)
617.25
转换生效,没格式化
Converter + StringFormat
$617.25
两个都生效,先转换后格式化
只有 StringFormat
$1,234.50
没转换,只格式化
图 12:Converter 与 StringFormat 同时写时的处理顺序。
所以「StringFormat 和 Converter 不能同时用」这个说法是错的。真正要留意的是顺序 :StringFormat 作用在转换器的输出 上,而不是原始值上。
这一点会影响你的设计。比如转换器返回的是文字「高级」,再配 {0:C2} 就会失败,因为「高级」不是数字,格式化不了。这种情况下要么转换器返回数字、让 StringFormat 去格式化,要么转换器直接返回最终文字、不写 StringFormat。
错误一:忘了转义。 写成 StringFormat={0:C2},编译直接报错。必须写成 StringFormat={}{0:C2}。
错误二:格式串写错。 比如把 {0:C2} 写成 {0:C2 少了一个大括号。这种错误不一定报错,可能只是显示成原样,所以改完必须盯着界面看。
错误三:以为写回也会被格式化。 StringFormat 只管「来源到界面」这个方向。如果 Mode 是 TwoWay,用户在界面上输入 $500,写回来源的是 500,不是 $500。格式化只影响显示。
错误四:用它做单位换算。 StringFormat 只能改显示形式,不能改数值。想把「米」显示成「厘米」,那是数值变了,得用 Converter。
4.7 跟做:搭出第 4 章页签 把下面的内容放进第 4 个页签。第一块给几个会变的原始值,第二块把同一个数字用六种格式显示,第三块演示 Run 拼接:
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 <TabItem Header ="4 StringFormat 格式化" > <ScrollViewer VerticalScrollBarVisibility ="Auto" Padding ="0,16,0,0" > <StackPanel > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="先给几个会变的原始值" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,14" Text ="改这里的数字,下面所有格式化输出会同时跟着变。这就是 StringFormat 的特点:只改显示样子,不动数据本身。" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,14,0" > <TextBlock Text ="金额(Amount)" Style ="{StaticResource LabelStyle}" /> <TextBox Text ="{Binding Amount, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> </StackPanel > <StackPanel Grid.Column ="1" Margin ="0,0,14,0" > <TextBlock Text ="比例(Ratio,0 到 1)" Style ="{StaticResource LabelStyle}" /> <Slider Minimum ="0" Maximum ="1" Value ="{Binding Ratio, Mode=TwoWay}" /> </StackPanel > <StackPanel Grid.Column ="2" Margin ="0,0,14,0" > <TextBlock Text ="数量(Quantity)" Style ="{StaticResource LabelStyle}" /> <TextBox Text ="{Binding Quantity, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> </StackPanel > <StackPanel Grid.Column ="3" > <TextBlock Text ="签约日期(SignDate)" Style ="{StaticResource LabelStyle}" /> <DatePicker SelectedDate ="{Binding SignDate, Mode=TwoWay}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="同一个数字的六种长相" Style ="{StaticResource SectionTitleStyle}" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="220" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <Grid.RowDefinitions > <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> </Grid.RowDefinitions > <TextBlock Grid.Row ="0" Text ="写法" Style ="{StaticResource LabelStyle}" /> <TextBlock Grid.Row ="0" Grid.Column ="1" Text ="显示结果" Style ="{StaticResource LabelStyle}" /> <TextBlock Grid.Row ="0" Grid.Column ="2" Text ="说明" Style ="{StaticResource LabelStyle}" /> <TextBlock Grid.Row ="1" Style ="{StaticResource CodeStyle}" Text ="{}{0:C2}" /> <TextBlock Grid.Row ="1" Grid.Column ="1" Style ="{StaticResource ValueStyle}" Text ="{Binding Amount, Mode=OneWay, StringFormat={}{0:C2}}" /> <TextBlock Grid.Row ="1" Grid.Column ="2" Style ="{StaticResource HintStyle}" Text ="货币格式,两位小数" /> <TextBlock Grid.Row ="2" Style ="{StaticResource CodeStyle}" Text ="{}{0:N0}" /> <TextBlock Grid.Row ="2" Grid.Column ="1" Style ="{StaticResource ValueStyle}" Text ="{Binding Quantity, Mode=OneWay, StringFormat={}{0:N0}}" /> <TextBlock Grid.Row ="2" Grid.Column ="2" Style ="{StaticResource HintStyle}" Text ="千位分隔,不要小数" /> <TextBlock Grid.Row ="3" Style ="{StaticResource CodeStyle}" Text ="{}{0:N3}" /> <TextBlock Grid.Row ="3" Grid.Column ="1" Style ="{StaticResource ValueStyle}" Text ="{Binding Amount, Mode=OneWay, StringFormat={}{0:N3}}" /> <TextBlock Grid.Row ="3" Grid.Column ="2" Style ="{StaticResource HintStyle}" Text ="千位分隔,三位小数" /> <TextBlock Grid.Row ="4" Style ="{StaticResource CodeStyle}" Text ="{}{0:P1}" /> <TextBlock Grid.Row ="4" Grid.Column ="1" Style ="{StaticResource ValueStyle}" Text ="{Binding Ratio, Mode=OneWay, StringFormat={}{0:P1}}" /> <TextBlock Grid.Row ="4" Grid.Column ="2" Style ="{StaticResource HintStyle}" Text ="百分比,一位小数" /> <TextBlock Grid.Row ="5" Style ="{StaticResource CodeStyle}" Text ="{}{0:F4}" /> <TextBlock Grid.Row ="5" Grid.Column ="1" Style ="{StaticResource ValueStyle}" Text ="{Binding Ratio, Mode=OneWay, StringFormat={}{0:F4}}" /> <TextBlock Grid.Row ="5" Grid.Column ="2" Style ="{StaticResource HintStyle}" Text ="定点小数,不分组" /> <TextBlock Grid.Row ="6" Style ="{StaticResource CodeStyle}" Text ="{}{0:yyyy-MM-dd}" /> <TextBlock Grid.Row ="6" Grid.Column ="1" Style ="{StaticResource ValueStyle}" Text ="{Binding SignDate, Mode=OneWay, StringFormat={}{0:yyyy-MM-dd}}" /> <TextBlock Grid.Row ="6" Grid.Column ="2" Style ="{StaticResource HintStyle}" Text ="日期格式,注意不是数字格式" /> <TextBlock Grid.Row ="7" Style ="{StaticResource CodeStyle}" Text ="没有 StringFormat" /> <TextBlock Grid.Row ="7" Grid.Column ="1" Style ="{StaticResource ValueStyle}" Text ="{Binding Amount, Mode=OneWay}" /> <TextBlock Grid.Row ="7" Grid.Column ="2" Style ="{StaticResource HintStyle}" Text ="不写就是这个样子" /> </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="把几个字段拼成一句话" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,12" Text ="StringFormat 一次只能格式化一个值。要在同一行里拼好几个值,用 Run 拼,每个 Run 各自绑一个值、各自写自己的格式。Run.Text 的默认模式是 TwoWay,所以这里要显式写 Mode=OneWay。" /> <Border Background ="#F7F9FD" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" Padding ="14" > <TextBlock FontSize ="15" Foreground ="#174EA6" FontWeight ="SemiBold" > <Run Text ="{Binding SelectedEmployee.Name, Mode=OneWay, TargetNullValue=(未选择)}" /> <Run Text =" | " /> <Run Text ="{Binding SelectedEmployee.Department, Mode=OneWay, TargetNullValue=未知}" /> <Run Text =" | 月薪 " /> <Run Text ="{Binding SelectedEmployee.Salary, Mode=OneWay, StringFormat={}{0:C0}}" /> <Run Text =" | 身份 " /> <Run Text ="{Binding SelectedEmployee.Level, Mode=OneWay, TargetNullValue=未知}" /> </TextBlock > </Border > <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,12,0,0" Text ="这段文字里的四个值分别来自同一个对象的四个属性,全都在第 1 章那个列表里选中。" /> </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="用之前先记住四条" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Text ="一、StringFormat 的值以 { 开头时,前面必须加一个空的大括号 {} 转义,写成 StringFormat={}{0:C2}。 二、StringFormat 和 Converter 可以同时写,两者都会生效,顺序是先转换、再格式化,格式作用在转换结果上。 三、StringFormat 只影响显示,改文本框里的内容不会把格式带回去,写回来源的仍然是原始数字。 四、格式写错不会报错,只是显示成原样,所以改完格式要盯着界面看一遍。" /> </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
4.8 跟做检查点
检查项
期望结果
打开页签 4
四块内容从上到下排列
看「同一个数字的六种长相」
六行格式结果分别是 $1,234.50、1,234,567、1,234.500、73.2%、0.7320、2026-10-20
把金额文本框改成 2000
货币行变成 $2,000.00,三位小数行变成 2,000.000
拖动比例滑块
百分比行和小数行同时变
改签约日期
日期行跟着变
看最后一行
显示 2000,说明没写格式就是原样
在页签 1 里选「孙七」
本页签底部那句话变成「孙七 | 研发部 | 月薪 $14,500 | 身份 主管」
如果货币行显示的是 1234.5 而不是 $1,234.50,检查 StringFormat 前面有没有漏掉 {}。
4.9 小练习
加一行 {}{0:E2},看看科学计数法长什么样。
给 Ratio 加一行 {}{0:P0},对比 P1 的差别。
把日期格式改成 {}{0:yyyy 年 MM 月 dd 日},观察中文格式的输出。
把金额那行改成 {}{0:C2} 但去掉 Mode=OneWay,观察有没有区别,并想清楚为什么。
给 Amount 的 TextBox 加上 StringFormat={}{0:C2},然后在里面输入数字,验证写回来源的是数字而不是带货币符号的字符串。
5. 绑定对象的 Converter 属性 本节目标 学完本节,你应该能够:
说清转换器在绑定链上的位置,知道 Convert 和 ConvertBack 各管哪个方向。
写一个能用的 IValueConverter,并把画刷缓存起来。
用 ConverterParameter 让一个转换器服务多种配色或多种输出。
说清 Binding.DoNothing 和返回 null 的区别,知道什么时候该用哪个。
知道转换器抛异常时会发生什么,从而写好兜底逻辑。
5.1 转换器管的是「翻译」 前面几章的选项都在调「怎么连」,Converter 管的是「翻译」。
数据里存的是 true,界面上要的是蓝色或灰色;数据里存的是 9800,界面上要的是「初级」;数据里存的是颜色名 SteelBlue,界面上要的是一个画刷。这些都不是格式问题,而是类型和含义都变了 ,StringFormat 做不了,得靠 Converter。
图 13:值从来源去界面走 Convert,从界面回来源走 ConvertBack。
转换器要实现 IValueConverter,它只有两个方法:
1 2 3 4 5 6 7 8 public interface IValueConverter { object Convert (object value , Type targetType, object parameter, CultureInfo culture ) ; object ConvertBack (object value , Type targetType, object parameter, CultureInfo culture ) ; }
四个参数的含义:
参数
是什么
value
传进来的值,来源方向时是来源属性的值,反向时是界面属性的值
targetType
目标属性的类型,用来判断该转成什么
parameter
XAML 里 ConverterParameter 传进来的值
culture
区域信息,做货币、日期格式化时会用到
如果这个转换器只用于显示, ConvertBack ** 直接 throw new NotSupportedException() ** 就好。 不要写成 return null 或者 return value,那样会让「反向不可用」这件事变得含糊。
5.2 ConverterParameter 是什么类型 实测结果很明确:ConverterParameter 从 XAML 传进来时始终是字符串 。
1 2 3 4 <TextBlock Foreground ="{Binding ManagerFlag, Converter={StaticResource BoolToBrushConverter}, ConverterParameter='#174EA6|#B03A3A'}" />
所以转换器里拿到参数后,第一步永远是「先判断它是不是字符串,再决定怎么解析」:
1 2 3 4 5 6 7 8 9 10 11 12 13 var flag = value is true ;if (parameter is string text){ var parts = text.Split('|' ); if (parts.Length == 2 ) { return CreateBrush(flag ? parts[0 ] : parts[1 ]); } } return flag ? TrueBrush : FalseBrush;
这段代码体现了一个稳妥的写法:参数不对就退回默认行为 ,而不是抛异常。因为 XAML 里的参数是手写的,写错一个字符很常见,退回默认比让整个界面崩掉好得多。
顺带说明一个语法细节:参数里带 # 或者 | 这类字符时,建议用单引号包起来写成 ConverterParameter='#174EA6|#B03A3A',避免解析歧义。
图 14:ConverterParameter 从 XAML 传进来永远是字符串,用参数切换输出。
5.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 public object Convert (object value , Type targetType, object parameter, CultureInfo culture ){ var salary = value switch { decimal number => number, double number => (decimal )number, int number => number, _ => 0 m }; var wantsBrush = parameter is string text && text.Equals("Brush" , StringComparison.OrdinalIgnoreCase); if (salary >= 13000 m) { return wantsBrush ? SeniorBrush : "高级" ; } if (salary >= 10500 m) { return wantsBrush ? MiddleBrush : "中级" ; } return wantsBrush ? JuniorBrush : "初级" ; }
同一个转换器,用在 Text 上就返回文字,用在 Foreground 上就返回画刷:
1 2 3 4 5 6 <TextBlock Text ="{Binding GradeSalary, Converter={StaticResource SalaryToGradeConverter}}" /><TextBlock Foreground ="{Binding GradeSalary, Converter={StaticResource SalaryToGradeConverter}, ConverterParameter=Brush}" />
注意开头那个 switch:转换器必须兜住意外类型 。绑定的值可能是 null、可能是别的类型,直接 (decimal)value 会抛异常。用模式匹配逐个判断,最后给一个默认值,才稳。
5.4 缓存画刷:为什么要有 Freeze 仔细看前面那些转换器,画刷都是写成 static readonly 字段,在转换器外面一次性创建好:
1 2 3 4 5 6 7 8 9 private static readonly Brush TrueBrush = CreateBrush("#2F6FED" );private static readonly Brush FalseBrush = CreateBrush("#5D6B82" );private static Brush CreateBrush (string colorText ){ var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colorText)!); brush.Freeze(); return brush; }
这有两个理由:
理由一:避免重复创建。 一个列表里可能有几百项,每一项的 Convert 都会被调用。如果每次都 new 一个画刷,就会产生几百个对象。画刷本身是不可变的,完全可以共用同一个。
理由二: Freeze() ** 让它变成只读,可以跨线程共享,也省掉了变更通知的开销。** 界面渲染时读它的速度更快。
但如果颜色是从 ConverterParameter 来的,那就没法预先缓存了,只能现场创建。这时仍然要 Freeze(),虽然对象是临时的,但冻结之后渲染开销更低:
1 2 3 4 5 6 private static Brush CreateBrush (string colorText ){ var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colorText)!); brush.Freeze(); return brush; }
5.5 双向转换:ConvertBack 真的用起来 前面几个转换器的 ConvertBack 都抛了异常,因为它们只做显示。但有一类转换器是双向 的:文字和颜色互相翻译。
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 public object Convert (object value , Type targetType, object parameter, CultureInfo culture ){ var name = value as string ; if (string .IsNullOrWhiteSpace(name)) { return Binding.DoNothing; } try { var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(name)!); brush.Freeze(); return brush; } catch (FormatException) { return Binding.DoNothing; } } public object ConvertBack (object value , Type targetType, object parameter, CultureInfo culture ){ return value is SolidColorBrush brush ? brush.Color.ToString() : Binding.DoNothing; }
界面上的效果是:左边输入颜色名,右边色块跟着变;如果这个色块还能被用户改(比如换个控件),改动会通过 ConvertBack 变回颜色名写进来源。
这里最关键的是那个 try / catch 加 return Binding.DoNothing。用户可能输入一个不存在的颜色名,比如「不是颜色」。这时候有两个选择:
抛异常:程序直接报错,界面崩掉。
返回 Binding.DoNothing:这次不做改动,界面保持原样。
显然是第二种更合理。
5.6 DoNothing 和 null 到底差在哪 这两个词经常被混着用,实测行为差别很大。
用一个转换器,第一次返回合法颜色名,第二次返回 DoNothing;另一路对照,第二次返回 null:
场景
目标属性的值
第一次返回合法值 SteelBlue
SteelBlue
第二次返回 Binding.DoNothing
SteelBlue(保持原值 )
第三次改回合法值 Tomato
Tomato
对照:第二次返回 null
空(被清掉了 )
结论很清楚:
Binding.DoNothing :表示「这次什么都不做」,绑定会跳过这次更新,目标保持它上一次的值。
返回 null :表示「值就是空」,绑定会把空值送过去,目标被清空。
所以「转换失败时保持界面原样」要用 Binding.DoNothing,不要用 null。用 null 的话,用户每打错一个字符,色块就会闪一下变成空白。
还有一个细节值得记住:首次绑定时就返回 DoNothing ,目标属性会显示它的默认值 (对 TextBlock.Text 来说是空字符串)。因为此时绑定还没有「上一次的值」可以保持。所以不要指望用 DoNothing 给目标一个初始值,初始值该在别的地方给。
图 15:DoNothing 保持原值,返回 null 会把目标清空。
5.7 转换器抛异常会怎样 这一条也实测了,因为很多人以为 WPF 会「悄悄吞掉」转换器的异常。
实测结果:
情况
结果
Convert 抛异常
异常直接抛给调用方 ,同时目标属性回到默认值(空字符串)
ConvertBack 抛 NotSupportedException
异常直接抛给调用方 ,来源值保持原样(写回没有发生)
所以「只做显示的转换器,ConvertBack 抛 NotSupportedException」这个写法是安全的,但它会在双向绑定的场景里报错 。如果这个转换器被用在 Mode=TwoWay 上,那么用户一改界面就会抛异常。
判断方法很简单:如果这个转换器配的是 TwoWay , ConvertBack ** 就必须实现**;只有确定是单向显示,才抛 NotSupportedException。
另外,Convert 里该有的兜底还是要写。抛异常虽然能被你看见,但用户看到的是程序崩溃,不是报错信息。
第 4 章测过两者同时写时的行为:都会生效,顺序是先转换、再格式化。这决定了它们的分工:
需求
用什么
数字 1234.5 显示成 $1,234.50
StringFormat
数字 9800 显示成「初级」
Converter
真假值显示成蓝或灰
Converter
日期显示成 2026-10-20
StringFormat
颜色名显示成色块
Converter
转换结果还要加货币符号
两个都写,先转换后格式化
一个简单的判断标准:如果值本身没变,只是写法变了,用 StringFormat ;如果值的类型或者含义变了,用 Converter 。
5.9 跟做:搭出第 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 <TabItem Header ="5 Converter 转换器" > <ScrollViewer VerticalScrollBarVisibility ="Auto" Padding ="0,16,0,0" > <StackPanel > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="转换器在绑定链上的位置" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource CodeStyle}" Text ="来源属性 → Convert() → 目标属性 目标属性 → ConvertBack() → 来源属性" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,12,0,0" Text ="值从来源去界面走 Convert,从界面回来源走 ConvertBack。只做显示用的转换器,ConvertBack 可以直接抛 NotSupportedException,表示这个方向不打算支持。" /> </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="布尔转颜色:一个参数改两种配色" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,14" Text ="下面三段文字绑的是同一个 ManagerFlag,但 ConverterParameter 不一样。第一段不传参数,用转换器自带的默认色;后两段各传一组颜色,所以同一个 true 能显示成三种颜色。" /> <CheckBox Content ="勾上就表示「是主管」" IsChecked ="{Binding ManagerFlag, Mode=TwoWay}" Margin ="0,0,0,14" /> <TextBlock Text ="不传参数(默认蓝与灰)" Foreground ="{Binding ManagerFlag, Converter={StaticResource BoolToBrushConverter}}" /> <TextBlock Text ="参数传 #174EA6|#B03A3A" Margin ="0,8,0,0" Foreground ="{Binding ManagerFlag, Converter={StaticResource BoolToBrushConverter}, ConverterParameter='#174EA6|#B03A3A'}" /> <TextBlock Text ="参数传 #0F7B57|#8A6D1F" Margin ="0,8,0,0" Foreground ="{Binding ManagerFlag, Converter={StaticResource BoolToBrushConverter}, ConverterParameter='#0F7B57|#8A6D1F'}" /> </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="数字转文字与颜色:一个转换器两种输出" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,14" Text ="工资是数字,界面上要显示成「高级」「中级」「初级」这样的文字,颜色也要跟着变。转换器用一个参数决定这次要文字还是颜色,同一个转换器就能顶两件事。拖动滑块看等级怎么跳。" /> <Slider Minimum ="8000" Maximum ="15000" TickFrequency ="500" Value ="{Binding GradeSalary, Mode=TwoWay}" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,16,0" > <TextBlock Text ="原始工资" Style ="{StaticResource LabelStyle}" /> <TextBlock Style ="{StaticResource ValueStyle}" Text ="{Binding GradeSalary, Mode=OneWay, StringFormat={}{0:C0}}" /> </StackPanel > <StackPanel Grid.Column ="1" Margin ="0,0,16,0" > <TextBlock Text ="转成等级文字" Style ="{StaticResource LabelStyle}" /> <TextBlock Style ="{StaticResource ValueStyle}" Text ="{Binding GradeSalary, Mode=OneWay, Converter={StaticResource SalaryToGradeConverter}}" /> </StackPanel > <StackPanel Grid.Column ="2" > <TextBlock Text ="转成等级颜色" Style ="{StaticResource LabelStyle}" /> <TextBlock FontWeight ="SemiBold" Text ="这个颜色的等级" Foreground ="{Binding GradeSalary, Mode=OneWay, Converter={StaticResource SalaryToGradeConverter}, ConverterParameter=Brush}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" Margin ="0,0,0,16" > <StackPanel > <TextBlock Text ="双向转换:文字和颜色互译" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Margin ="0,0,0,14" Text ="这个转换器的 ConvertBack 是真的实现了的:Convert 把颜色名变成画刷,ConvertBack 把画刷变回颜色名。在左边输入颜色名(比如 Red、Tomato、#2F6FED),右边色块跟着变。输入一个不认识的名字,它返回 Binding.DoNothing,界面保持上一次的颜色,而不是崩掉。" /> <Grid > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,16,0" > <TextBlock Text ="颜色名(来源)" Style ="{StaticResource LabelStyle}" /> <TextBox Text ="{Binding ColorName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> </StackPanel > <StackPanel Grid.Column ="1" > <TextBlock Text ="色块(目标)" Style ="{StaticResource LabelStyle}" /> <Border Height ="60" CornerRadius ="8" BorderBrush ="#DCE5F2" BorderThickness ="1" Background ="{Binding ColorName, Converter={StaticResource ColorNameToBrushConverter}}" /> </StackPanel > </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardStyle}" > <StackPanel > <TextBlock Text ="转换器四条实用经验" Style ="{StaticResource SectionTitleStyle}" /> <TextBlock Style ="{StaticResource HintStyle}" Text ="一、只做显示转换的,ConvertBack 抛 NotSupportedException,别写成 return null。 二、转换器写成无状态的、画刷记得 Freeze,可以少占内存。 三、能返回 Binding.DoNothing 的地方就返回它,比返回 null 安全,界面会保持原值。 四、转换器里的判断要能兜住意外类型,绑定的值可能是 null 或者别的类型,先做类型检查再转换。" /> </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
5.10 跟做检查点
检查项
期望结果
打开页签 5
四块内容从上到下排列
取消勾选「是主管」
三段文字颜色全部从深色变成红色系
拖动工资滑块到 15000
等级文字显示「高级」,颜色变深蓝
拖到 9800
等级文字显示「初级」,颜色变灰
在颜色名框里输入 Red
色块变成红色
输入 #2F6FED
色块变成主色蓝
输入「不是颜色」
色块保持上一次的颜色不变,不报错
再把颜色名改回 Tomato
色块恢复正常
如果输入非法颜色名时色块变成空白,检查转换器里那两处是不是写成了 return null,应该写成 return Binding.DoNothing。
5.11 小练习
给 SalaryToGradeConverter 加一个 ConverterParameter=Short,返回「高」「中」「低」这样的短文字。
写一个转换器,把 decimal 的工资按 1000 取整显示,比如 12000 显示成 12K。
把 ColorNameToBrushConverter 的 ConvertBack 改成抛 NotSupportedException,然后把颜色名文本框改成 Mode=TwoWay,观察会发生什么。
给 BoolToBrushConverter 加一个 ConverterParameter 只有一段颜色(没有 |)的情况,让背景用那段颜色、前景用默认色。
用探针程序验证:转换器返回 Binding.DoNothing 时,如果这次是首次绑定,目标属性拿到的是什么。
五个选项速查表 把五章的结论压成一张表,写绑定时对着查:
选项
作用
常用取值
最容易踩的坑
RelativeSource
描述来源在哪里
Self、FindAncestor、TemplatedParent、PreviousData
找到的是控件不是数据,路径前面要补 DataContext.
ElementName
按名字找来源控件
控件名
名字作用域不通用,模板里通常找不到
Mode
值往哪个方向流
OneWay、TwoWay、OneTime、OneWayToSource
以为不写就是双向,其实要看目标属性的默认值
UpdateSourceTrigger
什么时候写回来源
PropertyChanged、LostFocus、Explicit
TextBox.Text 默认是 LostFocus,不是每敲一个字
Delay
写回前等多久
毫秒数
只在 PropertyChanged 下有效,而且不能写成绑定
StringFormat
值的显示形式
{}{0:C2}、{}{0:N0}、{}{0:P1}
开头必须加 {} 转义
Converter
值的类型或含义转换
自定义 IValueConverter
配了 TwoWay 就必须实现 ConvertBack
七个常见问题的排查顺序 遇到「绑定不对」,按这个顺序查,比乱试快得多:
界面完全空白 :先看路径是不是在错误的 DataContext 上解析。在模板内部想用外层数据,就要 RelativeSource。
只有部分行空白 :多半是某个属性在数据项上不存在,或者拼写错了。看「输出」窗口的绑定错误信息。
界面改了但来源不变 :看 Mode。目标属性默认可能不是双向,或者被显式写成了 OneWay。
来源变了但界面不变 :看来源有没有发 PropertyChanged 通知,以及 Mode 是不是 OneWayToSource。
每敲一个字就卡一下 :看 UpdateSourceTrigger,需要的话加 Delay。
数字显示得很丑 :加 StringFormat,别忘了开头的 {}。
颜色或文字不对 :看 Converter,先确认参数是不是字符串,再确认兜底逻辑写全了。
完整示例结构 跟做完成后,项目结构大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 WpfBindingDetailsPractice\ ├── App.xaml ├── App.xaml.cs ├── AssemblyInfo.cs ├── Converters\ │ ├── BoolToBrushConverter.cs │ ├── ColorNameToBrushConverter.cs │ └── SalaryToGradeConverter.cs ├── Models\ │ ├── Department.cs │ └── Employee.cs ├── ViewModels\ │ └── MainViewModel.cs ├── MainWindow.xaml ├── MainWindow.xaml.cs └── WpfBindingDetailsPractice.csproj
五个页签与五个章节一一对应:
页签
对应章节
核心演示
1 相对源与联动
第 1 章
错误对照、作用域示意、列表套列表联动
2 Mode 属性
第 2 章
四种模式的值流向
3 写回时机与 Delay
第 3 章
三种时机的写回次数对比
4 StringFormat 格式化
第 4 章
六种数字格式、Run 拼接
5 Converter 转换器
第 5 章
参数换配色、一转换器两种输出、双向转换
运行检查表 启动程序后,按这个顺序检查一遍:
步骤
操作
期望结果
1
打开页签「1 相对源与联动」
左列每行第二行空白,右列全部显示「张三」
2
在下方部门卡片里点「孙七」
右列全部变成「孙七」,底部工资 $14,500
3
打开页签「2 Mode 属性」
四块演示从上到下排列
4
在第二块文本框里输入内容
滑块不动,验证 OneWay
5
拖动第三块滑块
下边「OneTime 读到的值」始终是 40.0
6
在第四块文本框里输入文字
右边「来源的值」立刻跟着变
7
点「在代码里改来源的值」
右边显示时间,左边文本框内容不变
8
打开页签「3 写回时机与 Delay」
四块演示从上到下排列
9
在左边文本框连打 5 个字符
写回次数立刻变成 5
10
在右边文本框连打 5 个字符后点别处
写回次数从 0 变成 1
11
在 Delay 文本框连打后停手等 1 秒
写回次数只加 1
12
在 Explicit 文本框里改内容后点按钮
点之前来源是旧值,点之后变成新值
13
打开页签「4 StringFormat 格式化」
六行格式结果分别是 $1,234.50、1,234,567、1,234.500、73.2%、0.7320、2026-10-20
14
把金额改成 2000
货币行变成 $2,000.00
15
打开页签「5 Converter 转换器」
四块演示从上到下排列
16
取消勾选「是主管」
三段文字颜色变成红色系
17
颜色名框里输入「不是颜色」
色块保持上一次颜色,不报错
如果某一步结果不对,先在「输出」窗口里找绑定错误。绑定相关的问题绝大多数是路径、作用域或默认值判断错误,而不是控件本身有问题。
综合练习 练习一:可实时搜索的员工表 要求:
一个搜索框,输入关键字后列表只剩匹配的员工。
搜索框要边输边过滤,但不要每敲一个字就查一次「接口」(用一个模拟方法代替)。
列表每行显示姓名、部门、工资,工资带货币符号。
没有匹配结果时显示一句提示。
提示:搜索框用 UpdateSourceTrigger=PropertyChanged,再配一个 Delay 把连续输入合并;FilteredEmployees 用派生属性实现,SearchText 变化时通知它;空状态用第 1 章的 RelativeSource 或一个 Converter 控制可见性。
练习二:可确认的编辑表单 要求:
表单里三个字段,改动时先不生效。
点「保存」才把三个字段一起写回来源。
点「取消」把界面恢复成来源里的值。
保存后显示一条提示,说明改了哪几个字段。
提示:三个文本框都用 UpdateSourceTrigger=Explicit;保存时对每个文本框调用 UpdateSource;取消时用 UpdateTarget 把来源的值拉回界面。
练习三:一个转换器做三种输出 要求:
工资数字要能显示成「12000」、「$12,000」、「12K」三种形式。
用 ConverterParameter 区分要哪一种。
参数缺失或写错时退回默认的第一种。
顺带把等级颜色也做出来(复用第 5 章的转换器)。
提示:在 Convert 开头先用模式匹配把值转成 decimal,再按参数分支;每一种分支都别忘 Freeze()。
结语 这一篇的五章可以压缩成五句话:
章节
一句话
相对源与多级联动
模板内部的 DataContext 换了人,要拿外层数据就得显式指路
Mode
先想清楚值该往哪流,再决定写不写这个属性
UpdateSourceTrigger 与 Delay
写回时机决定体验,Delay 是给实时场景降频的工具
StringFormat
只改长相不改数据,值以 { 开头时前面必须补 {}
Converter
值和界面含义不同时才用,失败时返回 DoNothing 而不是 null
这五个选项加起来,其实只回答两个问题:来源是谁 ,和值怎么走 。把这两件事想清楚,绑定的绝大多数怪问题都能自己解释。
再往深处走,会碰到数据校验、CollectionViewSource、虚拟化、异步加载、MVVM 框架这些话题。但它们都是在这套基础之上的扩展,底子稳了,学起来会快很多。
动手顺序建议:先把第 1 章的作用域和 RelativeSource 跑通,这是后面所有跨层绑定的基础;再加第 2、3 章的 Mode 和写回时机;最后做第 4、5 章的格式化和转换器。每完成一步都运行一次,看到变化再继续。
祝学习顺利。