面向已经练过绑定基础、准备把「数据和界面之间的翻译」这件事做扎实的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,以便在当前安装了 .NET 9 SDK 的电脑上直接编译。这里用到的转换与校验机制在 .NET 9 和 .NET 10 中没有变化。 最后更新:2026-10-20
目录
系统默认 ValueConverter
ConverterParameter 的使用
基于 Converter 的性别绑定案例
FallbackValue 与 TargetNullValue 属性
绑定数据异常与捕获
自定义 ValidationRule
写在前面 这份笔记解决什么问题 前一篇笔记讲的是绑定的五个可配置项,其中已经用过 Converter。但那时的重点是「转换器放在链路的什么位置」,这一篇要往下走一层,回答几个更具体的问题:
1 2 3 4 5 6 不写转换器的时候,界面到底是怎么把 1234.5 变成文字的?所有类型都能变吗? ConverterParameter 传进来是什么类型?为什么我传了数字却当成字符串用了? 两个单选按钮怎么绑同一个「性别」字段? 来源是 null,和来源根本取不到值,这两种情况该怎么分别兜底? 转换器抛异常、来源 setter 抛异常,界面上会看到什么? 校验规则写好了,为什么有时拿到的还是字符串,有时却是数字?
这些问题分成两组。前三个属于转换 ,讲的是「值怎么变成界面能显示的样子」;后三个属于兜底与校验 ,讲的是「值不对劲时怎么办」。
这一篇要建立的整体认识是这样一句话:
绑定这条路上,值会经过四道处理,每一道都可能改变它,也都可能失败。
图 1:默认转换、自定义转换、兜底值、校验规则,四道处理各管一段。
建议的学习顺序
先运行随附示例,把六个页签逐个点一遍,先有整体印象。
回到第 1 章,把「默认转换能做什么、不能做什么」摸清楚,这是理解后面所有转换器的前提。
第 2、3 章连着练:先明白参数怎么传,再用参数做出一个真实可用的性别绑定。
第 4 章单独练,它跟转换器关系不大,但界面健壮性全靠它。
第 5、6 章放在最后,因为它们需要你先写过几个转换器,才知道该在哪里接异常、该在哪里加校验。
示例项目位置 1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfConverterValidationDemo
命令行运行:
1 2 cd C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfConverterValidationDemodotnet run
2026 年的目标框架 这一篇用到的东西都在 WPF 的基础库里,没有版本相关的坑。示例工程仍然用 net9.0-windows,你在自己的项目里用 net10.0-windows 也一样。下面这个是最小工程文件:
1 2 3 4 5 6 7 8 9 10 11 <Project Sdk ="Microsoft.NET.Sdk" > <PropertyGroup > <OutputType > WinExe</OutputType > <TargetFramework > net9.0-windows</TargetFramework > <Nullable > enable</Nullable > <ImplicitUsings > enable</ImplicitUsings > <UseWPF > true</UseWPF > </PropertyGroup > </Project >
先建一个自己的跟做项目 在 Visual Studio 里新建一个「WPF 应用程序」,项目名建议叫 WpfConverterPractice,框架选 net9.0-windows 或 net10.0-windows 都行。
建好之后,在项目里新建这几个文件夹,后面每一章往里放文件:
1 2 3 4 5 WpfConverterPractice ├── Models ├── Converters ├── Validations └── ViewModels
先准备好数据模型 这一篇要用到三个模型,都不复杂。
第一个:员工模型。 它比前几篇多带几个「不好直接显示」的字段,用来观察默认转换的边界。
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 using System.ComponentModel;using System.Runtime.CompilerServices;namespace WpfConverterPractice.Models ;public class Employee : INotifyPropertyChanged { private string _name = string .Empty; private string _gender = "男" ; private string _department = string .Empty; private decimal _salary; private bool _isManager; private string _phone = string .Empty; private int _age = 28 ; private DateTime _entryDate = new (2026 , 10 , 20 ); private string _themeColorName = "SteelBlue" ; public int Id { get ; set ; } public string Name { get => _name; set => SetField(ref _name, value ); } public string Gender { get => _gender; set => SetField(ref _gender, 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 string Phone { get => _phone; set => SetField(ref _phone, value ); } public int Age { get => _age; set => SetField(ref _age, value ); } public DateTime EntryDate { get => _entryDate; set => SetField(ref _entryDate, value ); } public string ThemeColorName { get => _themeColorName; set => SetField(ref _themeColorName, 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 ; } private void OnPropertyChanged (string propertyName ) => PropertyChanged?.Invoke(this , new PropertyChangedEventArgs(propertyName)); }
第二个:只带一个整数的小对象。 校验那一章会反复改它,越简单越好看清规则的作用。
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 using System.ComponentModel;using System.Runtime.CompilerServices;namespace WpfConverterPractice.Models ;public class NumberItem : INotifyPropertyChanged { private int _value = 4 ; public int Value { get => _value; set => SetField(ref _value, 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 ; } }
第三个:只带性别的小对象。 性别绑定那一章单独用它,避免被别的属性干扰。
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 using System.ComponentModel;using System.Runtime.CompilerServices;namespace WpfConverterPractice.Models ;public class Person : INotifyPropertyChanged { private string _gender = "男" ; public string Gender { get => _gender; set => SetField(ref _gender, 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 ; } }
先准备好四个转换器 第一个:性别转布尔。 这是第 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 using System.Globalization;using System.Windows.Data;namespace WpfConverterPractice.Converters ;public class GenderToBoolConverter : IValueConverter { public object Convert (object value , Type targetType, object parameter, CultureInfo culture ) { return value is string gender && parameter is string wanted && string .Equals(gender, wanted, StringComparison.Ordinal); } public object ConvertBack (object value , Type targetType, object parameter, CultureInfo culture ) { if (value is true && parameter is string wanted) { return wanted; } return Binding.DoNothing; } }
第二个:颜色名转画刷。 它演示「转换失败时保持界面原样」这种稳妥写法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 using System.Globalization;using System.Windows.Data;using System.Windows.Media;namespace WpfConverterPractice.Converters ;public class ColorNameToBrushConverter : IValueConverter { public object Convert (object value , Type targetType, object parameter, CultureInfo culture ) { if (value is not string name || 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 ) => value is SolidColorBrush brush ? brush.Color.ToString() : Binding.DoNothing; }
第三个:工资转等级。 它演示「一个转换器两种输出」,靠 ConverterParameter 切换。
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 using System.Globalization;using System.Windows.Data;using System.Windows.Media;namespace WpfConverterPractice.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; } }
第四个:性别转一句完整的话。 它演示「只用于显示」的转换器怎么写。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 using System.Globalization;using System.Windows.Data;namespace WpfConverterPractice.Converters ;public class GenderToTextConverter : IValueConverter { public object Convert (object value , Type targetType, object parameter, CultureInfo culture ) { if (value is not string gender || string .IsNullOrWhiteSpace(gender)) { return Binding.DoNothing; } return gender == "男" ? "男员工" : "女员工" ; } public object ConvertBack (object value , Type targetType, object parameter, CultureInfo culture ) => throw new NotSupportedException(); }
先准备好三个校验规则 第一个:只允许偶数。 它顺便演示了「默认校验步骤拿到的是字符串」这个关键点。
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.Globalization;using System.Windows.Controls;namespace WpfConverterPractice.Validations ;public class EvenNumberRule : ValidationRule { public override ValidationResult Validate (object value , CultureInfo cultureInfo ) { if (value is int number) { return number % 2 == 0 ? ValidationResult.ValidResult : new ValidationResult(false , number + " 不是偶数" ); } if (value is string text) { if (string .IsNullOrWhiteSpace(text)) { return new ValidationResult(false , "不能为空" ); } if (!int .TryParse(text, NumberStyles.Integer, cultureInfo, out var parsed)) { return new ValidationResult(false , "「" + text + "」不是整数" ); } return parsed % 2 == 0 ? ValidationResult.ValidResult : new ValidationResult(false , text + " 不是偶数" ); } return new ValidationResult(false , "「" + value + "」不是整数" ); } }
第二个:带上下限的区间规则。 它演示「一个规则类复用多次」的写法。
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 using System.Globalization;using System.Windows.Controls;namespace WpfConverterPractice.Validations ;public class RangeRule : ValidationRule { public int Minimum { get ; set ; } = int .MinValue; public int Maximum { get ; set ; } = int .MaxValue; public string Unit { get ; set ; } = string .Empty; public override ValidationResult Validate (object value , CultureInfo cultureInfo ) { if (!int .TryParse(value ?.ToString(), NumberStyles.Integer, cultureInfo, out var number)) { return new ValidationResult(false , "请输入整数" ); } if (number < Minimum) { return new ValidationResult(false , $"不能小于 {Minimum} {Unit} " ); } if (number > Maximum) { return new ValidationResult(false , $"不能大于 {Maximum} {Unit} " ); } return ValidationResult.ValidResult; } }
第三个:非空规则。 它最简单,用来确认「校验失败时界面到底长什么样」。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 using System.Globalization;using System.Windows.Controls;namespace WpfConverterPractice.Validations ;public class NotEmptyRule : ValidationRule { public string FieldName { get ; set ; } = "内容" ; public override ValidationResult Validate (object value , CultureInfo cultureInfo ) { if (value is not string text || string .IsNullOrWhiteSpace(text)) { return new ValidationResult(false , FieldName + "不能为空" ); } return ValidationResult.ValidResult; } }
第四个:返回替换值的规则。 它专门演示 ValidationResult 的第二个参数。
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 using System.Globalization;using System.Windows.Controls;namespace WpfConverterPractice.Validations ;public class NumberReplacingRule : ValidationRule { public int ClampTo { get ; set ; } public override ValidationResult Validate (object value , CultureInfo cultureInfo ) { if (!int .TryParse(value ?.ToString(), NumberStyles.Integer, cultureInfo, out var number)) { return new ValidationResult(false , "请输入整数" ); } if (number > ClampTo) { return new ValidationResult(false , ClampTo); } return ValidationResult.ValidResult; } }
最后把视图模型搭好 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 using System.Collections.ObjectModel;using System.ComponentModel;using System.Runtime.CompilerServices;using WpfConverterPractice.Models;namespace WpfConverterPractice.ViewModels ;public class MainViewModel : INotifyPropertyChanged { private double _doubleValue = 1234.5 ; private string _stringToInt = "42" ; private string _stringToEnum = "Middle" ; private string _stringToDate = "2026-10-20" ; private string _stringToBool = "true" ; private string _stringToColor = "Red" ; private string ? _nullableText; private decimal _gradeSalary = 9800 m; private string _colorName = "SteelBlue" ; private Person _person = new (); private string ? _nickName; private string _exceptionLog = string .Empty; private NumberItem _numberItem = new (); private Employee _editableEmployee = new () { Id = 1 , Name = "张三" , Department = "研发部" , Salary = 12000 m, IsManager = true , Phone = "13800000000" , Age = 32 }; private Employee? _selectedEmployee; public MainViewModel () { Employees = new ObservableCollection<Employee> { new () { Id = 1 , Name = "张三" , Gender = "男" , Department = "研发部" , Salary = 12000 m, IsManager = true , Phone = "13800000000" , Age = 32 , EntryDate = new DateTime(2019 , 3 , 1 ) }, new () { Id = 2 , Name = "李四" , Gender = "女" , Department = "产品部" , Salary = 10500 m, Phone = "13900000001" , Age = 29 , EntryDate = new DateTime(2021 , 7 , 15 ) }, new () { Id = 3 , Name = "王五" , Gender = "男" , Department = "设计部" , Salary = 9800 m, Phone = "13700000002" , Age = 26 , EntryDate = new DateTime(2022 , 9 , 20 ) }, new () { Id = 4 , Name = "赵六" , Gender = "女" , Department = "测试部" , Salary = 9200 m, Phone = "13600000003" , Age = 25 , EntryDate = new DateTime(2023 , 1 , 10 ) }, new () { Id = 5 , Name = "孙七" , Gender = "男" , Department = "研发部" , Salary = 14500 m, IsManager = true , Phone = "13500000004" , Age = 38 , EntryDate = new DateTime(2016 , 5 , 6 ) }, new () { Id = 6 , Name = "周八" , Gender = "女" , Department = "测试部" , Salary = 8800 m, Phone = "13400000005" , Age = 24 , EntryDate = new DateTime(2023 , 8 , 1 ) }, new () { Id = 7 , Name = "吴九" , Gender = "男" , Department = "产品部" , Salary = 11200 m, Phone = "13300000006" , Age = 30 , EntryDate = new DateTime(2020 , 11 , 23 ) }, new () { Id = 8 , Name = "郑十" , Gender = "女" , Department = "设计部" , Salary = 13200 m, IsManager = true , Phone = "13200000007" , Age = 35 , EntryDate = new DateTime(2018 , 4 , 2 ) } }; SelectedEmployee = Employees[0 ]; } public ObservableCollection<Employee> Employees { get ; } public double DoubleValue { get => _doubleValue; set => SetField(ref _doubleValue, value ); } public string StringToInt { get => _stringToInt; set => SetField(ref _stringToInt, value ); } public string StringToEnum { get => _stringToEnum; set => SetField(ref _stringToEnum, value ); } public string StringToDate { get => _stringToDate; set => SetField(ref _stringToDate, value ); } public string StringToBool { get => _stringToBool; set => SetField(ref _stringToBool, value ); } public string StringToColor { get => _stringToColor; set => SetField(ref _stringToColor, value ); } public string ? NullableText { get => _nullableText; set => SetField(ref _nullableText, value ); } public decimal GradeSalary { get => _gradeSalary; set => SetField(ref _gradeSalary, value ); } public string ColorName { get => _colorName; set => SetField(ref _colorName, value ); } public Person Person { get => _person; set => SetField(ref _person, value ); } public string ? NickName { get => _nickName; set => SetField(ref _nickName, value ); } public string ExceptionLog { get => _exceptionLog; set => SetField(ref _exceptionLog, value ); } public NumberItem NumberItem { get => _numberItem; set => SetField(ref _numberItem, value ); } public Employee EditableEmployee { get => _editableEmployee; set => SetField(ref _editableEmployee, value ); } public Employee? SelectedEmployee { get => _selectedEmployee; set => SetField(ref _selectedEmployee, value ); } public void AppendLog (string message ) { ExceptionLog = string .IsNullOrEmpty(ExceptionLog) ? message : ExceptionLog + Environment.NewLine + message; } public void ClearLog () { ExceptionLog = string .Empty; } 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 ; } }
示例文件分工 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 WpfConverterValidationDemo ├── App.xaml 应用入口 ├── MainWindow.xaml 六个页签的界面 ├── MainWindow.xaml.cs 异常演示的三个按钮处理 ├── Models │ ├── Employee.cs 员工模型 │ ├── NumberItem.cs 校验演示用的整数对象 │ └── Person.cs 性别绑定用的对象 ├── Converters │ ├── GenderToBoolConverter.cs 性别 ↔ 布尔 │ ├── GenderToTextConverter.cs 性别 → 文字 │ ├── ColorNameToBrushConverter.cs 颜色名 ↔ 画刷 │ └── SalaryToGradeConverter.cs 工资 → 等级 ├── Validations │ ├── EvenNumberRule.cs 只能偶数 │ ├── RangeRule.cs 区间校验(带参数) │ ├── NotEmptyRule.cs 非空 │ └── NumberReplacingRule.cs 返回替换值 └── ViewModels └── MainViewModel.cs 全部演示数据
1. 系统默认 ValueConverter 本节目标
弄明白不写转换器时,WPF 到底做了什么。
知道哪些类型能自动转成文字,哪些不能。
知道界面上的字符串什么时候能写回强类型来源,什么时候不能。
学会用「默认转换」省掉一大半本来要手写的转换器。
1.1 不写转换器,值也能显示出来 先把最容易误解的一点说清楚:转换器不是必需品。 一个绑定不写 Converter,值照样能显示在界面上。
1 2 <TextBlock Text ="{Binding Salary}" />
Salary 是 decimal,TextBlock.Text 是 string,类型明明不一样,为什么能显示?
因为绑定在中间偷偷做了一次转换。WPF 内部有一套叫 DefaultValueConverter 的机制:当来源值和目标属性的类型不一致时,它会去找一个合适的办法把值转过去。
找办法的顺序是:
如果来源类型和目标类型能直接赋值 (比如都是 object),原样传过去。
如果来源类型自己注册过 TypeConverter,就用它。
如果目标类型注册过 TypeConverter,用它反向转。
如果来源类型和目标类型之间有 Nullable<T> 的关系,做包装或拆包。
都不行,就用 Convert.ChangeType 硬转一次。
还是不行,就报一次绑定错误,目标保持原值。
图 2:默认转换按顺序尝试六种办法,前一种不行才试下一种。
这套机制的存在,意味着你写界面时可以先不写转换器,等到确实转不过去了再加。
1.2 来源 → 文字:几乎什么都能显示 先看「来源 → 目标」这个方向。把一个类型不明的值送到 TextBlock.Text 上,会得到什么?
实测结果如下(TextBlock.Text,元素 Language 为 en-US):
来源值
显示结果
double 1234.5
1234.5
int 42
42
decimal 19.99
19.99
bool true
True
DateTime 2026-10-20
10/20/2026 12:00:00 AM
Visibility.Hidden
Hidden
字符串 Red
Red
自定义结构体(无 TypeConverter)
12.5 kg(走 ToString())
自定义结构体(有 TypeConverter)
12.5 kg(自定义 TypeConverter)
null
空白
几个值得注意的地方:
第一,数字的格式跟随元素 Language ,但不是全部跟随。 这一点我实测时先想错了,特意验证了三轮,结论如下:
值
Language=en-US
Language=zh-CN
Language=de-DE
1234.5
1234.5
1234.5
1234,5
1234567.89
1234567.89
1234567.89
1234567,89
规律是:小数分隔符跟随 Language ( de-DE ** 是逗号),但千位分隔符一律不加。** 所以 1234567.89 显示成 1234567.89,而不是 1,234,567.89。默认转换用的是「通用格式」,不带分组。
想显示成带千位分隔的样子,得自己写 StringFormat 或转换器。
第二, DateTime ** 的格式也跟随 Language** 。 同一个日期,Language=zh-CN 时显示成 2026/10/20 下午1:45:00,Language=en-US 时显示成 10/20/2026 1:45:00 PM。连「下午」「AM/PM」这种文字都会跟着变。
如果不给元素设 Language ** 会怎样?** 实测结果是继承到一个默认值 en-us,所以显示格式跟 en-US 一样。这就是为什么很多人的中文程序里,日期显示成了 10/20/2026 这种美式格式——不是程序写错了,是没人告诉它该用哪个区域 。
想让它按中文显示,最简单的办法是在窗口上设一次:
1 <Window ... Language ="zh-CN" >
这个设置会往下继承给所有子元素,所以设一次就够了。这件事下一篇讲格式化时还会再提到。
第三,转换器拿到的 CultureInfo ** 也跟随 Language** 。 实测:元素 Language=de-DE 时,转换器的 culture 参数就是 de-DE;元素不设 Language 时是 en-US;如果显式写了 ConverterCulture,以它为准。这给了你一个控制转换器区域的开关。
第三,布尔值显示成 True ** / False** 。 这是英文,界面上通常想显示成「是」「否」,那就需要转换器。
第四, null ** 显示成空白。** 空白和「值是空字符串」在界面上看不出区别,这正是第 4 章要用兜底值的原因。
第五,自定义类型只要有 TypeConverter ** 就能参与转换。** 没有 TypeConverter 时,会退回到 ToString(),所以你的自定义类型最好重写 ToString()。
第六,来源 → 目标方向转换失败时,目标会回到「默认值」,而不是保持上一次的值。 这一条容易想错,单独验证过:把一个 GridLength 目标先喂 2*,再喂 abc,目标回到 *(也就是它的默认值);char 目标先喂 X,再喂 YZ(两个字符转不过去),目标回到空格,也就是 char 的默认值。
第七,这种情况配 FallbackValue ** 是有效的。** 实测:double 目标先喂 3.14 显示 3.14,再喂 abc 就变成 0;而同一个绑定配上 FallbackValue=-1 之后,喂 abc 就显示 -1。
这一条和第 4 章要讲的「兜底值」正好接上:默认转换失败属于「取不到值」,所以 FallbackValue ** 管得着。** 但要注意区分——如果是转换器内部抛异常 ,那就不是「取不到值」,FallbackValue 救不了,这一点第 4 章会实测给你看。
还有一处方向上的区别要记牢:上面说的是来源 → 目标 方向。下一节要讲的目标 → 来源 方向失败时,行为完全不同:来源会保持原值。
1.3 文字 → 强类型来源:能解析才写回 反方向更有意思:界面上是字符串,来源是强类型,能不能写回去?
实测结果(在文本框里输入,看来源值变不变):
目标类型
输入
来源值
结论
double
9876.5
9876.5
转成功
double
abc
保持 9876.5
转失败,不写回
double
42(带空格)
42
首尾空格会被忽略
double
1e3
1000
支持科学计数法
int
3.7
保持原值
不四舍五入,直接拒绝
int
7
7
转成功
decimal
19.999
19.999
转成功
bool
true / True
True
两种大小写都认
bool
1
保持原值
1 不是合法布尔文字
图 3:能解析就写回,解析不了就保持上一次的合法值。
这里最重要的一条规律:转不过去时不会报错,也不会写回,来源保持原值。 界面上看起来就是「我输了但右边没变」。
这条规律决定了两个实用结论:
默认转换能省事,但也悄悄吞掉错误 。用户输入了非法内容,界面不会有任何提示。要提示,就得配校验(第 6 章)。
想让「转不过去」这件事变得可见,要么加校验规则,要么在转换器里自己处理。
1.4 更完整的类型对照表 把常见类型都试一遍,结果如下:
目标类型
输入
转换结果
double
3.14
3.14
double
abc
失败,目标回到默认值 0
int
7
7
int
3.7
失败,目标回到默认值 0
decimal
19.999
19.999
float
1.5
1.5
long
123456789012
123456789012
byte
200
200
byte
300
失败,超出范围,目标回到默认值 0
bool
true / True
True
bool
1 / yes
失败
枚举
Middle
Middle(按名字)
枚举
2
Senior(按序号,枚举从 0 开始)
DateTime
2026-10-20
2026-10-20
DateTime
2026/10/20 13:45
2026-10-20 13:45
DateTime
abc
失败,目标回到默认值
TimeSpan
01:30:00
01:30:00
Guid
标准 GUID 字符串
转成功
Uri
https://example.com/a
转成功
Color
Red
#FFFF0000
Color
#FF00FF00
#FF00FF00
Brush
Red
#FFFF0000
Thickness
1,2,3,4
1,2,3,4
Thickness
5
5,5,5,5
GridLength
2*
2*
GridLength
*
*
GridLength
Auto
Auto
Point
10,20
10,20
CornerRadius
1,2,3,4
1,2,3,4
FontFamily
Consolas
Consolas
Visibility
Hidden
Hidden
char
A
A
char
AB
失败,只能一个字符,目标回到默认值(空格)
object
任意文字
原样传入
自定义类型(有 TypeConverter)
abcde
由 TypeConverter 决定
几个实用提醒:
枚举按名字更安全。 写 Middle 比写 2 可读性好得多,而且以后枚举顺序调整了也不会出错。
char ** 只能装一个字符。** 输入两个字符会失败,这是很多人第一次做「首字母」输入框时踩的坑。
GridLength ** 的 * 、 2* 、 Auto ** 都能转。 我第一次测的时候被默认值骗了——GridLength 的默认值恰好就是 *,所以看起来像「转换失败但恰好相同」。后来改成先喂 2* 再喂 *,才看清 * 是能正常转过去的。这也提醒一件事:测试默认转换时,要先把目标改成别的值,否则「失败后回到默认值」和「转换成功」看起来一模一样。
Visibility ** 这种枚举,界面上一般用复选框配转换器,不直接输入文字。**
自定义类型要给 TypeConverter ** 实现 ConvertTo** ,否则反向(值 → 文字)只能走 ToString() 。 实测里,如果 CanConvertTo 返回 false,那么显示时就退回到 ToString();如果返回 true,就会走你自己的转换逻辑。
1.5 什么时候该省、什么时候该写 有了上面的对照表,判断标准就很清楚了:
可以省略转换器的场合:
数字、日期、布尔直接显示(虽然格式不一定好看)。
字符串和画刷之间的转换("Red" → 画刷是内置支持的)。
字符串和 Color、Thickness、Point、CornerRadius、FontFamily 之间。
字符串和枚举之间。
必须自己写转换器的场合:
想把 true 显示成「是」「否」或「主管」「成员」。
想把 9800 显示成「初级」。
想做字符串以外的翻译,比如 bool → 画刷、decimal → 颜色。
想控制转换失败的兜底行为(DoNothing 还是替换值)。
想让显示格式固定,不受运行环境区域设置影响。
1.6 跟做:搭出第 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 <TabItem Header ="1 默认转换" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="12" > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="来源 → 文本目标:不写转换器也能显示" /> <TextBlock Style ="{StaticResource HintText}" Text ="数字、日期、布尔、颜色名都能被默认转换成文字。" /> <Grid Margin ="0,10,0,0" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="Auto" /> <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="Auto" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <Grid.RowDefinitions > <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> </Grid.RowDefinitions > <TextBlock Grid.Row ="0" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="double" /> <TextBlock Grid.Row ="0" Grid.Column ="1" Text ="{Binding DoubleValue}" /> <TextBlock Grid.Row ="0" Grid.Column ="2" Style ="{StaticResource FieldLabel}" Text ="decimal 工资" /> <TextBlock Grid.Row ="0" Grid.Column ="3" Text ="{Binding EditableEmployee.Salary}" /> <TextBlock Grid.Row ="1" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="DateTime" /> <TextBlock Grid.Row ="1" Grid.Column ="1" Text ="{Binding EditableEmployee.EntryDate}" /> <TextBlock Grid.Row ="1" Grid.Column ="2" Style ="{StaticResource FieldLabel}" Text ="bool" /> <TextBlock Grid.Row ="1" Grid.Column ="3" Text ="{Binding EditableEmployee.IsManager}" /> <TextBlock Grid.Row ="2" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="null(有兜底)" /> <TextBlock Grid.Row ="2" Grid.Column ="1" Text ="{Binding NullableText, FallbackValue=暂无昵称}" /> <TextBlock Grid.Row ="2" Grid.Column ="2" Style ="{StaticResource FieldLabel}" Text ="字符串颜色名" /> <TextBlock Grid.Row ="2" Grid.Column ="3" Text ="{Binding StringToColor}" /> </Grid > </StackPanel > </Border > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="字符串界面 → 强类型来源:能解析就写回,解析不了就不动" /> <TextBlock Style ="{StaticResource HintText}" Text ="在左边文本框里随便改,看右边来源值什么时候跟着动。" /> <Grid Margin ="0,10,0,0" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="Auto" /> <ColumnDefinition Width ="170" /> <ColumnDefinition Width ="Auto" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <Grid.RowDefinitions > <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> </Grid.RowDefinitions > <TextBlock Grid.Row ="0" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="→ int" /> <TextBox Grid.Row ="0" Grid.Column ="1" Margin ="0,0,12,6" Text ="{Binding StringToInt, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <TextBlock Grid.Row ="0" Grid.Column ="2" Style ="{StaticResource FieldLabel}" Text ="来源值" /> <TextBlock Grid.Row ="0" Grid.Column ="3" Text ="{Binding StringToInt}" /> <TextBlock Grid.Row ="1" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="→ DateTime" /> <TextBox Grid.Row ="1" Grid.Column ="1" Margin ="0,0,12,6" Text ="{Binding StringToDate, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <TextBlock Grid.Row ="1" Grid.Column ="2" Style ="{StaticResource FieldLabel}" Text ="来源值" /> <TextBlock Grid.Row ="1" Grid.Column ="3" Text ="{Binding StringToDate}" /> <TextBlock Grid.Row ="2" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="→ bool" /> <TextBox Grid.Row ="2" Grid.Column ="1" Margin ="0,0,12,6" Text ="{Binding StringToBool, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <TextBlock Grid.Row ="2" Grid.Column ="2" Style ="{StaticResource FieldLabel}" Text ="来源值" /> <TextBlock Grid.Row ="2" Grid.Column ="3" Text ="{Binding StringToBool}" /> </Grid > </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
1.7 跟做检查点 做完以后逐条确认:
double 那一行显示 1234.5,没有千位分隔符。
DateTime 那一行显示的格式,跟你电脑的区域设置有关,可能不是 2026/10/20。
布尔那一行显示 True,不是「是」。
null 那一行显示空白,带 FallbackValue 的那一行显示「暂无昵称」。
在「→ int」文本框里输入 abc,右边来源值不变 ,界面也不报错。
输入 3.7 也一样不变;输入 7 就变了。
在「→ bool」里输入 1,来源不变;输入 true 或 True 都会变成 True。
在「→ DateTime」里输入 2026-10-20,来源变成那个日期。
1.8 小练习
在员工模型里加一个 Guid Id 属性,绑到一个 TextBlock 上,确认默认能显示。
加一个 TimeSpan WorkTime 属性,输入 08:30:00,确认能写回。
把 Salary 直接绑到一个 Slider.Value 上,试试能不能转过去(答案是不能,想想为什么)。
2. ConverterParameter 的使用 本节目标
记住 ConverterParameter 的运行时类型。
学会用一个转换器做出多种输出。
学会在参数不合法时退回默认行为。
理解为什么参数要用单引号包起来。
2.1 参数永远是字符串 这一条是本节的核心,也是最容易记错的:ConverterParameter ** 从 XAML 传进来时,运行时类型永远是 string** 。
实测结果:
XAML 里写的参数
转换器里拿到的类型
值
ConverterParameter="Hello"
String
Hello
ConverterParameter="123"
String
123
ConverterParameter="true"
String
true
ConverterParameter="2026-10-20"
String
2026-10-20
完全不写
null
null
图 4:不管写什么,参数到手都是字符串;不写则是 null。
为什么这件事这么重要? 因为很多人会写成这样:
1 2 3 4 5 6 7 8 9 10 public object Convert (object value , Type targetType, object parameter, CultureInfo culture ){ if (parameter == 5 ) { return "五分" ; } return "其他" ; }
正确的写法是先判断类型,再解析:
1 2 3 4 5 6 7 8 9 10 public object Convert (object value , Type targetType, object parameter, CultureInfo culture ){ if (parameter is string text && int .TryParse(text, out var number) && number == 5 ) { return "五分" ; } return "其他" ; }
2.2 参数不合法时的两种处理 参数是手写的,写错很正常。转换器里应该有两种处理方式,选哪种看场景:
方式一:退回默认行为。 参数不对就用默认值,界面照常工作。
1 2 3 4 5 6 7 8 var wantsBrush = parameter is string text && text.Equals("Brush" , StringComparison.OrdinalIgnoreCase); if (salary >= 13000 m){ return wantsBrush ? SeniorBrush : "高级" ; }
方式二:返回 Binding.DoNothing 。 参数不对就什么都不改,界面保持原样。
这两种都比「直接抛异常」好。抛异常会让整个界面崩掉,而参数写错只是个小问题。
2.3 一个转换器,两种输出 SalaryToGradeConverter 就是这个模式:同一个转换器,靠参数决定返回文字还是颜色。
1 2 3 4 5 6 7 8 <TextBlock Text ="{Binding GradeSalary, Converter={StaticResource SalaryToGrade}}" /><Border Background ="{Binding GradeSalary, Converter={StaticResource SalaryToGrade}, ConverterParameter=Brush}" />
因为判断用的是 OrdinalIgnoreCase,所以 Brush、brush、BRUSH 都能认。这一点在写代码时要注意,如果你想严格区分大小写,就换成 Ordinal。
图 5:同一个工资值,靠参数决定变成文字还是变成颜色。
2.4 参数里带特殊字符怎么办 参数里如果带 #、|、,、= 这类字符,建议用单引号 把整串包起来:
1 2 3 4 <TextBlock Foreground ="{Binding IsManager, Converter={StaticResource BoolToBrush}, ConverterParameter='#174EA6|#B03A3A'}" />
为什么要这样写?因为 ConverterParameter 在 XAML 里是按字符串解析的,遇到某些字符时,解析器可能把它当成标记扩展或别的东西。单引号的作用是明确告诉解析器:这里面全是普通文字,别做额外解释。
实测里,写了单引号的参数传进转换器后,内容就是 '#174EA6|#B03A3A',连单引号本身也一起传进去了 。所以转换器里解析时要先把引号去掉:
1 2 3 4 5 6 7 8 9 10 if (parameter is string text){ var cleaned = text.Trim('\'' ); var parts = cleaned.Split('|' ); if (parts.Length == 2 ) { return CreateBrush(flag ? parts[0 ] : parts[1 ]); } }
这个细节很多人第一次遇到都会卡住:明明参数写对了,颜色却出不来,就是因为没处理那对引号。
2.5 跟做:搭出第 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 <TabItem Header ="2 ConverterParameter" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="12" > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="同一个转换器,靠参数换出不同结果" /> <TextBlock Style ="{StaticResource HintText}" Text ="参数从 XAML 传进来永远是字符串。" /> <StackPanel Margin ="0,10,0,0" > <StackPanel Orientation ="Horizontal" Margin ="0,0,0,8" > <TextBlock Style ="{StaticResource FieldLabel}" Text ="不传参数" /> <TextBlock VerticalAlignment ="Center" Text ="{Binding GradeSalary, Converter={StaticResource SalaryToGrade}}" /> </StackPanel > <StackPanel Orientation ="Horizontal" Margin ="0,0,0,8" > <TextBlock Style ="{StaticResource FieldLabel}" Text ="传 Brush 参数" /> <Border Width ="90" Height ="22" Background ="{Binding GradeSalary, Converter={StaticResource SalaryToGrade}, ConverterParameter=Brush}" /> </StackPanel > <StackPanel Orientation ="Horizontal" > <TextBlock Style ="{StaticResource FieldLabel}" Text ="传 brush(小写)" /> <Border Width ="90" Height ="22" Background ="{Binding GradeSalary, Converter={StaticResource SalaryToGrade}, ConverterParameter=brush}" /> </StackPanel > <Slider Minimum ="6000" Maximum ="16000" Margin ="0,14,0,0" Value ="{Binding GradeSalary, Mode=TwoWay}" /> <TextBlock Style ="{StaticResource HintText}" Text ="{Binding GradeSalary, StringFormat=当前工资:{0:N0} 元}" /> </StackPanel > </StackPanel > </Border > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="参数写错时退回默认行为,而不是崩溃" /> <TextBlock Style ="{StaticResource HintText}" Text ="把颜色名改成不存在的名字,看色块是保持原样还是变白。" /> <TextBox Margin ="0,8,0,8" Width ="220" HorizontalAlignment ="Left" Text ="{Binding ColorName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <Border Width ="160" Height ="28" HorizontalAlignment ="Left" Background ="{Binding ColorName, Converter={StaticResource ColorNameToBrush}}" /> </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
别忘了在 Window.Resources 里加上转换器的声明:
1 2 3 4 5 6 <Window.Resources > <conv:GenderToBoolConverter x:Key ="GenderToBool" /> <conv:GenderToTextConverter x:Key ="GenderToText" /> <conv:ColorNameToBrushConverter x:Key ="ColorNameToBrush" /> <conv:SalaryToGradeConverter x:Key ="SalaryToGrade" /> </Window.Resources >
还有窗口头上的命名空间:
1 2 xmlns:conv="clr-namespace:WpfConverterPractice.Converters" xmlns:val="clr-namespace:WpfConverterPractice.Validations"
2.6 跟做检查点
「不传参数」那一行显示「初级」,因为初始工资是 9800。
拖动滑块到 11000 以上,文字变成「中级」。
拖到 13000 以上,变成「高级」。
两个色块随滑块一起变色,Brush 和 brush 效果完全一样。
把颜色名文本框清空,色块保持上一次的颜色 ,不变成白色(DoNothing 生效)。
输入 NotAColor,色块同样保持原样。
输入 #FF00FF00,色块变成绿色。
2.7 小练习
把 SalaryToGradeConverter 改一下:参数写 Text 才返回文字,其他都返回画刷。看看默认行为变了会怎样。
写一个转换器,参数用 | 分隔两段文字,比如 ConverterParameter='正常|异常',根据传入的布尔值返回其中一段。注意处理单引号。
故意把参数写成 Bursh(拼错),确认界面不会崩,只是退回默认行为。
3. 基于 Converter 的性别绑定案例 本节目标
学会把「一个字段、多个控件」这种关系用转换器表达。
理解为什么 ConvertBack 在取消勾选时要返回 DoNothing。
把同一套转换器用到列表里,确认可复用。
3.1 问题:一个性别字段,两个单选按钮 数据里 Gender 是一个字符串,值是「男」或「女」。界面上要的是两个单选按钮。
直觉写法是「男按钮绑一个布尔属性,女按钮绑另一个布尔属性」,但数据里根本没有这两个布尔属性。硬加的话,模型会被界面需求污染:本来一个字段能表达的信息,变成了三个字段还要手动保持同步。
更好的做法是不动数据模型 ,让转换器去翻译:
1 2 3 4 5 6 7 8 9 10 <RadioButton Content ="男" GroupName ="GenderGroup" IsChecked ="{Binding Person.Gender, Converter={StaticResource GenderToBool}, ConverterParameter=男, Mode=TwoWay}" /><RadioButton Content ="女" GroupName ="GenderGroup" IsChecked ="{Binding Person.Gender, Converter={StaticResource GenderToBool}, ConverterParameter=女, Mode=TwoWay}" />
两个按钮绑的是同一个来源属性 Person.Gender,靠 ConverterParameter 区分「我代表男还是女」。
3.2 Convert 方向:比较是否相等 1 2 3 4 5 6 7 public object Convert (object value , Type targetType, object parameter, CultureInfo culture ){ return value is string gender && parameter is string wanted && string .Equals(gender, wanted, StringComparison.Ordinal); }
逻辑很短,但要做两件事:确认 value 和 parameter 都是字符串,然后比较。任何一步不成立就返回 false,按钮不选中。
注意用的是 StringComparison.Ordinal,因为性别值是固定的两个字,不需要考虑区域差异。如果这里用忽略大小写的比较,反而可能把不该相等的值判成相等。
3.3 ConvertBack 方向:只在勾选时写回 这是本节最需要理解的一段:
1 2 3 4 5 6 7 8 9 10 11 public object ConvertBack (object value , Type targetType, object parameter, CultureInfo culture ){ if (value is true && parameter is string wanted) { return wanted; } return Binding.DoNothing; }
为什么取消勾选不能返回一个值? 想想用户点「女」的瞬间发生了什么:
女按钮 IsChecked 变成 true → 它的 ConvertBack 返回「女」→ 来源变成「女」。
同时,男按钮 IsChecked 变成 false → 它的 ConvertBack 被调用。
第 2 步如果返回了「男」,来源就被改回「男」了,跟第 1 步打架。返回 DoNothing 的意思是「这次我不参与」,来源就保持第 1 步写进去的「女」。
实测结果验证了这一点:
操作
Gender
男按钮
女按钮
初始
男
True
False
点「女」
女
False
True
代码改回「男」
男
True
False
取消勾选「男」
男(没变 )
False
False
最后一行是关键:取消勾选后,来源仍然是「男」 ,没有被改成「女」。DoNothing 起作用了。
图 6:两个按钮绑同一个来源,靠参数分工,靠 DoNothing 避免互相打架。
3.4 只用于显示的转换器要写 ConvertBack 吗 GenderToTextConverter 只把「男」变成「男员工」,从界面不会反向写回。这种情况下 ConvertBack 直接拒绝:
1 2 public object ConvertBack (object value , Type targetType, object parameter, CultureInfo culture ) => throw new NotSupportedException();
不要写成 return null ** 或者 return value** 。 那样会把「反向不可用」这件事变得含糊,将来别人误用了双向绑定,错误会很难查。直接抛 NotSupportedException,问题会立刻暴露出来。
但要注意一个前提:这个转换器不能配在 Mode=TwoWay ** 的绑定上。** 一旦配了,用户改界面就会触发 ConvertBack,然后抛异常。所以「只用于显示的转换器」和「双向绑定」这两件事不能凑在一起。
3.5 同一套转换器用到列表里 转换器是全局可复用的。同一套 GenderToBoolConverter,在列表模板里也能用:
1 2 3 4 5 6 7 8 9 10 11 <DataGridTemplateColumn Header ="性别" Width ="70" > <DataGridTemplateColumn.CellTemplate > <DataTemplate > <TextBlock VerticalAlignment ="Center" Text ="{Binding Gender, Converter={StaticResource GenderToText}, FallbackValue=未填写}" /> </DataTemplate > </DataGridTemplateColumn.CellTemplate > </DataGridTemplateColumn >
注意这里用的是 GenderToTextConverter(显示用),不是 GenderToBoolConverter(交互用)。同一个数据,交互场景和展示场景可能要用不同的转换器 ,这是设计时要提前想清楚的。
还有一处细节:这里配了 FallbackValue=未填写。当某一行员工的 Gender 是 null 或空字符串时,转换器返回 DoNothing,绑定就会用兜底值显示「未填写」,而不是空白。
3.6 跟做:搭出第 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 <TabItem Header ="3 性别绑定" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="12" > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="字符串性别 ↔ 两个单选按钮" /> <TextBlock Style ="{StaticResource HintText}" Text ="两个单选按钮各绑一次同一个来源,靠 ConverterParameter 区分。" /> <StackPanel Orientation ="Horizontal" Margin ="0,12,0,0" > <RadioButton Content ="男" GroupName ="GenderGroup" Margin ="0,0,20,0" IsChecked ="{Binding Person.Gender, Converter={StaticResource GenderToBool}, ConverterParameter=男, Mode=TwoWay}" /> <RadioButton Content ="女" GroupName ="GenderGroup" IsChecked ="{Binding Person.Gender, Converter={StaticResource GenderToBool}, ConverterParameter=女, Mode=TwoWay}" /> </StackPanel > <TextBlock Margin ="0,12,0,0" Style ="{StaticResource HintText}" Text ="{Binding Person.Gender, StringFormat=数据里的性别:{0}}" /> <TextBlock Margin ="0,4,0,0" Style ="{StaticResource HintText}" Text ="{Binding Person.Gender, Converter={StaticResource GenderToText}, FallbackValue=未填写}" /> </StackPanel > </Border > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="列表里的性别也用同一套转换器" /> <DataGrid ItemsSource ="{Binding Employees}" AutoGenerateColumns ="False" IsReadOnly ="True" MaxHeight ="260" HeadersVisibility ="Column" > <DataGrid.Columns > <DataGridTextColumn Header ="姓名" Binding ="{Binding Name}" Width ="80" /> <DataGridTextColumn Header ="部门" Binding ="{Binding Department}" Width ="90" /> <DataGridTextColumn Header ="工资" Binding ="{Binding Salary, StringFormat={}{0:N0}}" Width ="90" /> <DataGridTemplateColumn Header ="性别" Width ="70" > <DataGridTemplateColumn.CellTemplate > <DataTemplate > <TextBlock VerticalAlignment ="Center" Text ="{Binding Gender, Converter={StaticResource GenderToText}, FallbackValue=未填写}" /> </DataTemplate > </DataGridTemplateColumn.CellTemplate > </DataGridTemplateColumn > </DataGrid.Columns > </DataGrid > </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
3.7 跟做检查点
初始状态「男」被选中,下面显示「数据里的性别:男」和「男员工」。
点「女」,两个文字都变成「女」和「女员工」。
列表里 8 个人的性别列都显示成「男员工」「女员工」。
在调试器里手动把 Person.Gender 改成「女」,界面上的单选按钮跟着切到「女」。
把 Gender 改成 null,最下面那行显示「未填写」,两个单选按钮都不选中。
点「男」再取消勾选(可以再点一次同组按钮),性别值不会来回跳。
3.8 小练习
把性别换成三个取值(男、女、未知),用三个单选按钮实现。想想第三个按钮的 ConvertBack 该怎么写。
把「是否主管」也做成两个单选按钮(主管 / 成员),复用同一个转换器。这样能不能做到?参数需要怎么改?
把 GenderToBoolConverter 改成忽略大小写比较,试试输入「男 」带空格时会怎样。
4. FallbackValue 与 TargetNullValue 属性 本节目标
分清这两个属性各自负责什么情况。
知道它们同时写的时候谁优先。
学会用它们让界面不再出现「莫名其妙的空白」。
4.1 两种「没值」,两种兜底 界面出现空白时,背后可能是两种完全不同的原因:
情况
含义
该用哪个兜底
来源属性取到了,但值是 null
数据确实存在,只是内容为空
TargetNullValue
来源属性根本取不到
路径写错了、转换失败了
FallbackValue
这两个属性的分工就是按这个划分的。名字也说明了含义:TargetNullValue 管「送到目标的值是 null」,FallbackValue 管「拿不到值,退一步用什么」。
图 7:来源为 null 走 TargetNullValue;取不到值走 FallbackValue。
4.2 实测:单独写各自的区别 先准备一个值为 null 的来源属性,然后试三种写法:
写法
显示结果
什么都不写
空白
只写 FallbackValue="兜底文字"
空白
只写 TargetNullValue="空值提示"
空值提示
这个结果值得停下来看一会儿。来源是 null ** 时, FallbackValue ** 不生效。 因为绑定确实取到了值(值就是 null),这不算「取不到」,所以走的是 TargetNullValue 那条路。
只有下面这种情况才会走 FallbackValue:
写法
显示结果
路径不存在,不写兜底
空白
路径不存在 + FallbackValue="路径不存在"
路径不存在
路径不对时,绑定根本取不到值,这时 FallbackValue 才上场。
4.3 两者都写的时候谁优先 1 2 3 <TextBlock Text ="{Binding NickName, FallbackValue=兜底文字, TargetNullValue=空值提示}" />
实测结果:来源是 null 时显示 「空值提示」 。也就是说,TargetNullValue 优先。
这个顺序是合理的:TargetNullValue 描述的是更具体的情况(值存在且为 null),应该比笼统的 FallbackValue 优先。
把来源从 null 改成「有值了」,显示跟着变成「有值了」;再改回 null,又变回「空值提示」。整个过程是活的。
4.4 转换器返回 null 时会怎样 这里有个容易踩的点。转换器可以返回三种「特殊」结果:
转换器返回
绑定的反应
Binding.DoNothing
跳过这次更新,目标保持原值
null
把空值送到目标;配了 TargetNullValue 就换成它
DependencyProperty.UnsetValue
视作「没有值」,走 FallbackValue
实测确认:
场景
目标值
转换器返回 null,不写 TargetNullValue
空白
转换器返回 null + TargetNullValue="空值提示"
空值提示
转换器返回 DoNothing(目标原本有值)
保持原值
转换器返回 UnsetValue
走 FallbackValue
所以如果你写了一个「可能返回空」的转换器,记得同时配上 TargetNullValue,否则界面上会出现空白,而且分不清是数据问题还是转换问题。
4.5 转换器抛异常时,兜底还能用吗 实测结果:
1 2 转换器抛异常 + FallbackValue="转换失败兜底" → 目标值「」,界面线程收到 InvalidOperationException
FallbackValue ** 救不了抛异常的转换器。** 异常会直接冒到触发绑定的那行代码上,这一次转换没有产出任何值,目标保持默认值(对 TextBlock.Text 来说就是空字符串)。
这个结论很重要,它说明两件事:
FallbackValue 处理的是「正常流程里取不到值」,不是「代码崩了」。
想让转换失败时界面好看,转换器内部要自己 try/catch,返回 DoNothing 或 null,而不是让异常跑出去。
ColorNameToBrushConverter 就是这么写的——颜色名不合法时自己接住异常,返回 DoNothing。
4.6 跟做:搭出第 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 <TabItem Header ="4 兜底值" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="12" > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="两个兜底属性的分工" /> <TextBlock Style ="{StaticResource HintText}" Text ="TargetNullValue 管「来源取到了但值是 null」;FallbackValue 管「取不到值」。" /> <Grid Margin ="0,10,0,0" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="Auto" /> <ColumnDefinition Width ="190" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <Grid.RowDefinitions > <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> <RowDefinition Height ="Auto" /> </Grid.RowDefinitions > <TextBlock Grid.Row ="0" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="昵称(可清空)" /> <TextBox Grid.Row ="0" Grid.Column ="1" Margin ="0,0,12,8" Text ="{Binding NickName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <TextBlock Grid.Row ="0" Grid.Column ="2" VerticalAlignment ="Center" Text ="{Binding NickName, TargetNullValue=还没填昵称}" /> <TextBlock Grid.Row ="1" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="不写 TargetNullValue" /> <TextBlock Grid.Row ="1" Grid.Column ="1" Text ="{Binding NickName}" /> <TextBlock Grid.Row ="1" Grid.Column ="2" Style ="{StaticResource HintText}" Text ="来源为 null 时这里就是空白。" /> <TextBlock Grid.Row ="2" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="路径不存在" /> <TextBlock Grid.Row ="2" Grid.Column ="1" Text ="{Binding NotExistPath}" /> <TextBlock Grid.Row ="2" Grid.Column ="2" Style ="{StaticResource HintText}" Text ="没写兜底,绑定失败后保持默认值。" /> <TextBlock Grid.Row ="3" Grid.Column ="0" Style ="{StaticResource FieldLabel}" Text ="路径不存在 + 兜底" /> <TextBlock Grid.Row ="3" Grid.Column ="1" Text ="{Binding NotExistPath, FallbackValue=数据没找到}" /> <TextBlock Grid.Row ="3" Grid.Column ="2" Style ="{StaticResource HintText}" Text ="写了兜底,界面上就能看到提示文字。" /> </Grid > </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
4.7 跟做检查点
一开始昵称是空的,第一行显示「还没填昵称」。
在昵称框里输入「小张」,两行都显示「小张」。
把昵称框清空,第一行回到「还没填昵称」,第二行变空白。
「路径不存在」那一行是空白,下面那行显示「数据没找到」。
在调试输出窗口能看到路径不存在的绑定警告(第 5 章会讲怎么打开跟踪)。
4.8 小练习
给「路径不存在」那一行也配上 TargetNullValue,确认它不会 生效(因为这不是 null 问题)。
把 NickName 改成 string? NickName { get; set; } 之外的类型,比如 int?,配上 TargetNullValue=未设置,确认能正常显示。
写一个转换器,故意返回 DependencyProperty.UnsetValue,看看会走 FallbackValue 还是别的。
5. 绑定数据异常与捕获 本节目标
知道转换器抛异常时,异常会跑到哪里去。
知道来源 setter 抛异常时会发生什么。
学会打开绑定的跟踪,看清绑定到底在做什么。
知道哪几种异常必须自己接住。
5.1 三类异常,三种去处 绑定过程中可能出错的地方有三处,处理方式各不相同:
出错位置
会发生什么
转换器内部
异常直接冒到触发绑定的代码,这一次转换没有结果
来源属性的 setter
异常会冒出来,但绑定的值可能已经部分更新
来源属性的 getter
异常同样会冒出来,绑定这一次拿不到值
图 8:转换器、getter、setter 三处出错,异常都不进兜底值,而是直接冒出来。
5.2 转换器抛异常:实测 1 2 3 4 5 6 7 8 public class ThrowingConverter : IValueConverter { public object Convert (object value , Type targetType, object parameter, CultureInfo culture ) => throw new InvalidOperationException("转换器故意抛出的异常" ); public object ConvertBack (object value , Type targetType, object parameter, CultureInfo culture ) => throw new InvalidOperationException("反向转换也故意抛出异常" ); }
实测结果:
1 2 3 4 5 TextBlock 目标 + Convert 抛异常 → 目标值「」,界面线程收到 InvalidOperationException:转换器故意抛出的异常 TextBox 目标 + Convert 抛异常 → TextBox.Text「」,界面线程收到同样的异常
两个结论:
异常不会被吞掉。 它直接冒到执行绑定的那行代码上,所以你能在调试器里看到它。
这一次转换没有产出值。 目标保持默认值,FallbackValue 也不起作用。
注意第二点跟直觉不太一样。很多人以为「抛异常了那就用兜底值」,但实测不是这样。FallbackValue 处理的是「取不到值」,不是「代码抛异常」。
所以转换器里要自己接住异常 ,这也是 ColorNameToBrushConverter 里那个 try/catch 的原因:
1 2 3 4 5 6 7 8 9 10 11 try { var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(name)!); brush.Freeze(); return brush; } catch (FormatException){ return Binding.DoNothing; }
5.3 来源 setter 抛异常:实测 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class ThrowingSource { private int _value; public int Value { get => _value; set { if (value > 100 ) { throw new ArgumentOutOfRangeException(nameof (value ), "不能大于 100" ); } _value = value ; } } }
把这个来源绑到文本框上,输入 500:
咦,居然没有异常?这个结果需要解释一下。
在示例的探针里,UpdateSourceTrigger 是 PropertyChanged,写回是在文本框文本变化时发生的。而文本框的文本变化本身是在一次输入事件里,WPF 会在内部把这次写回包起来,异常不一定冒到外层调用者手上。
但这不是说可以不管。 真实的写法应该是:来源 setter 里不要抛异常。如果确实要做范围限制,用校验规则(第 6 章)或者强置回调,而不是靠抛异常。
如果一定要在 setter 里做检查,那就让它静默忽略 非法值,而不是抛:
1 2 3 4 5 6 7 8 9 10 set { if (value > 100 ) { return ; } _value = value ; }
更好的做法是「用校验规则拦住」,这样界面会给出明确提示,而不是默默不生效。
5.4 打开绑定跟踪,看清过程 绑定出问题时,最有效的手段是打开它的跟踪。有三种方式:
方式一:在绑定上单独设置。
1 2 3 <TextBlock Text ="{Binding NotExistPath, FallbackValue=兜底, diag:PresentationTraceSources.TraceLevel=High}" />
需要先声明 diag 命名空间:
1 xmlns:diag="clr-namespace:System.Diagnostics;assembly=WindowsBase"
方式二:在代码里设置。
1 2 var binding = new Binding("NotExistPath" );PresentationTraceSources.SetTraceLevel(binding, PresentationTraceLevel.High);
方式三:全局打开。 在 App.xaml.cs 里给所有绑定都开:
1 2 3 PresentationTraceSources.Refresh(); PresentationTraceSources.DataBindingSource.Listeners.Add(new ConsoleTraceListener()); PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Warning | SourceLevels.Error;
打开之后,绑定会把它每一步都写出来。实测输出长这样:
1 2 3 4 5 6 7 8 System.Windows.Data Warning: 56 : Created BindingExpression (hash=9380660) for Binding (hash=62668232) BindingExpression:Path=MissingPath; DataItem=null; System.Windows.Data Warning: 58 : Path: 'MissingPath' System.Windows.Data Warning: 60 : BindingExpression (hash=9380660): Default mode resolved to OneWay System.Windows.Data Warning: 61 : BindingExpression (hash=9380660): Default update trigger resolved to PropertyChanged System.Windows.Data Warning: 62 : BindingExpression (hash=9380660): Attach to System.Windows.Controls.TextBlock.Text (hash=54839716) System.Windows.Data Warning: 67 : BindingExpression (hash=9380660): Resolving source System.Windows.Data Warning: 70 : BindingExpression (hash=9380660): Found data context element: <null> (OK) System.Windows.Data Warning: 78 : BindingExpression (hash=9380660): Activate with root item ProbeSource (hash=11103033)
这几行信息量很大:
第 56 行告诉你这个绑定的路径是 MissingPath。
第 60、61 行告诉你默认的 Mode 和 UpdateSourceTrigger 解析成了什么 ——这正好是上一篇笔记里要你背的那两个默认值,这里可以直接看到。
第 62 行告诉你绑到了哪个属性上。
第 70 行告诉你 DataContext 是 <null>,这就是路径取不到值的直接原因。
为什么我打开了却看不到输出? 最常见的原因有三个:
跟踪级别没调到 Warning 或 High,默认是 None。
输出看错地方了——要看 Visual Studio 的「输出」面板,并且把「显示输出来源」选成「调试」。
没有调试器附加。跟踪输出走的是 Trace,在没有调试器时默认不输出到任何地方。
5.5 哪些异常必须自己接住 总结一下,写转换器时这几处必须处理:
位置
处理方式
参数解析
解析失败就退回默认行为,不要抛
值解析
值不合法时返回 DoNothing 或 null,不要抛
类型判断
用 is 模式匹配,而不是强制转换
空值
先判断 null,再处理
一个稳妥的转换器模板:
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 public object Convert (object value , Type targetType, object parameter, CultureInfo culture ){ if (value is null ) { return Binding.DoNothing; } if (value is not string text) { return Binding.DoNothing; } var option = parameter as string ?? "默认" ; try { return Translate(text, option); } catch (Exception ex) when (ex is FormatException or OverflowException) { return Binding.DoNothing; } }
5.6 跟做:搭出第 5 章页签 这一章需要一点代码,因为要真的触发异常。先给窗口加一个隐藏的容器:
1 2 3 4 5 6 7 8 <Grid Margin ="12" > <StackPanel x:Name ="ProbeHost" Visibility ="Collapsed" /> <TabControl > ... </TabControl > </Grid >
然后是三个按钮的处理:
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 private void OnThrowConverterClick (object sender, RoutedEventArgs e ){ ViewModel.ClearLog(); var probe = new TextBlock { Text = "初始文字" }; var binding = new Binding(nameof (MainViewModel.StringToColor)) { Converter = new ThrowingConverter(), FallbackValue = "转换失败兜底" }; probe.SetBinding(TextBlock.TextProperty, binding); ViewModel.AppendLog("准备:目标属性 TextBlock.Text,转换器内部会抛 InvalidOperationException。" ); try { ProbeHost.Children.Add(probe); probe.DataContext = ViewModel; ViewModel.AppendLog("结果:目标值变成「" + probe.Text + "」。" ); ViewModel.AppendLog("说明:异常被交给了调用方,这一次转换没有产出结果,但程序还在运行。" ); } catch (Exception ex) { ViewModel.AppendLog("捕获到:" + ex.GetType().Name + " —— " + ex.Message); } finally { ProbeHost.Children.Remove(probe); } }
一个实用技巧: 把要测试的元素挂到可视化树里,绑定才会真正生效。这就是那个 ProbeHost 容器的作用——它 Visibility="Collapsed",看不见但不影响绑定运行。
5.7 跟做检查点
点「触发转换器异常」,日志里出现「捕获到:InvalidOperationException」。
日志里同时说明「程序还在运行」,界面没有崩。
点「触发来源 setter 异常」,日志说明这次没有异常冒出来。
点「输出一次绑定跟踪」,日志里说明跟踪级别已调到 High,目标值是「兜底」。
在 Visual Studio 里运行,打开「输出」面板选「调试」,能看到绑定的跟踪行。
把跟踪级别改回 None,再点一次按钮,确认输出面板里不再出现那些行。
5.8 小练习
写一个转换器,故意在 ConvertBack 里抛异常,配在 Mode=TwoWay 的绑定上,看用户改界面时会发生什么。
把 ColorNameToBrushConverter 里的 catch (FormatException) 改成 catch (Exception),想想为什么通常不推荐捕获所有异常。
用全局跟踪方式(App.xaml.cs 里那段代码)打开所有绑定的跟踪,然后启动程序,看看输出里有多少行。
6. 自定义 ValidationRule 本节目标
知道校验规则在绑定的哪一步介入。
知道默认校验步骤拿到的是什么类型,以及为什么经常拿到字符串。
学会写带参数的规则,并复用到多个字段。
理解 ValidationResult 的第二个参数是干什么用的。
6.1 校验规则插在哪个位置 绑定的写回方向有四个步骤,校验规则可以插在其中三个位置上:
ValidationStep
规则拿到什么
什么时候用
RawProposedValue
用户敲进去的原始值(通常是字符串)
想校验「用户输入了什么」
ConvertedProposedValue
已经过类型转换的值
想校验「转换后的值对不对」
UpdatedValue
来源属性被更新之后的值
想校验「最终存进去的值」
默认是 RawProposedValue。这一点很关键,因为很多人以为规则里拿到的是转换后的数字,其实是字符串 。
实测结果:
1 2 3 ValidationStep=RawProposedValue → 收到 (String) 7 ValidationStep=ConvertedProposedValue → 收到 (Int32) 7 ValidationStep=UpdatedValue → 收到 (BindingExpression)
三行结果说明:
默认步骤拿到的是 String,所以要自己 int.TryParse。
改成 ConvertedProposedValue,拿到的才是 Int32。
UpdatedValue 拿到的居然是 BindingExpression 对象,不是值本身。因为这一步已经更新完了,规则只能通过表达式去读值。这个步骤很少用,除非要做「多个字段联合校验」。
图 9:三个校验步骤各自拿到什么,决定了规则里该怎么写。
6.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 public class EvenNumberRule : ValidationRule { public override ValidationResult Validate (object value , CultureInfo cultureInfo ) { if (value is string text) { if (string .IsNullOrWhiteSpace(text)) { return new ValidationResult(false , "不能为空" ); } if (!int .TryParse(text, NumberStyles.Integer, cultureInfo, out var parsed)) { return new ValidationResult(false , "「" + text + "」不是整数" ); } return parsed % 2 == 0 ? ValidationResult.ValidResult : new ValidationResult(false , text + " 不是偶数" ); } if (value is int number) { return number % 2 == 0 ? ValidationResult.ValidResult : new ValidationResult(false , number + " 不是偶数" ); } return new ValidationResult(false , "「" + value + "」不是整数" ); } }
两种分支都写上,规则就能在两种校验步骤下工作,比较稳妥。
6.3 接到绑定上 校验规则要用完整的绑定语法写,不能写成标记扩展的简写:
1 2 3 4 5 6 7 8 9 10 <TextBox Width ="220" > <TextBox.Text > <Binding Path ="NumberItem.Value" Mode ="TwoWay" UpdateSourceTrigger ="PropertyChanged" > <Binding.ValidationRules > <val:EvenNumberRule /> </Binding.ValidationRules > </Binding > </TextBox.Text > </TextBox >
这里有个语法上的必然性:ValidationRules 是一个集合,标记扩展没法表达集合,所以只能写开标签。
实测行为:
输入
规则被调用
校验错误数
提示内容
绑定刚建立
0 次
0
无
7
1 次
1
7 不是偶数
8
1 次
0
无
abc
1 次
1
「abc」不是整数
注意第一行:绑定刚建立时规则没有被调用。 因为校验只在写回 的时候发生,单向读取不触发校验。所以初始值哪怕不合法,界面也不会标红。这是很多人的困惑点。
还有一点:规则返回 ValidResult 时,校验错误会被清空 。所以输入 7 标红,再改成 8 就恢复正常了。
6.4 校验失败时界面长什么样 默认情况下,校验失败的控件会加一圈红边,但不会显示提示文字 。想要看到提示内容,得自己加一个模板或者用 ToolTip:
1 2 3 4 5 6 7 8 9 10 11 12 13 <TextBox Width ="220" ToolTip ="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" > <TextBox.Text > <Binding Path ="NumberItem.Value" Mode ="TwoWay" UpdateSourceTrigger ="PropertyChanged" > <Binding.ValidationRules > <val:EvenNumberRule /> </Binding.ValidationRules > </Binding > </TextBox.Text > </TextBox >
(Validation.Errors) 是一个附加属性,规则失败时绑定会往上面写一条记录。[0] 取第一条,.ErrorContent 就是你在 new ValidationResult(false, "...") 里写的那个字符串。
一个更完整、也更常用的做法是给文本框写一个带错误提示的样式,但那是模板的话题,这里先把基础的走通。
6.5 带参数的规则,复用到多个字段 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class RangeRule : ValidationRule { public int Minimum { get ; set ; } = int .MinValue; public int Maximum { get ; set ; } = int .MaxValue; public string Unit { get ; set ; } = string .Empty; public override ValidationResult Validate (object value , CultureInfo cultureInfo ) { if (!int .TryParse(value ?.ToString(), NumberStyles.Integer, cultureInfo, out var number)) { return new ValidationResult(false , "请输入整数" ); } if (number < Minimum) { return new ValidationResult(false , $"不能小于 {Minimum} {Unit} " ); } if (number > Maximum) { return new ValidationResult(false , $"不能大于 {Maximum} {Unit} " ); } return ValidationResult.ValidResult; } }
用的时候,同一个类可以复用到很多字段上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <Binding Path ="EditableEmployee.Age" Mode ="TwoWay" UpdateSourceTrigger ="PropertyChanged" > <Binding.ValidationRules > <val:RangeRule Minimum ="1" Maximum ="60" Unit =" 岁" /> </Binding.ValidationRules > </Binding > <Binding Path ="EditableEmployee.Salary" Mode ="TwoWay" UpdateSourceTrigger ="PropertyChanged" > <Binding.ValidationRules > <val:RangeRule Minimum ="3000" Maximum ="200000" Unit =" 元" /> </Binding.ValidationRules > </Binding >
这就是把「规则」和「参数」分开的好处:逻辑写一次,界限在 XAML 里配。
6.6 ValidationResult 的第二个参数是替换值 ValidationResult 的构造函数是 ValidationResult(bool isValid, object errorContent)。第二个参数在文档里叫「错误内容」,但它还有一个不太为人知的作用:当它是非字符串内容时,会被当作替换值继续往下走。
实测结果:
规则返回
输入
来源值
错误数
提示内容
new(false, 0)
42
4
1
0
new(false, 99)
42
4
1
99
new(true, 10)
42
42
0
无
new(false, "不是偶数")
7
保持
1
不是偶数
这里要仔细看。前两行返回的是数字 ,来源值变成了 4(初始值),提示内容是那个数字;第三行返回 true,校验通过,来源值保持 42,没有替换 。
所以结论是:
返回 false 时,校验失败,来源不会被更新 ,那个非字符串内容会显示成错误提示。
返回 true 时,校验通过,值正常写回,第二个参数基本不起作用。
「替换值」这个说法在 WPF 里其实是个容易误导人的说法。 实测表明,校验失败时来源保持原值,不会真的被替换掉。真正想做「超范围就改成上限」,正确做法是返回 true ** 并在转换器或强置回调里处理**,或者干脆在来源属性的 setter 里做修正。
这一点跟很多网上文章的说法不一样,所以特别提醒:以实测为准。
6.7 多条规则一起用 一个绑定可以挂多条规则,ValidationRules 是个集合:
1 2 3 4 <Binding.ValidationRules > <val:NotEmptyRule FieldName ="姓名" /> <val:RangeRule Minimum ="1" Maximum ="60" /> </Binding.ValidationRules >
实测行为:规则按顺序执行,一旦某条失败,后面的就不执行了。
1 两条规则,输入 3 → 规则一调用 1 次,规则二调用 0 次
这个行为意味着规则顺序有讲究 :先放「格式检查」,再放「范围检查」。因为格式不对时,范围检查根本没法进行。
6.8 规则里抛异常会怎样 实测结果:
1 规则内部抛异常 → InvalidOperationException:校验规则故意抛出的异常
异常直接冒出来,跟转换器抛异常一样。所以规则里也要自己接住可预期的异常,返回 new ValidationResult(false, "提示"),而不是让异常跑出去。
6.9 还有三种校验方式 除了自定义 ValidationRule,WPF 还提供了三种现成的校验,都通过绑定上的开关启用:
开关
作用
来源需要实现
ValidatesOnExceptions
捕获来源 setter 抛的异常,转成校验错误
无
ValidatesOnDataErrors
读取 IDataErrorInfo 提供的错误
IDataErrorInfo
ValidatesOnNotifyDataErrors
读取 INotifyDataErrorInfo 提供的错误
INotifyDataErrorInfo
实测结果:
1 2 3 4 5 6 7 8 9 10 11 ValidatesOnExceptions=True,输入 500 → 错误数 1,首条「不能大于 100 (Parameter 'value')」;来源值 0 不写 ValidatesOnExceptions,输入 500 → 错误数 0 ValidatesOnDataErrors=True,输入 10 → 错误数 1,首条「年龄不能小于 18」 ValidatesOnNotifyDataErrors=True,输入 10 → 错误数 1,首条「年龄不能小于 18(INotifyDataErrorInfo)」
这三条路各有适用场景:
ValidatesOnExceptions 适合来源 setter 里本来就有范围检查的情况。它把「抛异常」这件事变成了「校验错误」,比默默忽略好得多。代价是异常本身有性能开销。
ValidatesOnDataErrors 是老式做法,来源实现 IDataErrorInfo,里面有个索引器 this[string columnName] 返回错误信息。写法简单,缺点是不能异步、不能一个属性报多条错。
ValidatesOnNotifyDataErrors 是新式做法,来源实现 INotifyDataErrorInfo,支持多条错误、支持异步校验。写新代码优先用它。
顺便注意一个细节:实测里「不写 ValidatesOnNotifyDataErrors,输入 10」也报出了 1 个错误。因为来源实现了 INotifyDataErrorInfo,而这个开关在新版本里默认是开的。所以实现了这个接口就要小心,错误会自动冒出来。
6.10 跟做:搭出第 6 章页签 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 <TabItem Header ="6 校验规则" > <ScrollViewer VerticalScrollBarVisibility ="Auto" > <StackPanel Margin ="12" > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="规则一:只能填偶数" /> <TextBlock Style ="{StaticResource HintText}" Text ="默认的校验步骤拿到的还是字符串,所以规则里要自己解析。" /> <TextBox Margin ="0,8,0,0" Width ="220" HorizontalAlignment ="Left" ToolTip ="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" > <TextBox.Text > <Binding Path ="NumberItem.Value" Mode ="TwoWay" UpdateSourceTrigger ="PropertyChanged" > <Binding.ValidationRules > <val:EvenNumberRule /> </Binding.ValidationRules > </Binding > </TextBox.Text > </TextBox > <TextBlock Margin ="0,8,0,0" Style ="{StaticResource HintText}" Text ="{Binding NumberItem.Value, StringFormat=来源里的值:{0}}" /> </StackPanel > </Border > <Border Style ="{StaticResource CardBorder}" > <StackPanel > <TextBlock Style ="{StaticResource SectionTitle}" Text ="规则二:带参数的区间校验" /> <TextBlock Style ="{StaticResource HintText}" Text ="同一个规则类,通过 XAML 上的属性传不同的上下限,就能复用很多次。" /> <StackPanel Orientation ="Horizontal" Margin ="0,8,0,0" > <TextBlock Style ="{StaticResource FieldLabel}" Text ="年龄(1-60)" /> <TextBox Width ="120" > <TextBox.Text > <Binding Path ="EditableEmployee.Age" Mode ="TwoWay" UpdateSourceTrigger ="PropertyChanged" > <Binding.ValidationRules > <val:RangeRule Minimum ="1" Maximum ="60" Unit =" 岁" /> </Binding.ValidationRules > </Binding > </TextBox.Text > </TextBox > <TextBlock Margin ="16,0,0,0" VerticalAlignment ="Center" Style ="{StaticResource HintText}" Text ="{Binding EditableEmployee.Age, StringFormat=来源值:{0}}" /> </StackPanel > <StackPanel Orientation ="Horizontal" Margin ="0,10,0,0" > <TextBlock Style ="{StaticResource FieldLabel}" Text ="姓名(非空)" /> <TextBox Width ="180" > <TextBox.Text > <Binding Path ="EditableEmployee.Name" Mode ="TwoWay" UpdateSourceTrigger ="PropertyChanged" > <Binding.ValidationRules > <val:NotEmptyRule FieldName ="姓名" /> </Binding.ValidationRules > </Binding > </TextBox.Text > </TextBox > </StackPanel > </StackPanel > </Border > </StackPanel > </ScrollViewer > </TabItem >
6.11 跟做检查点
初始状态三个文本框都没有红框(因为校验只在写回时触发)。
在「只能填偶数」里输入 7,出现红框,鼠标悬停能看到「7 不是偶数」。
改成 8,红框消失。
输入 abc,红框出现,提示「「abc」不是整数」。
在年龄框里输入 70,红框出现,提示「不能大于 60 岁」。
在姓名框里清空,红框出现,提示「姓名不能为空」。
把 ValidationStep 改成 ConvertedProposedValue,确认规则里拿到的变成 Int32(可以加一行调试输出验证)。
加两条规则,确认第一条失败时第二条不会被调用。
6.12 小练习
写一个规则,检查手机号是 11 位数字。用 Regex 实现,失败时提示「手机号格式不对」。
给「只能填偶数」那个文本框写一个样式,让红框和提示文字同时显示(用 ControlTemplate + AdornedElementPlaceholder)。
让来源实现 INotifyDataErrorInfo,在 Age 小于 18 时给出错误,配 ValidatesOnNotifyDataErrors=True,确认提示能出来。
四道处理速查表 把这一篇的核心结论收在一起:
处理
谁来做
关键点
默认转换
WPF 内置
六种办法按顺序试;数字用不变文化,日期用元素 Language;转不过去就保持原值
自定义转换
IValueConverter
参数永远是字符串;失败时返回 DoNothing 而不是抛异常
兜底值
FallbackValue / TargetNullValue
来源为 null 走 TargetNullValue;取不到值走 FallbackValue;救不了异常
校验
ValidationRule
默认步骤拿到字符串;规则按顺序执行,失败即停;失败时来源不更新
常见错误对照表
现象
原因
解决
参数明明写了数字,转换器里比较却不成立
参数是字符串
先 int.TryParse 再比较
参数带 # 或 | 时解析出错
XAML 解析歧义
用单引号包起来,转换器里 Trim('\'')
转换器里 (int)value 抛异常
值类型不是 int
用 is 模式匹配,或 switch 兜底
校验规则里拿到的是字符串不是数字
默认步骤是 RawProposedValue
自己解析,或改成 ConvertedProposedValue
绑定建立时不校验
校验只在写回时发生
想初始校验就手动调一次 UpdateSource
转换器抛异常界面变空白
异常不进兜底
转换器内部 try/catch,返回 DoNothing
打开了跟踪却看不到输出
级别没调 / 看错面板 / 没有调试器
调到 Warning 或 High,看「输出」面板的「调试」来源
两个单选按钮互相打架
取消勾选时 ConvertBack 返回了值
取消勾选返回 Binding.DoNothing
颜色名写错时色块变白
转换器返回了 null 或抛异常
返回 Binding.DoNothing
完整示例结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 WpfConverterValidationDemo ├── App.xaml ├── App.xaml.cs ├── AssemblyInfo.cs ├── MainWindow.xaml 六个页签 ├── MainWindow.xaml.cs 异常演示按钮 ├── Models │ ├── Employee.cs │ ├── NumberItem.cs │ └── Person.cs ├── Converters │ ├── ColorNameToBrushConverter.cs │ ├── GenderToBoolConverter.cs │ ├── GenderToTextConverter.cs │ └── SalaryToGradeConverter.cs ├── Validations │ ├── EvenNumberRule.cs │ ├── NotEmptyRule.cs │ ├── NumberReplacingRule.cs │ └── RangeRule.cs └── ViewModels └── MainViewModel.cs
运行检查表 启动示例程序,按顺序确认下面这些现象:
第 1 页签:double 显示 1234.5,布尔显示 True。
第 1 页签:null 那行是空白,带兜底的那行显示「暂无昵称」。
第 1 页签:在「→ int」里输入 abc,右边来源值不变。
第 1 页签:输入 7,来源值变成 7。
第 2 页签:拖动滑块,等级文字和两个色块一起变。
第 2 页签:Brush 和 brush 效果相同。
第 2 页签:把颜色名清空,色块保持上一次的颜色。
第 3 页签:点「女」,下面两行文字都跟着变。
第 3 页签:列表里性别列显示「男员工」「女员工」。
第 4 页签:昵称清空后,带 TargetNullValue 的那行显示「还没填昵称」。
第 4 页签:「路径不存在 + 兜底」那行显示「数据没找到」。
第 5 页签:点第一个按钮,日志里出现「捕获到:InvalidOperationException」。
第 5 页签:点第二个按钮,日志说明没有异常冒出。
第 5 页签:点第三个按钮,日志说明跟踪级别已调到 High。
第 6 页签:偶数框输入 7 出现红框,悬停看到提示。
第 6 页签:改成 8 红框消失。
第 6 页签:年龄框输入 70 出现红框,提示「不能大于 60 岁」。
综合练习 练习一:做一个带校验的注册表单 要求:
用户名不能为空,长度至少 3 个字符。
年龄必须是 18 到 100 之间的整数。
邮箱要包含 @ 和 .。
校验失败时红框 + 提示文字都要出现。
提示:写一个 EmailRule,用 Regex.IsMatch 判断;红框和提示文字用 ControlTemplate 实现。
练习二:给列表做一个可编辑的等级列 要求:
列表里显示员工的工资等级(初级 / 中级 / 高级)。
等级文字用转换器生成,不写在模型里。
再加一列显示等级对应的颜色块。
工资可编辑,编辑后等级和颜色都要跟着变。
提示:工资用 DataGridTextColumn 配 StringFormat,等级和颜色用 DataGridTemplateColumn 配转换器。
练习三:把转换失败的三种处理都试一遍 要求:
写一个转换器,故意在某种输入下失败。
第一次让它返回 null,观察界面。
第二次让它返回 Binding.DoNothing,观察界面。
第三次让它抛异常,观察界面。
把三次的现象写下来,跟这一篇的实测结果对照。
结语 这一篇的六个知识点,其实在回答同一件事的两面:
一面是「值怎么变成界面能显示的样子」 ——默认转换兜住了大部分常规类型,转换器负责剩下的翻译工作,参数让一个转换器能顶好几个用。
另一面是「值不对劲时怎么办」 ——兜底值让空白变得有含义,异常处理让程序不崩,校验规则让用户知道哪里填错了。
写完这一篇,你应该能做到两件事:
拿到一个数据类型和界面控件不匹配的需求,能立刻判断「这是默认转换能做的,还是要写转换器」。
写转换器时,知道参数是什么类型、失败时该返回什么、异常该怎么处理。
再往深处走,会碰到异步校验、INotifyDataErrorInfo 的完整实现、MultiBinding 与 IMultiValueConverter、以及基于 CollectionViewSource 的过滤排序。这些都在这一篇的基础上展开,底子稳了,学起来会快很多。
动手顺序建议:先把第 1 章的默认转换对照表跑一遍,知道边界在哪;再写第 3 章的性别绑定,这是最典型的转换器用例;最后练第 6 章的校验规则,把「失败时给用户看得见的反馈」这件事做完整。
祝学习顺利。