Adding Validation to a XAML Control Using INotifyDataErrorInfo and the CommunityToolkit for WinUI, WPF, UWP and Uno

In my previous post on Building a XAML UserControl I made use of the CommunityToolkit.Mvvm (formerly Microsoft.Toolkit.Mvvm) NuGet package to provide the implementation of INotifyPropertyChanged used to data bind properties of our view model to the XAML controls. In this post we’re going to again use the community toolkit but this time we’re going to take advantage of their implementation of the INotifyDataErrorInfo interface in order to bubble up validation errors from our view model.

We’ll start by taking a look at the INotifyDataErrorInfo interface. This interface has been around for quite some time and exposes a single property, HasErrors, which true to it’s name is designed to return a boolean value indicating if the entity has any errors. It also exposes an event, ErrorsChanged, allowing other entities to register to be notified when the errors associated with the entity changes. Lastly, it exposes a method, GetErrors, which can be invoked in order to retrieve a list of the errors, either for a specific property, or, if propertyName is set to null, for all properties.

    public interface INotifyDataErrorInfo
    {
        bool HasErrors { get; }

        event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;

        IEnumerable GetErrors(string? propertyName);
    }

The CommunityToolkit includes an ObservableValidator class, which inherits from ObservableObject (the INotifyPropertyChanged implementation), and implements the INotifyDataErrorInfo interface. We’ll update our MainViewModel to inherit from ObservableValidator instead of ObservableObject.

public class MainViewModel : ObservableValidator
{
    private string firstName;
    private string lastName;

    [Required]
    [MinLength(2)]
    [MaxLength(100)]
    public string First { get => firstName; set => SetProperty(ref firstName, value, true); }

    [Required]
    [MinLength(2)]
    [MaxLength(100)]
    public string Last { get => lastName; set => SetProperty(ref lastName, value, true); }
}

You’ll notice in this code block, we’ve also attributed the First and Last properties with three attributes. The attributes define the validation rules for the class, which in this case requires both First and Last properties to be set and to have a minimum length of 2 and a maximum length of 100.

The other, slightly more subtle, change is in the call to SetProperty. A third parameter has been supplied, true, which indicates that the validation rules should be run as part of setting the underlying field.

As you can see, the ObservableValidator makes implementing the INotifyDataErrorInfo interface as simple as adding some attributes to the properties that need to be validated. Note you can create your own validation attributes, or you can use the CustomValidationAttribute if you want to specify a method to call in order to validate the property.

Now that our class, MainViewModel, implements INotifyDataErrorInfo, we need to update our user interface to indicate when there are validation errors. Unfortunately the current version (v0.8) of the WindowsAppSdk (i.e. Windows UI) doesn’t support validation. However, this is something that will be coming in the future – the preview of 0.8 includes the validation bits, which you can take a look at if you’re interested. In fact, we’re going to sneak a look at the styles that are packaged with the 0.8 preview and incorporate them into our Profile control. The built in style (and yes, this should be an explicit, not an implicit style) for the TextBox includes the following (taken from the generic.xaml file that ships with the 0.8 preview of the Microsoft.ProjectReunion.WinUI NuGet Package).

    <Style TargetType="TextBox">
        <!-- ... -->
        <!-- Please uncomment below line if you want to enable InputValidation APIs for TextBox/ PasswordBox in Pre-Release builds -->
        <!-- <Setter Property="ErrorTemplate" Value="{StaticResource DefaultInputValidationErrorTemplate}" /> -->
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="TextBox">
                    <Grid>
                        <VisualStateManager.VisualStateGroups>
                            <VisualStateGroup x:Name="CommonStates"> ... </VisualStateGroup>
                            <VisualStateGroup x:Name="ButtonStates"> ... </VisualStateGroup>
                             <VisualStateGroup x:Name="InputValidationEnabledStates"> ... </VisualStateGroup>
                            <VisualStateGroup x:Name="InputValidationErrorStates">
                                <VisualState x:Name="CompactErrors">
                                    <VisualState.Setters>
					...
                                        <Setter Target="ErrorPresenter.Visibility" Value="Visible" />
                                    </VisualState.Setters>
                                </VisualState>
                                <VisualState x:Name="InlineErrors">
                                    <VisualState.Setters>
                                        ...
                                        <Setter Target="ErrorPresenter.Visibility" Value="Visible" />
                                    </VisualState.Setters>
                                </VisualState>
                                <VisualState x:Name="ErrorsCleared" />
                            </VisualStateGroup>
                        </VisualStateManager.VisualStateGroups>
			...
                        <ContentPresenter x:Name="ErrorPresenter"
                            x:Load="False"
                            Grid.Row="1"
                            Grid.Column="3"
                            Foreground="{ThemeResource SystemControlErrorTextForegroundBrush}"
                            AutomationProperties.AccessibilityView="Raw" />
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

There are a couple of things to note in this XAML:

  • The presentation of the validation errors is done by the ErrorPresenter ContentPresenter element.
  • Whilst not visible in the XAML, the ErrorPresenter element uses the ErrorTemplate property to define the ContentTemplate for the errors to be presented (i.e. the Content).
  • There are visual states in the InputValidationErrorStates visual state group that controls whether the ErrorPresenter element is visible
  • The ErrorPresenter element is only loaded when required (i.e. has x:Load=”False”)

What’s missing from this XAML is the default value of the ErrorTemplate, which is set to a static resource, DefaultInputValidationErrorTemplate.

<DataTemplate x:Key="DefaultInputValidationErrorTemplate">
    <ItemsControl ItemsSource="{Binding ValidationErrors}">
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding ErrorMessage}" />
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</DataTemplate>

As we can see from this XAML, the ErrorPresenter is expecting a property called ValidationErrors, which is a list of items with a property called ErrorMessage. If you look at the implementation of the INotifyDataErrorInfo that the ObservableValidator class provides you’ll see that the GetErrors method returns an IEnumerable of ValidationResult objects. The ValidationResult class has a property called ErrorMessage, which aligns well with this XAML template.

However, the ObservableValidator class does not expose a property called ValidationErrors, so if we were to just reuse this XAML, we’d never see any validation errors. Luckily, we can work around this, without having to alter our MainViewModel class any further.

Let’s check out the XAML for the Profile control:

<UserControl x:Class="WinUIControl.Profile"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:dataannotations="using:System.ComponentModel.DataAnnotations"
             xmlns:collections="using:System.Collections"
             mc:Ignorable="d">
    <UserControl.Resources>
        <DataTemplate x:Key="DefaultInputValidationErrorTemplate" x:DataType="collections:IEnumerable">
            <ItemsControl ItemsSource="{x:Bind (collections:IEnumerable)}">
                <ItemsControl.ItemTemplate>
                    <DataTemplate x:DataType="dataannotations:ValidationResult">
                        <TextBlock Text="{x:Bind ErrorMessage}" />
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </DataTemplate>
    </UserControl.Resources>
    
    <StackPanel Padding="10">
        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="InputValidationErrorStates">
                <VisualState x:Name="Errors">
                    <VisualState.Setters>
                        <Setter Target="ErrorPresenter.Visibility"
                                Value="Visible" />
                    </VisualState.Setters>
                </VisualState>
                <VisualState x:Name="ErrorsCleared" />
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>

        <TextBox Text="{x:Bind FirstName, Mode=TwoWay}" />
        <TextBox Text="{x:Bind LastName, Mode=TwoWay}" />
        <ContentPresenter x:Name="ErrorPresenter"
                          x:Load="False"
                          Content="{x:Bind ValidationErrors, Mode=OneWay}"
                          ContentTemplate="{x:Bind ErrorTemplate, Mode=OneWay}"
                          Foreground="{ThemeResource SystemControlErrorTextForegroundBrush}"
                          AutomationProperties.AccessibilityView="Raw" />
    </StackPanel>
</UserControl>

Points of interest are:

  • Our DefaultInputValidationErrorTemplate template looks similar to the one included in the preview of Project Reunion except it’s expecting an IEnumerable as the data context
  • The DefaultInputValidationErrorTemplate template uses x:Bind to bind to the both the IEnumerable and the ErrorMessage property
  • The ErrorPresenter ContentPresenter uses x:Bind to bind to the Content to the ValidationErrors property, whilst the ContentTemplate is bound to the ErrorTemplate property. Both these properties exist on the Profile control, not on the MainViewModel that it will be data bound to.
  • Our InputValidationErrorStates visual states group is simpler than the Project Reunion version because we’re not allowing for different presentations of the error (i.e Compact v Inline). In this case we’re just setting the ErrorPresenter to Visible when there are Errors.

Ok, now for the code that makes this all work, which is in the code behind file for the Profile control.

public sealed partial class Profile : UserControl
{
    public string FirstName
    {
        get { return (string)GetValue(FirstNameProperty); }
        set { SetValue(FirstNameProperty, value); }
    }
    public static readonly DependencyProperty FirstNameProperty =
        DependencyProperty.Register("FirstName", typeof(string), typeof(Profile), new PropertyMetadata(null));
    public string LastName
    {
        get { return (string)GetValue(LastNameProperty); }
        set { SetValue(LastNameProperty, value); }
    }

    public static readonly DependencyProperty LastNameProperty =
        DependencyProperty.Register("LastName", typeof(string), typeof(Profile), new PropertyMetadata(null));
    public IEnumerable<ValidationResult> ValidationErrors
    {
        get { return (IEnumerable<ValidationResult>)GetValue(ValidationErrorsProperty); }
        set { SetValue(ValidationErrorsProperty, value); }
    }
    public static readonly DependencyProperty ValidationErrorsProperty =
        DependencyProperty.Register("ValidationErrors", typeof(IEnumerable<ValidationResult>), typeof(Profile), new PropertyMetadata(null));
    public DataTemplate ErrorTemplate
    {
        get { return (DataTemplate)GetValue(ErrorTemplateProperty); }
        set { SetValue(ErrorTemplateProperty, value); }
    }
    public static readonly DependencyProperty ErrorTemplateProperty =
        DependencyProperty.Register("ErrorTemplate", typeof(DataTemplate), typeof(Profile), new PropertyMetadata(null));

    public Profile()
    {
        this.InitializeComponent();
        this.ErrorTemplate = Resources["DefaultInputValidationErrorTemplate"] as DataTemplate;
        DataContextChanged += Profile_DataContextChanged;
    }
    private INotifyDataErrorInfo dataErrorSource;
    private void Profile_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
    {
        var newDataErrorSource = args.NewValue as INotifyDataErrorInfo;
        if (dataErrorSource is not null && dataErrorSource != newDataErrorSource)
        {
            dataErrorSource.ErrorsChanged -= ProfileErrorsChanged;
        }
        dataErrorSource = newDataErrorSource;
        if (dataErrorSource is not null)
        {
            dataErrorSource.ErrorsChanged += ProfileErrorsChanged;
        }
    }
    private void ProfileErrorsChanged(object sender, DataErrorsChangedEventArgs e)
    {
        VisualStateManager.GoToState(this, dataErrorSource.HasErrors ? nameof(Errors) : nameof(ErrorsCleared), true);
        ValidationErrors = dataErrorSource.GetErrors(null).OfType<ValidationResult>().ToArray();
    }
}

Key points:

  • Both ValidationErrors (IEnumerable<ValidationResult>) and ErrorTemplate (DataTemplate) are dependency properties
  • The default value for the ErrorTemplate is set in the Constructor. An alternative for this would be to define a default style for the Profile control that sets this property. Either way the ErrorTemplate can be overridden in XAML where it is used (more on this in a bit).
  • An event handler for the DataContextChanged event is wired up, which allows for subsequent wiring/unwiring of the ErrorsChanged event on the DataContext.
  • The ErrorsChanged handler (i.e. the ProfileErrorsChanged method) updates the visual state of the control, based on whether the DataContext HasErrors and then updates the ValidationErrors property with the list of errors.

The last thing to point out is that the ErrorTemplate can be overridden in the XAML where the Profile is used. For example, here’s the XAML for the MainWindow which includes a Profile element.

<Window x:Class="WinUIControl.MainWindow" ... >
    <StackPanel HorizontalAlignment="Center"
                VerticalAlignment="Center">
        <StackPanel.Resources>
            <DataTemplate x:Key="CustomErrorTemplate">
                <ItemsControl ItemsSource="{Binding}">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding ErrorMessage}"
                                       Foreground="DeepPink" />
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </DataTemplate>
        </StackPanel.Resources>
        <local:Profile Width="200"
                       ErrorTemplate="{StaticResource CustomErrorTemplate}"
                       FirstName="{Binding First, Mode=TwoWay}"
                       LastName="{Binding Last, Mode=TwoWay}" />
        <Button x:Name="myButton"
                Click="myButton_Click">Click Me</Button>
    </StackPanel>
</Window>

In this case the layout of the CustomErrorTemplate is similar to the default template except that the ErrorMessage is DeepPink. Note also that this template uses Binding instead of x:Bind.

Here’s the validation on our Profile control in action.

You’ll notice that there are a couple of things we still need to adjust. Firstly, the text refers to “First” instead of something like First Name. This is because the property on MainViewModel is First, so we need to update this to improve the way it displays. Secondly, and somewhat related to the first, is that we have no handling for either accessibility or different langauges. We’ll pick up these points in a subsequent post.