Search code examples
c#wpfxamltextbox

How to pass through NotifyDataErrorInfo to a custom TextBox user control?


The goal

I am practicing how to use the TextBox with different Type bound to the Text property.

There are a lot of threads out there covering this issue. However, I am missing some steps and will appreciate some support.

  • input values must be in a specific range
  • only positive integers are allowed
  • space, backspace and delete keystrokes must be handled to prevent a System.Windows.Data Error

The setting

  • Wpf application with .Net 8
  • MVVM pattern; Dependency Injection
  • CommunityToolkit.Mvvm
  • a public repository on GitHub

The View contains three TextBox controls which are bound to the same property of the ViewModel. The CommunityToolkit.Mvvm is used to reduce boilerplate.

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(SaveDataButtonCommand))]
    [NotifyCanExecuteChangedFor(nameof(DiscardButtonCommand))]
    [NotifyDataErrorInfo]
    [Range(9, 999, ErrorMessage = "Value is out of range")]
    private int _intValue = 0;
    private int _backupIntValue = 0;
  • Integer TextBox is a TextBox with standard behavior
  • CustomTextBox is a TextBox where event handlers are added in HomeView.xaml.cs
  • Custom IntegerTextBox is a custom user control named IntegerTextBox.xaml where event handlers are added and a DependencyProperty named Value is registered.
                <!-- Integer components -->
                <Label Grid.Row="2" Grid.Column="1" Content="Integer TextBox" />
                <TextBox Grid.Row="3" Grid.Column="1"
                         MinWidth="200"
                         Text="{Binding IntValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
                <Label Grid.Row="2" Grid.Column="3" Content="Custom TextBox" />
                <TextBox x:Name="CustomTextBox" 
                         Grid.Row="3" Grid.Column="3"
                         HorizontalContentAlignment="Right"
                         MinWidth="200"
                         Text="{Binding IntValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
                <Label Grid.Row="2" Grid.Column="5" Content="Custom IntegerTextBox" />
                <customControls:IntegerTextBox Grid.Row="3" Grid.Column="5"
                         MinWidth="200"
                         Value="{Binding IntValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />

enter image description here

The steps to accomplish the goal

Within a ResourceDictionary the behavior of all TextBox controls is modified to remove the "red box" if a validation error was found and to show the validation error provided in the ViewModel.

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Style TargetType="TextBox">
        <!-- Sets basic look of the TextBox -->
        <Setter Property="Padding" Value="2 1" />
        <Setter Property="BorderBrush" Value="LightGray" />
        <Setter Property="BorderThickness" Value="1" />

        <!-- Removes the red border around the TextBox, if validation found errors -->
        <Setter Property="Validation.ErrorTemplate">
            <Setter.Value>
                <ControlTemplate>
                    <AdornedElementPlaceholder />
                </ControlTemplate>
            </Setter.Value>
        </Setter>      
        
        <!-- Enables the UI to show the error messages -->
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate>
                    <StackPanel>
                        <Border Padding="{TemplateBinding Padding}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="3">
                            <ScrollViewer x:Name="PART_ContentHost" />
                        </Border>
                        <ItemsControl ItemsSource="{TemplateBinding Validation.Errors}" Margin="0 5 0 5">
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <TextBlock Foreground="Red" FontStyle="Italic" Text="{Binding ErrorContent}" />
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>
                    </StackPanel>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

Additionally some events are added to validate the input while entering data into the TextBox control. The threads out there showed two solutions I like most. One is using Regex and the other is using int.TryParse(). To catch input like space, backspace and delete the event handlers are handling those.

    [GeneratedRegex("[^0-9]+")]
    private static partial Regex IntegerPositiveValuesOnlyRegex();

    // one option
    private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
    {
        e.Handled = IntegerPositiveValuesOnlyRegex().IsMatch(e.Text);
    }

    // another option
    private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
    {
        e.Handled = !int.TryParse((sender as TextBox)!.Text,
            NumberStyles.Integer,
            CultureInfo.InvariantCulture,
            out int validInteger);
    }

The problems

Visualization of validation errors

  1. the standard TextBox is working as intended (no red box; red error message beneath the control)
  2. the Custom TextBox is working as intended (no red box; red error message beneath the control)
  3. the Custom IntegerTextBox is showing the red box and not displaying the error message

Catching input

  1. the standard TextBox is throwing a lot of exceptions and accepts all keys and values (however, that is the expected result)
  2. the Custom TextBox is allowing integers only; no comma, no decimal point, no -; but pressing space, backspace or delete throws exceptions
  3. the Custom IntegerTextBox is working as intended (no exceptions at all; only accepting positive integer values)

That is where I do need your help!

I am missing something, why the TextBox controls are acting differently. At least the Custom IntegerTextBox I would like to work as intended.

enter image description here

Unabridged code

The HomeView.xaml

<UserControl x:Class="SampleTextBoxValidation.Views.Screens.HomeView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:SampleTextBoxValidation.Views.Screens" 
             xmlns:viewModels="clr-namespace:SampleTextBoxValidation.ViewModels" 
             xmlns:customControls="clr-namespace:SampleTextBoxValidation.Views.CustomControls"
             mc:Ignorable="d" 
             d:DataContext="{d:DesignInstance Type=viewModels:HomeViewModel}"
             d:DesignHeight="600" d:DesignWidth="800">

    <Border CornerRadius="10" BorderThickness="1" BorderBrush="Black" Background="MintCream" Padding="5">
        <DockPanel>
            <!-- *** The UI's title *** -->
            <Label DockPanel.Dock="Top" HorizontalAlignment="Center"
               Content="Home Screen" 
               Margin="0 0 0 20"
               FontSize="14" FontWeight="Bold">
            </Label>

            <!-- *** The UI's footer *** -->
            <Label DockPanel.Dock="Bottom" HorizontalAlignment="Center"
               Content="Testing CommunityToolkit.Mvvm => ObservableValidator on several user controls." 
               Margin="0 20 0 0"
               FontSize="10" FontWeight="Bold">
            </Label>

            <!-- Button components -->
            <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Center">
                <Button  
                        Margin="15 20 15 5"
                        Command="{Binding DiscardButtonCommand}"
                        Content="Discard" />
                <Button 
                        Margin="15 20 15 5"
                        Command="{Binding LoadDataButtonCommand}"
                        Content="Load Data" />
                <Button 
                        Margin="15 20 15 5"
                        Command="{Binding SaveDataButtonCommand}"
                        Content="Save Data" />
            </StackPanel>

            <!-- *** The UI's body *** -->
            <Grid DockPanel.Dock="Top" HorizontalAlignment="Center">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="*"/>
                </Grid.RowDefinitions>

                <!-- A column to seperate the user components on the left and right -->
                <Rectangle Grid.Column="2" Grid.Row="0" Grid.RowSpan="10" MinWidth=" 20" />
                <Rectangle Grid.Column="4" Grid.Row="0" Grid.RowSpan="10" MinWidth=" 20" />

                <!-- FirstName components -->
                <Label Grid.Row="0" Grid.Column="1" Content="First Name" />
                <TextBox Grid.Row="1" Grid.Column="1"
                         MinWidth="200"
                         Text="{Binding FirstName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
                <!-- LastName components -->
                <Label Grid.Row="0" Grid.Column="3" Content="Last Name" />
                <TextBox Grid.Row="1" Grid.Column="3"
                         MinWidth="200"
                         Text="{Binding LastName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />

                <!-- Integer components -->
                <Label Grid.Row="2" Grid.Column="1" Content="Integer TextBox" />
                <TextBox Grid.Row="3" Grid.Column="1"
                         MinWidth="200"
                         Text="{Binding IntValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
                <Label Grid.Row="2" Grid.Column="3" Content="Custom TextBox" />
                <TextBox x:Name="CustomTextBox" 
                         Grid.Row="3" Grid.Column="3"
                         HorizontalContentAlignment="Right"
                         MinWidth="200"
                         Text="{Binding IntValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
                <Label Grid.Row="2" Grid.Column="5" Content="Custom IntegerTextBox" />
                <customControls:IntegerTextBox Grid.Row="3" Grid.Column="5"
                         MinWidth="200"
                         Value="{Binding IntValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />

                <!-- Decimal components -->
                <Label Grid.Row="4" Grid.Column="1" Content="Decimal StringFormat {0:C}" />
                <TextBox Grid.Row="5" Grid.Column="1"
                         MinWidth="200"
                         Text="{Binding DecimalValue, StringFormat={}{0:C}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
                <Label Grid.Row="4" Grid.Column="3" Content="Decimal As String" />
                <TextBox Grid.Row="5" Grid.Column="3"
                         MinWidth="200"
                         Text="{Binding DecimalValueAsString, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />

                <!-- Double components -->
                <Label Grid.Row="6" Grid.Column="1" Content="Double StringFormat {0:F2}" />
                <TextBox Grid.Row="7" Grid.Column="1"
                         MinWidth="200"
                         Text="{Binding DoubleValue, StringFormat={}{0:F2}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
                <Label Grid.Row="6" Grid.Column="3" Content="Double As String" />
                <TextBox Grid.Row="7" Grid.Column="3"
                         MinWidth="200"
                         Text="{Binding DoubleValueAsString, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />

            </Grid>
        </DockPanel>

    </Border>
</UserControl>

The HomeView.xaml.cs

using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace SampleTextBoxValidation.Views.Screens;

public partial class HomeView : UserControl
{
    [GeneratedRegex("[^0-9]+")]
    private static partial Regex IntegerPositiveValuesOnlyRegex();

    public HomeView()
    {
        InitializeComponent();

        CustomTextBox.GotFocus += TextBox_GotFocus;
        CustomTextBox.PreviewTextInput += TextBox_PreviewTextInput;
        CustomTextBox.PreviewKeyUp += TextBox_PreviewKeyDown;
        CustomTextBox.TextChanged += TextBox_TextChanged;
    }

    private void TextBox_GotFocus(object sender, RoutedEventArgs e)
    {
        Debug.WriteLine($"Entered {nameof(TextBox_GotFocus)}");

        (sender as TextBox)!.SelectAll();
    }

    private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
    {
        Debug.WriteLine($"Entered {nameof(TextBox_PreviewTextInput)}");

        e.Handled = IntegerPositiveValuesOnlyRegex().IsMatch(e.Text);
    }

    private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
    {
        Debug.WriteLine($"Entered {nameof(TextBox_PreviewKeyDown)}");

        if (e.Key == Key.Space)
        {
            e.Handled = true;
        }
        else if (e.Key == Key.Back)
        {
            if ((sender as TextBox)!.Text.Length == 1)
            {
                (sender as TextBox)!.SelectAll();
                e.Handled = true;
            }
        }
        else if (e.Key == Key.Delete)
        {
            if ((sender as TextBox)!.Text.Length == 1)
            {
                (sender as TextBox)!.SelectAll();
                e.Handled = true;
            }
        }
    }

    private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
    {
        Debug.WriteLine($"Entered {nameof(TextBox_TextChanged)}");

        bool valid = int.TryParse((sender as TextBox)!.Text,
            NumberStyles.Integer,
            CultureInfo.InvariantCulture,
            out int validInteger);

        if (valid)
        {
            CustomTextBox.Text = validInteger.ToString();
        }
        else
        {
            CustomTextBox.Text = 999.ToString(); // For testing, only, to show there was a problem.
        }
    }

}

The IntegerTextBox.xaml

<UserControl x:Class="SampleTextBoxValidation.Views.CustomControls.IntegerTextBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:SampleTextBoxValidation.Views.CustomControls"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">

    <!-- Refer to the code behind file to see this user control's logic! -->
    <!-- Binding Value => this is a custom dependency property that is registered to this user control -->
    <!-- RelativeSource => this is needed to enable the databinding in both directions -->
    <TextBox x:Name="CustomIntegerTextBox"
             HorizontalContentAlignment="Right"
             Text="{Binding Value, 
                    Mode=TwoWay, 
                    UpdateSourceTrigger=PropertyChanged, 
                    ValidatesOnNotifyDataErrors=True,
                    RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type UserControl}}}" />

</UserControl>

The IntegerTextBox.xaml.cs

using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace SampleTextBoxValidation.Views.CustomControls
{
    public partial class IntegerTextBox : UserControl
    {
        [GeneratedRegex("[^0-9]+")]
        private static partial Regex IntegerPositiveValuesOnlyRegex();

        public static readonly DependencyProperty DependencyPropertyOfValue =
            DependencyProperty.Register(nameof(Value), typeof(int), typeof(IntegerTextBox), new PropertyMetadata(0));

        public int Value
        {
            get { return (int)GetValue(DependencyPropertyOfValue); }
            set { SetValue(DependencyPropertyOfValue, value); }
        }

        public IntegerTextBox()
        {
            InitializeComponent();

            CustomIntegerTextBox.TextChanged += TextBox_TextChanged;
            CustomIntegerTextBox.GotFocus += TextBox_GotFocus;
            CustomIntegerTextBox.PreviewTextInput += TextBox_PreviewTextInput;
            CustomIntegerTextBox.PreviewKeyDown += TextBox_PreviewKeyDown;
        }

        private void TextBox_GotFocus(object sender, RoutedEventArgs e)
        {
            Debug.WriteLine($"Entered {nameof(TextBox_GotFocus)}");

            (sender as TextBox)!.SelectAll();
        }

        private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
        {
            Debug.WriteLine($"Entered {nameof(TextBox_PreviewTextInput)}");

            e.Handled = IntegerPositiveValuesOnlyRegex().IsMatch(e.Text);
        }

        private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
        {
            Debug.WriteLine($"Entered {nameof(TextBox_PreviewKeyDown)}");

            if (e.Key == Key.Space)
            {
                e.Handled = true;
            }
            else if (e.Key == Key.Back)
            {
                if ((sender as TextBox)!.Text.Length == 1)
                {
                    (sender as TextBox)!.SelectAll();
                    e.Handled = true;
                }
            }
            else if (e.Key == Key.Delete)
            {
                if ((sender as TextBox)!.Text.Length == 1)
                {
                    (sender as TextBox)!.SelectAll();
                    e.Handled = true;
                }
            }
        }

        private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
        {
            Debug.WriteLine($"Entered {nameof(TextBox_TextChanged)}");

            bool valid = int.TryParse((sender as TextBox)!.Text,
                NumberStyles.Integer,
                CultureInfo.InvariantCulture,
                out int validInteger);

            if (valid)
            {
                Value = validInteger;
            }
            else
            {
                Value = 999; // for testing, only, to show an error was not handled
            }
        }
    }
}

<<< Edit after implementing the changes provided by @mm8>>>

So far the passing through of the NotifyDataErrorInfos is working, now.

There is still a issue with data binding, but that is another question.

enter image description here enter image description here


Solution

  • the Custom IntegerTextBox is showing the red box and not displaying the error message

    The difference is that IntegerTextBox is a UserControl that simply wraps an ordinary TextBox. It's not a custom TextBox. Your custom Validation.ErrorTemplate won't apply to the control.

    You should modify your IntegerTextBox class to be a custom control that extends TextBox. Remove the XAML file and create a stand-alone class:

    public partial class IntegerTextBox : TextBox
    {
        [GeneratedRegex("[^0-9]+")]
        private static partial Regex IntegerPositiveValuesOnlyRegex();
    
        public static readonly DependencyProperty DependencyPropertyOfValue =
            DependencyProperty.Register(nameof(Value), typeof(int), typeof(IntegerTextBox), new PropertyMetadata(0));
    
        public int Value
        {
            get { return (int)GetValue(DependencyPropertyOfValue); }
            set { SetValue(DependencyPropertyOfValue, value); }
        }
    
        public IntegerTextBox()
        {
            TextChanged += TextBox_TextChanged;
            GotFocus += TextBox_GotFocus;
            PreviewTextInput += TextBox_PreviewTextInput;
            PreviewKeyDown += TextBox_PreviewKeyDown;
        }
    
        private void TextBox_GotFocus(object sender, RoutedEventArgs e)
        {
            Debug.WriteLine($"Entered {nameof(TextBox_GotFocus)}");
    
            (sender as TextBox)!.SelectAll();
        }
    
        private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
        {
            Debug.WriteLine($"Entered {nameof(TextBox_PreviewTextInput)}");
    
            e.Handled = IntegerPositiveValuesOnlyRegex().IsMatch(e.Text);
        }
    
        private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
        {
            Debug.WriteLine($"Entered {nameof(TextBox_PreviewKeyDown)}");
    
            if (e.Key == Key.Space)
            {
                e.Handled = true;
            }
            else if (e.Key == Key.Back)
            {
                if ((sender as TextBox)!.Text.Length == 1)
                {
                    (sender as TextBox)!.SelectAll();
                    e.Handled = true;
                }
            }
            else if (e.Key == Key.Delete)
            {
                if ((sender as TextBox)!.Text.Length == 1)
                {
                    (sender as TextBox)!.SelectAll();
                    e.Handled = true;
                }
            }
        }
    
        private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
        {
            Debug.WriteLine($"Entered {nameof(TextBox_TextChanged)}");
    
            bool valid = int.TryParse((sender as TextBox)!.Text,
                NumberStyles.Integer,
                CultureInfo.InvariantCulture,
                out int validInteger);
    
            if (valid)
            {
                Value = validInteger;
            }
            else
            {
                Value = 999; // for testing, only, to show an error was not handled
            }
        }
    }
    

    Then you define a Style for the custom control that is based on your TextBox style in TextBoxStyle.xaml:

    <Style TargetType="customControls:IntegerTextBox" BasedOn="{StaticResource {x:Type TextBox}}" />