3.3 转换器与输入校验
学习目标
本节完成一个带即时校验的任务标题表单。你将知道转换器适合处理什么,如何向 WPF 报告属性错误,以及为什么后端熟悉的 DataAnnotations 特性不会仅凭存在就自动触发客户端校验。
转换与校验是两种职责
绑定源和目标的类型不同时,可以用 IValueConverter 转换。例如源是布尔值,目标是 Visibility,转换器把“有错误”转换成“显示提示”。Convert 负责源到目标,ConvertBack 负责目标到源。格式化数字或日期可先考虑绑定的 StringFormat,简单的布尔显示也可以使用内置 BooleanToVisibilityConverter;无需为每一种文字都创建转换器。
转换器应尽量没有副作用:不要在转换期间发 HTTP 请求、弹窗或修改其他对象。它服务于展示,不能承担整个业务工作流。如果反向输入无法转换,可返回 DependencyProperty.UnsetValue 表示无法提供值;Binding.DoNothing 则表示本次不继续更新。它们的含义不同,不应当作任意默认值使用。
校验则回答“这个值是否允许继续操作”。WPF 可以使用 ValidationRule、异常校验、IDataErrorInfo 或 INotifyDataErrorInfo。本节选最后一个:它支持同一属性多个错误,也能在异步校验完成后报告错误变化。这里仍使用同步规则,先掌握通知契约。
完整的原生校验对象
沿用 WpfBasics 项目。新增下面两个完整 C# 文件,并替换后面的窗口文件;不安装 MVVM 工具包。TaskForm 报告标题错误,同时通知 HasErrors,因为错误提示的可见性会绑定这个属性。
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace WpfBasics;
public sealed class TaskForm : INotifyPropertyChanged, INotifyDataErrorInfo
{
private string _title = string.Empty;
private readonly List<string> _titleErrors = new();
public TaskForm() => ValidateTitle();
public string Title
{
get => _title;
set
{
if (_title == value) return;
_title = value;
OnPropertyChanged();
ValidateTitle();
}
}
public bool HasErrors => _titleErrors.Count > 0;
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
public IEnumerable GetErrors(string? propertyName)
{
return propertyName == nameof(Title)
? _titleErrors.ToArray()
: Array.Empty<string>();
}
private void ValidateTitle()
{
_titleErrors.Clear();
string normalized = Title.Trim();
if (normalized.Length < 3)
_titleErrors.Add("标题至少需要 3 个字符。");
if (normalized.Length > 50)
_titleErrors.Add("标题不能超过 50 个字符。");
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Title)));
OnPropertyChanged(nameof(HasErrors));
}
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace WpfBasics;
public sealed class ErrorVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is true ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
转换器只做展示,因此窗口使用单向绑定,ConvertBack 明确不回传。Collapsed 不仅不显示,还不占布局空间;Hidden 则保留空间。你也可以把这个自写转换器替换为 WPF 内置的 BooleanToVisibilityConverter,结果一致。
把错误连接到输入框
<Window x:Class="WpfBasics.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfBasics"
Title="标题校验" Width="620" Height="320">
<Window.Resources>
<local:ErrorVisibilityConverter x:Key="ErrorVisibility" />
</Window.Resources>
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="任务标题(3~50 个字符)" Margin="0,0,0,8" />
<TextBox x:Name="TitleInput" Grid.Row="1"
Text="{Binding Title, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
<TextBlock Grid.Row="2" Margin="0,8,0,16" Foreground="#B42318"
Text="请输入 3~50 个字符,不能只输入空格。"
Visibility="{Binding HasErrors, Mode=OneWay, Converter={StaticResource ErrorVisibility}}" />
<Button Grid.Row="3" Content="保存草稿" Padding="16,6"
HorizontalAlignment="Left" Click="OnSaveClick" />
</Grid>
</Window>
using System.Windows;
namespace WpfBasics;
public partial class MainWindow : Window
{
private readonly TaskForm _form = new();
public MainWindow()
{
InitializeComponent();
DataContext = _form;
}
private void OnSaveClick(object sender, RoutedEventArgs e)
{
if (_form.HasErrors)
{
TitleInput.Focus();
return;
}
MessageBox.Show(this, $"有效草稿:{_form.Title.Trim()}", "校验通过");
}
}
ErrorsChanged 提醒 WPF 重新调用 GetErrors。这里仅有 Title 属性错误,其他属性名和对象级错误查询都返回空列表。绑定设置 ValidatesOnNotifyDataErrors 后,错误会进入控件的 Validation.Errors,默认控件样式通常用红色边框表达错误。本例用统一文案显示提示,完整错误列表仍由对象提供;多字段表单可进一步通过错误模板或列表显示具体错误,见样式与资源。
PropertyChanged 和 ErrorsChanged 是两条不同通知:前者让 HasErrors 的展示刷新,后者让绑定校验状态刷新。只触发其中一条,可能造成提示和边框不同步。示例在 setter 中先保存输入,再校验它,所以对象会暂时持有无效草稿;保存动作必须显式检查错误。
与后端校验的关系
给属性写上 [Required] 或 [StringLength],不会自动让原生 WPF 绑定执行 DataAnnotations 校验。若采用这些特性,需要主动调用 Validator.TryValidateProperty 等 API,再把结果转成 WPF 认识的错误通知;某些工具包会封装这层工作,但必须理解它的行为。
客户端校验用于及时反馈,服务端仍需校验请求并执行授权。标题格式正确并不表示调用者有权限修改任务。若进行异步唯一性检查,应处理取消和过期结果,避免较早请求的结果覆盖较新输入;不要把远程请求塞进转换器。
常见错误
- 红框要等离开输入框才出现:检查
TextBox.Text的更新时机,本例显式设置即时写回。 - 错误已经清除,提示还在:确认错误列表更新后触发
ErrorsChanged,并对HasErrors发属性通知。 - 只依赖红色边框:提供清楚的文字说明和键盘操作路径,让用户知道如何修复输入。
- 转换器遇到错误返回随意的 0 或空字符串:这可能把无效输入伪装成有效值;根据绑定契约报告无法转换,并提供校验提示。
动手练习与验收
把标题最短长度改为 5,同步修改错误文案和窗口标签。测试空值、纯空格、4 个字符、5 个字符和 51 个字符,再尝试保存。
验收标准:无效标题显示提示并阻止保存;5~50 个有效字符可以保存;从无效值改为有效值后提示立即消失;程序没有引用任何 MVVM 包。你应能说明转换器、属性通知、错误通知和服务端校验各负责什么。