WPF TextBox输入限制实战:从数字校验到正则表达式的高级玩法

在WPF应用开发中,TextBox作为最基础的用户输入控件,其输入验证的健壮性直接决定了用户体验的质量。很多开发者都曾遇到过这样的场景:一个看似简单的“只能输入数字”的需求,在实际实现时却要面对输入法切换、粘贴操作、右键菜单、程序赋值等一系列边界情况。更复杂的是,当业务需求扩展到金额格式、手机号码、身份证号等特定格式时,简单的字符过滤已经无法满足要求。

这篇文章将带你深入WPF输入验证的核心机制,超越基础教程的范畴,重点探讨如何利用正则表达式构建灵活、可复用的输入限制方案。我们将从事件机制的差异分析入手,逐步构建一个能够应对真实业务场景的验证框架,最终封装成易于维护的附加属性。无论你是正在处理金融系统的金额输入,还是需要为CRM系统设计电话号码验证,这里提供的思路和代码都能直接应用到你的项目中。

1. 理解WPF输入事件的三层架构

要真正掌握TextBox的输入限制,首先需要理解WPF处理用户输入的三个关键事件:PreviewTextInput、PreviewKeyDown和TextChanged。这三个事件并非简单的替代关系,而是构成了一个完整的事件处理链条,各自负责不同的验证阶段。

1.1 PreviewTextInput:字符级别的实时拦截

PreviewTextInput事件在用户通过任何输入方式(键盘、手写、语音等)向控件输入文本时触发。这个事件的特点是它处理的是即将输入的文本内容,而不是已经显示在控件中的文本。

private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
    // e.Text包含用户本次输入的字符
    // 例如:用户输入"5",e.Text就是"5"
    
    // 使用正则表达式验证单个字符
    Regex digitRegex = new Regex(@"^[0-9]$");
    e.Handled = !digitRegex.IsMatch(e.Text);
}

这里有一个关键细节:e.Handled = true表示事件已被处理,系统将不再继续传递该输入。这种机制让我们能够在字符进入TextBox之前就将其拦截。

注意:PreviewTextInput事件对于输入法(IME)的支持需要特别处理。当用户使用中文输入法时,输入的拼音并不会触发此事件,只有最终选定的汉字才会触发。这意味着如果你需要完全禁用非数字输入,还需要配合其他机制。

1.2 PreviewKeyDown:键盘操作的底层控制

与PreviewTextInput关注文本内容不同,PreviewKeyDown关注的是物理按键。这在某些场景下非常有用,比如需要区分数字键盘和主键盘区的数字键,或者需要处理功能键(如Delete、Backspace)。

private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
    // 允许的功能键
    var allowedKeys = new[] { 
        Key.Back, Key.Delete, Key.Left, Key.Right, 
        Key.Home, Key.End, Key.Tab 
    };
    
    // 允许的数字键(主键盘区和数字键盘区)
    bool isDigit = (e.Key >= Key.D0 && e.Key <= Key.D9) ||
                   (e.Key >= Key.NumPad0 && e.Key <= Key.NumPad9);
    
    // 允许小数点(注意不同键盘布局的差异)
    bool isDecimalPoint = e.Key == Key.OemPeriod || e.Key == Key.Decimal;
    
    // 允许Ctrl组合键(复制、粘贴等)
    bool isCtrlCombination = (e.KeyboardDevice.Modifiers & ModifierKeys.Control) != 0;
    
    e.Handled = !(isDigit || isDecimalPoint || 
                  allowedKeys.Contains(e.Key) || isCtrlCombination);
}

在实际项目中,我经常发现开发者忽略了对功能键的处理。想象一下用户无法使用退格键删除错误输入时的挫败感——这种细节决定了一个功能是否真正可用。

1.3 TextChanged:最终文本的完整性验证

前两个事件都是在输入过程中进行拦截,而TextChanged事件则在文本已经改变后触发。这看起来有些矛盾:既然已经阻止了非法输入,为什么还需要在改变后验证?

考虑以下场景:

  1. 通过代码直接设置TextBox.Text属性
  2. 通过数据绑定更新文本框内容
  3. 用户使用鼠标右键菜单的粘贴功能(某些情况下可能绕过键盘事件)
  4. 从其他应用程序拖放文本到TextBox
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
    TextBox textBox = sender as TextBox;
    
    // 验证完整的文本是否符合格式要求
    Regex amountRegex = new Regex(@"^\d+(\.\d{0,2})?$");
    
    if (!amountRegex.IsMatch(textBox.Text))
    {
        // 标记验证错误
        Validation.SetError(textBox, "请输入有效的金额格式(最多两位小数)");
        
        // 或者自动修正:移除非法字符
        string cleanedText = new string(
            textBox.Text.Where(c => char.IsDigit(c) || c == '.').ToArray()
        );
        
        // 避免递归触发TextChanged事件
        textBox.TextChanged -= TextBox_TextChanged;
        textBox.Text = cleanedText;
        textBox.TextChanged += TextBox_TextChanged;
    }
}

这三个事件构成了一个完整的防御体系:PreviewKeyDown处理物理按键,PreviewTextInput处理字符输入,TextChanged作为最终的安全网。在实际应用中,我建议根据具体需求选择合适的组合:

验证需求推荐使用的事件理由
简单字符过滤PreviewTextInput实现简单,能处理大多数输入场景
需要特殊按键控制PreviewKeyDown + PreviewTextInput如需要禁用空格键但允许Tab键
格式验证(如金额)PreviewTextInput + TextChanged字符过滤+完整格式验证
最高安全性要求所有三个事件应对各种边界情况

2. 正则表达式的实战应用模式

正则表达式是文本验证的瑞士军刀,但在WPF输入限制中,它的使用方式需要根据具体场景进行调整。很多人直接套用网上的正则表达式,却忽略了WPF事件机制的特殊性。

2.1 单字符验证 vs 完整文本验证

这是最容易混淆的概念。在PreviewTextInput事件中,我们验证的是单个输入字符,而在TextChanged事件中,我们验证的是完整的文本内容。

// 单字符验证:只允许数字和小数点
private void ValidateSingleCharacter(object sender, TextCompositionEventArgs e)
{
    // 这个正则匹配单个字符:数字或小数点
    Regex singleCharRegex = new Regex(@"^[0-9.]$");
    e.Handled = !singleCharRegex.IsMatch(e.Text);
}

// 完整文本验证:金额格式(最多两位小数)
private void ValidateCompleteText(object sender, TextChangedEventArgs e)
{
    TextBox textBox = sender as TextBox;
    
    // 这个正则匹配完整文本:整数部分 + 可选的小数部分(最多两位)
    Regex completeTextRegex = new Regex(@"^\d+(\.\d{0,2})?$");
    
    if (!completeTextRegex.IsMatch(textBox.Text))
    {
        // 处理验证失败
        ShowValidationError(textBox, "格式错误:最多两位小数");
    }
}

我曾经在一个金融项目中遇到过这样的问题:开发者在PreviewTextInput中使用了完整的金额正则表达式,结果用户无法输入小数点,因为单独的小数点字符"."不符合^\d+(\.\d{0,2})?$这个模式。正确的做法是在PreviewTextInput中只进行字符级过滤,在TextChanged中进行格式验证。

2.2 常见业务场景的正则表达式

不同的业务场景需要不同的验证策略。下面是一些我在实际项目中积累的验证模式:

手机号码验证(中国)

// 简单的手机号格式验证
Regex phoneRegex = new Regex(@"^1[3-9]\d{9}$");

// 更宽松的验证:允许带空格或连字符
Regex phoneWithSeparators = new Regex(@"^1[3-9]\d[\s\-]?\d{4}[\s\-]?\d{4}$");

// 在PreviewTextInput中的使用:允许数字、空格、连字符
private void PhoneNumber_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
    Regex allowedChars = new Regex(@"^[0-9\s\-]$");
    e.Handled = !allowedChars.IsMatch(e.Text);
}

身份证号码验证

// 简单的15位或18位身份证验证
Regex idCardRegex = new Regex(@"^(\d{15}|\d{17}[\dXx])$");

// 在TextChanged中的完整验证
private void ValidateIdCard(object sender, TextChangedEventArgs e)
{
    TextBox textBox = sender as TextBox;
    string input = textBox.Text.Replace(" ", ""); // 移除空格
    
    if (idCardRegex.IsMatch(input))
    {
        // 还可以进行更详细的验证,如校验位计算
        if (input.Length == 18 && !ValidateIdCardCheckDigit(input))
        {
            ShowValidationError(textBox, "身份证校验位错误");
        }
    }
    else if (!string.IsNullOrEmpty(input))
    {
        ShowValidationError(textBox, "请输入15位或18位身份证号码");
    }
}

金额输入的特殊处理 金额输入有几个特殊需求:

  1. 只能有一个小数点
  2. 小数点后最多两位
  3. 不能以0开头(除非是0本身或0.xx)
public class AmountInputValidator
{
    private string previousText = "";
    
    public void ValidateAmountInput(TextBox textBox, TextChangedEventArgs e)
    {
        string currentText = textBox.Text;
        
        // 空文本是有效的
        if (string.IsNullOrEmpty(currentText))
        {
            previousText = currentText;
            return;
        }
        
        // 检查是否只包含数字和小数点
        if (!Regex.IsMatch(currentText, @"^[0-9.]*$"))
        {
            textBox.Text = previousText;
            textBox.CaretIndex = Math.Min(previousText.Length, textBox.CaretIndex);
            return;
        }
        
        // 检查小数点的数量
        int dotCount = currentText.Count(c => c == '.');
        if (dotCount > 1)
        {
            textBox.Text = previousText;
            textBox.CaretIndex = Math.Min(previousText.Length, textBox.CaretIndex);
            return;
        }
        
        // 如果有小数点,检查小数位数
        if (dotCount == 1)
        {
            int dotIndex = currentText.IndexOf('.');
            string decimalPart = currentText.Substring(dotIndex + 1);
            if (decimalPart.Length > 2)
            {
                textBox.Text = previousText;
                textBox.CaretIndex = Math.Min(previousText.Length, textBox.CaretIndex);
                return;
            }
        }
        
        // 检查是否以0开头(排除0.xx的情况)
        if (currentText.Length > 1 && currentText[0] == '0' && currentText[1] != '.')
        {
            textBox.Text = previousText;
            textBox.CaretIndex = Math.Min(previousText.Length, textBox.CaretIndex);
            return;
        }
        
        previousText = currentText;
    }
}

这个金额验证器展示了如何在TextChanged事件中实现复杂的业务逻辑。它维护了一个previousText状态,在用户输入非法内容时能够回退到上一个有效状态。

2.3 正则表达式的性能优化

在频繁触发的事件(如PreviewTextInput)中使用正则表达式时,性能是需要考虑的因素。每次事件触发都创建新的Regex对象会产生不必要的开销。

public static class RegexCache
{
    private static readonly ConcurrentDictionary<string, Regex> _cache = 
        new ConcurrentDictionary<string, Regex>();
    
    public static Regex GetOrCreate(string pattern, RegexOptions options = RegexOptions.None)
    {
        string key = $"{pattern}|{options}";
        
        return _cache.GetOrAdd(key, k => 
        {
            // 对于频繁使用的模式,使用Compiled选项提高性能
            if (pattern.Length < 50) // 简单的模式才编译
            {
                return new Regex(pattern, options | RegexOptions.Compiled);
            }
            return new Regex(pattern, options);
        });
    }
}

// 使用缓存的正则表达式
private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
    // 从缓存获取或创建正则表达式
    Regex digitRegex = RegexCache.GetOrCreate(@"^[0-9]$");
    e.Handled = !digitRegex.IsMatch(e.Text);
}

对于简单的字符验证,其实有更高效的方式:

// 替代方案:使用char方法,性能更好
private void ValidateDigitChar(object sender, TextCompositionEventArgs e)
{
    // 检查每个字符是否为数字
    foreach (char c in e.Text)
    {
        if (!char.IsDigit(c))
        {
            e.Handled = true;
            return;
        }
    }
    e.Handled = false;
}

选择哪种方式取决于具体需求:如果需要复杂的模式匹配,使用正则表达式;如果只是简单的字符分类,使用char类的方法性能更好。

3. 处理边界情况和用户体验细节

输入验证不仅仅是技术实现,更是用户体验设计。很多看似微小的细节,实际上对用户满意度有巨大影响。

3.1 粘贴操作的处理

用户习惯使用Ctrl+V粘贴内容,但粘贴操作可能绕过PreviewTextInput事件。WPF提供了专门处理粘贴的机制:

public static void SetupPasteHandling(TextBox textBox, Func<string, bool> validationFunc)
{
    // 添加粘贴处理器
    DataObject.AddPastingHandler(textBox, (sender, e) =>
    {
        if (e.DataObject.GetDataPresent(DataFormats.Text))
        {
            string pasteText = e.DataObject.GetData(DataFormats.Text) as string;
            
            if (!string.IsNullOrEmpty(pasteText))
            {
                // 验证粘贴内容
                if (!validationFunc(pasteText))
                {
                    e.CancelCommand(); // 阻止粘贴
                    
                    // 可选:给用户反馈
                    SystemSounds.Beep.Play();
                    
                    // 或者显示提示
                    ShowTooltip(textBox, "粘贴内容包含非法字符");
                }
            }
        }
    });
}

// 使用示例:只允许粘贴数字
SetupPasteHandling(myTextBox, text =>
{
    return text.All(char.IsDigit);
});

更友好的做法是允许粘贴,但自动过滤掉非法字符:

DataObject.AddPastingHandler(textBox, (sender, e) =>
{
    if (e.DataObject.GetDataPresent(DataFormats.Text))
    {
        string originalText = e.DataObject.GetData(DataFormats.Text) as string;
        
        if (!string.IsNullOrEmpty(originalText))
        {
            // 过滤非法字符
            string filteredText = new string(
                originalText.Where(c => char.IsDigit(c) || c == '.').ToArray()
            );
            
            if (filteredText != originalText)
            {
                // 用过滤后的文本替换粘贴内容
                e.DataObject.SetData(DataFormats.Text, filteredText);
                
                // 可选:通知用户
                if (!string.IsNullOrEmpty(filteredText))
                {
                    ShowNotification($"已过滤非法字符,粘贴内容:{filteredText}");
                }
            }
        }
    }
});

3.2 输入法(IME)的兼容性

对于中文、日文等需要使用输入法的语言,直接禁用输入法可能不是最佳选择。更好的做法是允许输入法,但在最终提交时验证:

// 不禁用输入法,但在TextChanged中验证
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
    TextBox textBox = sender as TextBox;
    
    // 检查是否正在使用输入法
    if (InputMethod.Current.ImeState != ImeState.Off)
    {
        // 输入法激活时,放宽验证或提供实时提示
        UpdateImeStatusIndicator(textBox, "输入法激活中...");
        return;
    }
    
    // 输入法关闭时,进行严格验证
    if (!ValidateFinalText(textBox.Text))
    {
        MarkAsInvalid(textBox);
    }
}

对于必须禁用输入法的场景(如纯数字输入),除了设置InputMethod.IsInputMethodEnabled="False",还需要处理用户可能通过系统快捷键切换输入法的情况:

// 监听输入法状态变化
InputMethod.Current.ImeStateChanged += (s, e) =>
{
    if (myTextBox.IsFocused && InputMethod.Current.ImeState != ImeState.Off)
    {
        // 强制关闭输入法
        InputMethod.Current.ImeState = ImeState.Off;
        
        // 给用户提示
        ShowTemporaryMessage("此输入框不支持输入法,已切换为英文模式");
    }
};

3.3 光标位置和选择文本的处理

当用户在TextBox中已有文本的中间插入字符,或者选择了部分文本后输入时,验证逻辑需要特别处理:

private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
    TextBox textBox = sender as TextBox;
    
    // 模拟输入后的完整文本
    string currentText = textBox.Text;
    int selectionStart = textBox.SelectionStart;
    int selectionLength = textBox.SelectionLength;
    
    // 移除选中的文本
    string textBeforeSelection = currentText.Substring(0, selectionStart);
    string textAfterSelection = currentText.Substring(selectionStart + selectionLength);
    
    // 插入新输入的文本
    string newText = textBeforeSelection + e.Text + textAfterSelection;
    
    // 验证新文本
    Regex validationRegex = new Regex(@"^\d+(\.\d{0,2})?$");
    e.Handled = !validationRegex.IsMatch(newText);
}

这个实现考虑了文本选择的情况,确保验证的是最终文本,而不仅仅是新输入的字符。

3.4 提供实时反馈和错误提示

良好的验证体验应该给用户即时、清晰的反馈。WPF提供了多种方式:

使用Validation.ErrorTemplate

<Style TargetType="TextBox" x:Key="ValidatedTextBoxStyle">
    <Setter Property="Validation.ErrorTemplate">
        <Setter.Value>
            <ControlTemplate>
                <DockPanel>
                    <Border BorderBrush="Red" BorderThickness="1">
                        <AdornedElementPlaceholder/>
                    </Border>
                    <TextBlock Foreground="Red" Margin="5,0,0,0"
                               Text="{Binding [0].ErrorContent, RelativeSource={RelativeSource Self}}"/>
                </DockPanel>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

实时提示(ToolTip)

private void UpdateValidationTooltip(TextBox textBox, bool isValid, string message)
{
    if (!isValid)
    {
        ToolTipService.SetToolTip(textBox, message);
        ToolTipService.SetInitialShowDelay(textBox, 0);
        ToolTipService.SetShowDuration(textBox, 3000);
        
        // 立即显示ToolTip
        if (textBox.ToolTip != null)
        {
            ToolTip toolTip = textBox.ToolTip as ToolTip;
            if (toolTip != null && !toolTip.IsOpen)
            {
                toolTip.PlacementTarget = textBox;
                toolTip.Placement = PlacementMode.Bottom;
                toolTip.IsOpen = true;
                
                // 3秒后自动关闭
                var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
                timer.Tick += (s, e) => { toolTip.IsOpen = false; timer.Stop(); };
                timer.Start();
            }
        }
    }
    else
    {
        ToolTipService.SetToolTip(textBox, null);
    }
}

视觉反馈(颜色变化)

private void UpdateVisualState(TextBox textBox, bool isValid)
{
    if (!isValid)
    {
        textBox.Background = new SolidColorBrush(Color.FromArgb(30, 255, 0, 0)); // 浅红色背景
        textBox.BorderBrush = Brushes.Red;
    }
    else
    {
        textBox.ClearValue(TextBox.BackgroundProperty);
        textBox.ClearValue(TextBox.BorderBrushProperty);
    }
}

4. 构建可复用的附加属性框架

当项目中需要多个不同验证规则的TextBox时,为每个控件单独编写事件处理代码会导致大量重复。通过附加属性(Attached Property)封装验证逻辑,可以极大提高代码的复用性和可维护性。

4.1 基础附加属性设计

让我们从最简单的“只允许数字”开始,逐步构建一个完整的验证框架:

public static class TextBoxValidation
{
    #region AllowOnlyNumbers 附加属性
    
    public static bool GetAllowOnlyNumbers(DependencyObject obj)
    {
        return (bool)obj.GetValue(AllowOnlyNumbersProperty);
    }
    
    public static void SetAllowOnlyNumbers(DependencyObject obj, bool value)
    {
        obj.SetValue(AllowOnlyNumbersProperty, value);
    }
    
    public static readonly DependencyProperty AllowOnlyNumbersProperty =
        DependencyProperty.RegisterAttached(
            "AllowOnlyNumbers",
            typeof(bool),
            typeof(TextBoxValidation),
            new PropertyMetadata(false, OnAllowOnlyNumbersChanged));
    
    private static void OnAllowOnlyNumbersChanged(
        DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is TextBox textBox)
        {
            // 禁用输入法,确保只能输入英文数字
            InputMethod.SetIsInputMethodEnabled(textBox, false);
            
            // 移除右键菜单,防止粘贴
            textBox.ContextMenu = null;
            
            bool newValue = (bool)e.NewValue;
            bool oldValue = (bool)e.OldValue;
            
            if (newValue && !oldValue)
            {
                // 附加事件处理器
                textBox.PreviewTextInput += OnNumberOnlyPreviewTextInput;
                textBox.PreviewKeyDown += OnNumberOnlyPreviewKeyDown;
                
                // 处理粘贴
                DataObject.AddPastingHandler(textBox, OnNumberOnlyPasting);
                
                // 处理TextChanged(用于程序赋值的情况)
                textBox.TextChanged += OnNumberOnlyTextChanged;
            }
            else if (!newValue && oldValue)
            {
                // 移除事件处理器
                textBox.PreviewTextInput -= OnNumberOnlyPreviewTextInput;
                textBox.PreviewKeyDown -= OnNumberOnlyPreviewKeyDown;
                DataObject.RemovePastingHandler(textBox, OnNumberOnlyPasting);
                textBox.TextChanged -= OnNumberOnlyTextChanged;
            }
        }
    }
    
    private static void OnNumberOnlyPreviewTextInput(
        object sender, TextCompositionEventArgs e)
    {
        // 验证每个字符是否为数字
        e.Handled = e.Text.Any(c => !char.IsDigit(c));
    }
    
    private static void OnNumberOnlyPreviewKeyDown(
        object sender, KeyEventArgs e)
    {
        // 允许数字键、功能键、导航键
        bool isDigit = (e.Key >= Key.D0 && e.Key <= Key.D9) ||
                      (e.Key >= Key.NumPad0 && e.Key <= Key.NumPad9);
        
        bool isFunctionKey = e.Key == Key.Back || e.Key == Key.Delete ||
                           e.Key == Key.Left || e.Key == Key.Right ||
                           e.Key == Key.Home || e.Key == Key.End ||
                           e.Key == Key.Tab || e.Key == Key.Enter;
        
        // 允许Ctrl+A(全选)、Ctrl+C(复制)、Ctrl+X(剪切)
        bool isCtrlCombination = (e.KeyboardDevice.Modifiers & ModifierKeys.Control) != 0;
        
        e.Handled = !(isDigit || isFunctionKey || isCtrlCombination);
    }
    
    private static void OnNumberOnlyPasting(
        object sender, DataObjectPastingEventArgs e)
    {
        if (e.DataObject.GetDataPresent(DataFormats.Text))
        {
            string text = e.DataObject.GetData(DataFormats.Text) as string;
            if (!string.IsNullOrEmpty(text) && text.Any(c => !char.IsDigit(c)))
            {
                e.CancelCommand();
            }
        }
    }
    
    private static void OnNumberOnlyTextChanged(
        object sender, TextChangedEventArgs e)
    {
        TextBox textBox = sender as TextBox;
        
        // 移除所有非数字字符
        string newText = new string(
            textBox.Text.Where(char.IsDigit).ToArray()
        );
        
        if (newText != textBox.Text)
        {
            // 避免递归触发TextChanged
            textBox.TextChanged -= OnNumberOnlyTextChanged;
            textBox.Text = newText;
            textBox.CaretIndex = newText.Length;
            textBox.TextChanged += OnNumberOnlyTextChanged;
        }
    }
    
    #endregion
}

使用这个附加属性非常简单:

<TextBox local:TextBoxValidation.AllowOnlyNumbers="True"
         Width="200" Height="30"/>

4.2 支持正则表达式的通用验证属性

为了让框架更加灵活,我们可以添加对正则表达式的支持:

public static class TextBoxValidation
{
    #region ValidationRegex 附加属性
    
    public static string GetValidationRegex(DependencyObject obj)
    {
        return (string)obj.GetValue(ValidationRegexProperty);
    }
    
    public static void SetValidationRegex(DependencyObject obj, string value)
    {
        obj.SetValue(ValidationRegexProperty, value);
    }
    
    public static readonly DependencyProperty ValidationRegexProperty =
        DependencyProperty.RegisterAttached(
            "ValidationRegex",
            typeof(string),
            typeof(TextBoxValidation),
            new PropertyMetadata(null, OnValidationRegexChanged));
    
    private static void OnValidationRegexChanged(
        DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is TextBox textBox)
        {
            string newPattern = e.NewValue as string;
            string oldPattern = e.OldValue as string;
            
            // 清理旧的验证逻辑
            if (!string.IsNullOrEmpty(oldPattern))
            {
                DetachValidationHandlers(textBox);
            }
            
            // 设置新的验证逻辑
            if (!string.IsNullOrEmpty(newPattern))
            {
                AttachValidationHandlers(textBox, newPattern);
            }
        }
    }
    
    private static void AttachValidationHandlers(TextBox textBox, string pattern)
    {
        // 存储验证模式,供事件处理器使用
        textBox.SetValue(ValidationRegexPatternProperty, pattern);
        
        // 附加事件处理器
        textBox.PreviewTextInput += OnRegexPreviewTextInput;
        textBox.PreviewKeyDown += OnRegexPreviewKeyDown;
        DataObject.AddPastingHandler(textBox, OnRegexPasting);
        textBox.TextChanged += OnRegexTextChanged;
        
        // 初始验证
        ValidateTextWithRegex(textBox, textBox.Text, pattern);
    }
    
    private static readonly DependencyProperty ValidationRegexPatternProperty =
        DependencyProperty.RegisterAttached(
            "ValidationRegexPattern",
            typeof(string),
            typeof(TextBoxValidation),
            new PropertyMetadata(null));
    
    private static void OnRegexPreviewTextInput(
        object sender, TextCompositionEventArgs e)
    {
        TextBox textBox = sender as TextBox;
        string pattern = textBox.GetValue(ValidationRegexPatternProperty) as string;
        
        if (!string.IsNullOrEmpty(pattern))
        {
            // 模拟输入后的文本
            string currentText = textBox.Text;
            int caretIndex = textBox.CaretIndex;
            
            // 处理文本选择
            string textBeforeCaret = currentText.Substring(0, caretIndex);
            string textAfterCaret = currentText.Substring(caretIndex + textBox.SelectionLength);
            
            string newText = textBeforeCaret + e.Text + textAfterCaret;
            
            // 使用正则验证
            try
            {
                Regex regex = new Regex(pattern);
                e.Handled = !regex.IsMatch(newText);
            }
            catch (ArgumentException)
            {
                // 正则表达式无效,不阻止输入
                e.Handled = false;
            }
        }
    }
    
    private static void ValidateTextWithRegex(TextBox textBox, string text, string pattern)
    {
        if (string.IsNullOrEmpty(pattern)) return;
        
        try
        {
            Regex regex = new Regex(pattern);
            bool isValid = string.IsNullOrEmpty(text) || regex.IsMatch(text);
            
            // 更新验证状态
            Validation.SetError(textBox, isValid ? null : "输入格式不正确");
            
            // 视觉反馈
            UpdateValidationVisual(textBox, isValid);
        }
        catch (ArgumentException ex)
        {
            // 记录日志或显示错误
            Debug.WriteLine($"正则表达式错误: {ex.Message}");
        }
    }
    
    #endregion
    
    #region 预定义验证类型
    
    public enum ValidationType
    {
        None,
        Integer,           // 整数
        Decimal,           // 小数
        PhoneNumber,       // 手机号
        Email,             // 电子邮件
        Custom             // 自定义正则
    }
    
    public static ValidationType GetValidationType(DependencyObject obj)
    {
        return (ValidationType)obj.GetValue(ValidationTypeProperty);
    }
    
    public static void SetValidationType(DependencyObject obj, ValidationType value)
    {
        obj.SetValue(ValidationTypeProperty, value);
    }
    
    public static readonly DependencyProperty ValidationTypeProperty =
        DependencyProperty.RegisterAttached(
            "ValidationType",
            typeof(ValidationType),
            typeof(TextBoxValidation),
            new PropertyMetadata(ValidationType.None, OnValidationTypeChanged));
    
    private static void OnValidationTypeChanged(
        DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is TextBox textBox)
        {
            ValidationType type = (ValidationType)e.NewValue;
            
            // 根据类型设置对应的正则表达式
            string pattern = GetPatternForValidationType(type);
            SetValidationRegex(textBox, pattern);
            
            // 根据类型设置其他属性
            switch (type)
            {
                case ValidationType.Integer:
                case ValidationType.Decimal:
                    InputMethod.SetIsInputMethodEnabled(textBox, false);
                    textBox.ContextMenu = null;
                    break;
                    
                case ValidationType.Email:
                    // 电子邮件允许输入法
                    InputMethod.SetIsInputMethodEnabled(textBox, true);
                    break;
            }
        }
    }
    
    private static string GetPatternForValidationType(ValidationType type)
    {
        return type switch
        {
            ValidationType.Integer => @"^-?\d+$",
            ValidationType.Decimal => @"^-?\d+(\.\d{0,2})?$",
            ValidationType.PhoneNumber => @"^1[3-9]\d{9}$",
            ValidationType.Email => @"^[^@\s]+@[^@\s]+\.[^@\s]+$",
            _ => null
        };
    }
    
    #endregion
}

现在我们可以通过多种方式使用验证:

<!-- 方式1:直接使用预定义类型 -->
<TextBox local:TextBoxValidation.ValidationType="Integer"
         Width="200" Height="30"/>

<!-- 方式2:使用自定义正则表达式 -->
<TextBox local:TextBoxValidation.ValidationRegex="^[A-Za-z0-9_]+$"
         Width="200" Height="30"/>

<!-- 方式3:金额输入(两位小数) -->
<TextBox local:TextBoxValidation.ValidationRegex="^\d+(\.\d{0,2})?$"
         Width="200" Height="30"/>

4.3 添加验证状态和错误信息

一个完整的验证框架还需要提供验证状态和错误信息:

public static class TextBoxValidation
{
    #region 验证状态和错误信息
    
    public static bool GetIsValid(DependencyObject obj)
    {
        return (bool)obj.GetValue(IsValidProperty);
    }
    
    private static void SetIsValid(DependencyObject obj, bool value)
    {
        obj.SetValue(IsValidPropertyKey, value);
    }
    
    private static readonly DependencyPropertyKey IsValidPropertyKey =
        DependencyProperty.RegisterAttachedReadOnly(
            "IsValid",
            typeof(bool),
            typeof(TextBoxValidation),
            new PropertyMetadata(true));
    
    public static readonly DependencyProperty IsValidProperty =
        IsValidPropertyKey.DependencyProperty;
    
    public static string GetErrorMessage(DependencyObject obj)
    {
        return (string)obj.GetValue(ErrorMessageProperty);
    }
    
    private static void SetErrorMessage(DependencyObject obj, string value)
    {
        obj.SetValue(ErrorMessagePropertyKey, value);
    }
    
    private static readonly DependencyPropertyKey ErrorMessagePropertyKey =
        DependencyProperty.RegisterAttachedReadOnly(
            "ErrorMessage",
            typeof(string),
            typeof(TextBoxValidation),
            new PropertyMetadata(null));
    
    public static readonly DependencyProperty ErrorMessageProperty =
        ErrorMessagePropertyKey.DependencyProperty;
    
    private static void UpdateValidationVisual(TextBox textBox, bool isValid, string message = null)
    {
        SetIsValid(textBox, isValid);
        SetErrorMessage(textBox, isValid ? null : message);
        
        // 更新WPF内置的验证系统
        if (!isValid)
        {
            Validation.SetError(textBox, new ValidationError(
                new RegexValidationRule(),
                textBox.GetBindingExpression(TextBox.TextProperty),
                message ?? "输入验证失败",
                null
            ));
        }
        else
        {
            Validation.ClearInvalid(textBox.GetBindingExpression(TextBox.TextProperty));
        }
        
        // 触发属性变更通知,方便MVVM绑定
        var binding = textBox.GetBindingExpression(IsValidProperty);
        binding?.UpdateTarget();
    }
    
    #endregion
    
    #region 验证规则(用于MVVM数据验证)
    
    public class RegexValidationRule : ValidationRule
    {
        public string Pattern { get; set; }
        
        public override ValidationResult Validate(object value, CultureInfo cultureInfo)
        {
            string input = value as string;
            
            if (string.IsNullOrEmpty(Pattern) || string.IsNullOrEmpty(input))
            {
                return ValidationResult.ValidResult;
            }
            
            try
            {
                if (Regex.IsMatch(input, Pattern))
                {
                    return ValidationResult.ValidResult;
                }
                else
                {
                    return new ValidationResult(false, 
                        $"输入不符合格式要求。预期格式: {GetPatternDescription(Pattern)}");
                }
            }
            catch (ArgumentException ex)
            {
                return new ValidationResult(false, 
                    $"验证规则配置错误: {ex.Message}");
            }
        }
        
        private string GetPatternDescription(string pattern)
        {
            // 将常见的正则模式转换为用户友好的描述
            return pattern switch
            {
                @"^-?\d+$" => "整数",
                @"^-?\d+(\.\d{0,2})?$" => "数字(最多两位小数)",
                @"^1[3-9]\d{9}$" => "手机号码",
                @"^[^@\s]+@[^@\s]+\.[^@\s]+$" => "电子邮件地址",
                _ => "自定义格式"
            };
        }
    }
    
    #endregion
}

4.4 完整的使用示例

最后,让我们看一个完整的示例,展示如何在真实项目中使用这个验证框架:

<Window x:Class="WpfApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfApp.Validation"
        Title="用户信息录入" Height="450" Width="400">
    
    <Window.Resources>
        <Style TargetType="TextBox" x:Key="ValidatedTextBox">
            <Setter Property="Margin" Value="5"/>
            <Setter Property="Padding" Value="3"/>
            <Style.Triggers>
                <Trigger Property="local:TextBoxValidation.IsValid" Value="False">
                    <Setter Property="BorderBrush" Value="Red"/>
                    <Setter Property="BorderThickness" Value="2"/>
                    <Setter Property="ToolTip" 
                            Value="{Binding RelativeSource={RelativeSource Self}, 
                                           Path=(local:TextBoxValidation.ErrorMessage)}"/>
                </Trigger>
            </Style.Triggers>
        </Style>
    </Window.Resources>
    
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        
        <!-- 姓名(无限制) -->
        <TextBlock Text="姓名:" Grid.Row="0" Grid.Column="0"
                   VerticalAlignment="Center" Margin="0,0,10,0"/>
        <TextBox Grid.Row="0" Grid.Column="1"
                 Style="{StaticResource ValidatedTextBox}"/>
        
        <!-- 年龄(只允许整数) -->
        <TextBlock Text="年龄:" Grid.Row="1" Grid.Column="0"
                   VerticalAlignment="Center" Margin="0,0,10,0"/>
        <TextBox Grid.Row="1" Grid.Column="1"
                 Style="{StaticResource ValidatedTextBox}"
                 local:TextBoxValidation.ValidationType="Integer"/>
        
        <!-- 手机号 -->
        <TextBlock Text="手机号:" Grid.Row="2" Grid.Column="0"
                   VerticalAlignment="Center" Margin="0,0,10,0"/>
        <TextBox Grid.Row="2" Grid.Column="1"
                 Style="{StaticResource ValidatedTextBox}"
                 local:TextBoxValidation.ValidationType="PhoneNumber"/>
        
        <!-- 邮箱 -->
        <TextBlock Text="邮箱:" Grid.Row="3" Grid.Column="0"
                   VerticalAlignment="Center" Margin="0,0,10,0"/>
        <TextBox Grid.Row="3" Grid.Column="1"
                 Style="{StaticResource ValidatedTextBox}"
                 local:TextBoxValidation.ValidationType="Email"/>
        
        <!-- 金额(自定义正则) -->
        <TextBlock Text="金额:" Grid.Row="4" Grid.Column="0"
                   VerticalAlignment="Center" Margin="0,0,10,0"/>
        <TextBox Grid.Row="4" Grid.Column="1"
                 Style="{StaticResource ValidatedTextBox}"
                 local:TextBoxValidation.ValidationRegex="^\d+(\.\d{0,2})?$"/>
        
        <!-- 提交按钮(根据验证状态启用) -->
        <Button Grid.Row="5" Grid.Column="0" Grid.ColumnSpan="2"
                Content="提交" HorizontalAlignment="Center" 
                Margin="0,20,0,0" Padding="20,5">
            <Button.Style>
                <Style TargetType="Button">
                    <Setter Property="IsEnabled" Value="False"/>
                    <Style.Triggers>
                        <MultiDataTrigger>
                            <MultiDataTrigger.Conditions>
                                <!-- 检查所有TextBox的验证状态 -->
                                <Condition Binding="{Binding ElementName=ageBox, 
                                                    Path=(local:TextBoxValidation.IsValid)}" 
                                           Value="True"/>
                                <Condition Binding="{Binding ElementName=phoneBox, 
                                                    Path=(local:TextBoxValidation.IsValid)}" 
                                           Value="True"/>
                                <Condition Binding="{Binding ElementName=emailBox, 
                                                    Path=(local:TextBoxValidation.IsValid)}" 
                                           Value="True"/>
                            </MultiDataTrigger.Conditions>
                            <Setter Property="IsEnabled" Value="True"/>
                        </MultiDataTrigger>
                    </Style.Triggers>
                </Style>
            </Button.Style>
        </Button>
    </Grid>
</Window>

这个完整的示例展示了如何将验证框架应用到实际界面中。通过附加属性,我们可以为每个TextBox指定验证规则,样式触发器会自动根据验证状态更新视觉反馈,提交按钮的状态也会根据所有输入框的验证结果自动更新。

在实际项目中,我通常会将这种验证框架与MVVM模式结合使用,通过绑定将验证状态同步到ViewModel中。这样既保持了UI的响应性,又确保了业务逻辑的清晰分离。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐