An in-depth explanation of data binding validation in WPF

In-depth explanation of data binding verification in WPF

WPF provides a verification function when the user inputs. Usually verification is implemented in the following two ways:

  1. Throws an error in the data object. Usually an exception is thrown during property setting, or the INotifyDataErrorInfo or IDataErrorInfo interface is implemented in the data class.
  2. Define validation at the binding level.

Validation is only applied if the value from the target is being used to update the data source.

Set validation in data object

  1. Throws exception on Set in property
public class MyData
{<!-- -->
    private string _value = "200";

    public string Value
    {<!-- -->
        get {<!-- --> return _value; }
        set
        {<!-- -->
            _value = value;

            if (value == "123")
                throw new System.Exception("An error was reported~~~[Exception]");
        }
    }
}
  1. If an exception is thrown directly, WPF will often ignore it, so that the exception information cannot be obtained. In this case, you need to use ExceptionValidationRule.

ExceptionValidationRule is a pre-built validation rule that reports all exceptions to WPF. It must be inside

<TextBox x:Name="tb1">
    <TextBox.Text>
        <Binding Path="Value" UpdateSourceTrigger="PropertyChanged">
            <Binding.ValidationRules>
                <ExceptionValidationRule />
            </Binding.ValidationRules>
        </Binding>
    </TextBox.Text>
</TextBox>

ExceptionValidationRuleAll exceptions that occur during the binding process, including the edited value that cannot be converted to the correct type, property setter exceptions, and value converter exceptions (float to string). When a validation failure occurs, the additional properties of the System.Windows.Controls.Validation class will log the error:

  • On the bound element, Validation.HasError is True, and the template of the control will be automatically switched to the template defined by Validation.ErrorTemplate.
  • ValidationRule.Validate() will return ValidationError, which contains error details
  • If Binding.NotifyOnValidationError is set to True, the Validation.Error event will be raised on the binding element

INotifyDataErrorInfo

INotifyDataErrorInfo and INotifyDataErrorInfo have similar functions, but the INotifyDataErrorInfo interface is richer. The difference from the above is that when implementing the INotifyDataErrorInfo or IDataErrorInfo interface, the user is allowed to modify it to an illegal value, but an error prompt is given.

image-20231108111152474

Cases of using INotifyDataErrorInfo

  1. Create a new Data class
//The class implements the INotifyDataErrorInfo interface, which defines the HasErrors attribute and GetErrors method, as well as the ErrorsChanged event
public class Data : INotifyDataErrorInfo,INotifyPropertyChanged
{<!-- -->
    //key is the attribute name, value is the error message list
    Dictionary<string, List<string>> errors = new();

    void SetErrors(string propertyName, List<string> value)
    {<!-- -->
        errors.Remove(propertyName);
        errors.Add(propertyName, value);
        if (ErrorsChanged != null)
        {<!-- -->
            ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
        }
    }
    void ClearErrors(string propertyName)
    {<!-- -->
        errors.Remove(propertyName);
        if (ErrorsChanged != null)
        {<!-- -->
            ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
        }
    }
    public bool HasErrors => errors.Count>0;

    public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
    public event PropertyChangedEventHandler? PropertyChanged;

    public IEnumerable Errors => GetErrors("ModelNumber");
    public IEnumerable GetErrors(string? propertyName)
    {<!-- -->
        if (propertyName is null or {<!-- --> Length: <= 0 })
        {<!-- -->
            return errors.Values;
        }
        else
        {<!-- -->
            if (errors.ContainsKey(propertyName))
            {<!-- -->
                return errors[propertyName];
            }
            else
            {<!-- -->
                return null;
            }
        }
    }

    private string modelNumber;

    public string ModelNumber
    {<!-- -->
        get {<!-- --> return modelNumber; }
        set {<!-- --> modelNumber = value;
            bool valid = true;
            foreach (char c in modelNumber)
            {<!-- -->
                if (!char.IsLetterOrDigit(c))
                {<!-- -->
                    valid = false;
                    break;
                }
            }
            if (!valid)
            {<!-- -->
                List<string> errors = new();
                errors.Add("ModelNumber cannot contain punctuation marks, spaces, etc.");
                SetErrors("ModelNumber", errors);
            }
            else
            {<!-- -->
                ClearErrors("ModelNumber");
            }
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ModelNumber"));
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("HasErrors"));
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Errors"));
        }
    }
}
  1. Make an interface and bind ModelNumber
<Window ...>
    <Window.DataContext>
        <local:Data/>
    </Window.DataContext>
    <StackPanel>
        <TextBox Text="{Binding ModelNumber ,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged,ValidatesOnDataErrors=True}"/>
        <TextBlock>
            <Run Text="Are there any errors?"/>
            <Run Text="{Binding HasErrors, Mode=OneWay}"/>
        </TextBlock>
        <ListView ItemsSource="{Binding Errors}"/>
    </StackPanel>
</Window>

animation

Custom validation rules

Custom validation rules are much like custom converters

  1. Customize validation rules for an attribute
public class ValueRule : ValidationRule
{<!-- -->

    public override ValidationResult Validate(object value, CultureInfo cultureInfo)
    {<!-- -->
        if (value?.ToString() == "123") return new ValidationResult(false, "The entered value is not within the range");
        return new ValidationResult(true, null);
    }
}
  1. Use validation rules on the interface
<StackPanel>
    <TextBox>
        <TextBox.Text>
            <Binding Path="Max" UpdateSourceTrigger="PropertyChanged" Mode="TwoWay">
                <Binding.ValidationRules>
                    <local:ValueRule/>
                </Binding.ValidationRules>
            </Binding>
        </TextBox.Text>
    </TextBox>
</StackPanel>

It can be seen that multiple validation rules can be placed below and executed in order. When all validation rules pass, the converter (if it exists) is called, where ExceptionValidationRule is special and will also be triggered when the input content cannot be converted into conversions defined by other rules.

Error display

First of all, the Validation.Error event will only be triggered when Binding.NotifyOnValidationError is set to true. When there is an error, you can use the static class Validation The additional attributes Errors and HasError in it are used to obtain information.

Usually when an error occurs, the border is not displayed in red. You can also set the error template yourself. The error template is located in the decorative layer, which is located above the ordinary window content.

<TextBox Width="130">
    <TextBox.Text>
        <Binding
            Mode="TwoWay"
            Path="Max"
            UpdateSourceTrigger="PropertyChanged">
            <Binding.ValidationRules>
                <local:ValueRule />
            </Binding.ValidationRules>
        </Binding>
    </TextBox.Text>
    <Validation.ErrorTemplate>
        <ControlTemplate>
            <DockPanel LastChildFill="True">
                <TextBlock
                    DockPanel.Dock="Right"
                    Foreground="Red"
                    Text="*" />
                <Border BorderBrush="Green" BorderThickness="2">
                    <AdornedElementPlaceholder />
                </Border>
            </DockPanel>
        </ControlTemplate>
    </Validation.ErrorTemplate>
</TextBox>

image-20231108140348940

Among them, AdornedElementPlaceholder represents the control itself. In the above case, * is placed around the control. If you want to overlap * on top of the control, you can Use Grid and put them in the same pane.

<Validation.ErrorTemplate>
    <ControlTemplate>
        <Grid>
            <TextBlock
                Margin="50,5,0,0"
                DockPanel.Dock="Right"
                Foreground="Red"
                Text="*" />
            <Border BorderBrush="Green" BorderThickness="2">
                <AdornedElementPlaceholder />
            </Border>
        </Grid>
    </ControlTemplate>
</Validation.ErrorTemplate>

image-20231108141002670

However, the error message cannot be displayed. You can use ToolTip to display the first error content.

<Validation.ErrorTemplate>
    <ControlTemplate>
        <Grid>
            <TextBlock
                Margin="50,5,0,0"
                DockPanel.Dock="Right"
                Foreground="Red"
                Text="*"
                ToolTip="{Binding ElementName=adornerPlaceholder, Path=AdornedElement.(Validation.Errors)[0].ErrorContent}" />
            <Border BorderBrush="Green" BorderThickness="2">
                <AdornedElementPlaceholder x:Name="adornerPlaceholder" />
            </Border>
        </Grid>
    </ControlTemplate>
</Validation.ErrorTemplate>

The AdornedElement attribute of AdornedElementPlaceholder is used in the above template to point to the element behind it.

animation

In this way, the error message will only be displayed when the * sign is suspended at the back. If you want to use it as the ToolTip of the TextBox element itself, you can use Validation.HasError to achieve this.

<TextBox Width="130">
    <TextBox.Text>
        <Binding
            Mode="TwoWay"
            Path="Max"
            UpdateSourceTrigger="PropertyChanged">
            <Binding.ValidationRules>
                <local:ValueRule />
            </Binding.ValidationRules>
        </Binding>
    </TextBox.Text>
    <Validation.ErrorTemplate>
        <ControlTemplate>
            <Grid>
                <TextBlock
                    Margin="50,5,0,0"
                    DockPanel.Dock="Right"
                    Foreground="Red"
                    Text="*"
                    ToolTip="{Binding ElementName=adornerPlaceholder, Path=AdornedElement.(Validation.Errors)[0].ErrorContent}" />
                <Border BorderBrush="Green" BorderThickness="2">
                    <AdornedElementPlaceholder x:Name="adornerPlaceholder" />
                </Border>
            </Grid>
        </ControlTemplate>
    </Validation.ErrorTemplate>
    <TextBox.Style>
        <Style TargetType="TextBox">
            <Style.Triggers>
                <Trigger Property="Validation.HasError" Value="True">
                    <Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Mode=Self}, Path=(Validation.Errors)[0].ErrorContent}" />
                </Trigger>
            </Style.Triggers>
        </Style>
    </TextBox.Style>
</TextBox>

animation

Validate multiple values

Many times it is necessary to dynamically verify multiple binding values. For example, there are two attributes, one Max and one Min. The requirement is that the user input Min must be less than Max. To achieve this function, you can use a binding group to create it.

The principle of the binding group is very simple. It is also a class that inherits from ValidationRule. The difference is that the rule cannot be bound to a single binding expression, but is attached to a container containing all bound controls.

  1. There are two properties in ViewModel
public class Data : INotifyDataErrorInfo,INotifyPropertyChanged
{<!-- -->
    public int Max {<!-- --> set; get; } = 100;
    public int Min {<!-- --> set; get; } = 1;
}
  1. Create validation rules
public class ValueRule : ValidationRule
{<!-- -->
    public override ValidationResult Validate(object value, CultureInfo cultureInfo)
    {<!-- -->
        BindingGroup bindingGroup = (BindingGroup)value;
        var d= (Data)bindingGroup.Items[0];
        if (d.Min >= d.Max)
        {<!-- -->
            return new ValidationResult(false, "Error, the minimum value must be less than the maximum value");
        }
        return new ValidationResult(true, null);
    }
}
  1. Binding on the UI. Note that a binding group needs to be added to the Grid here.
<Grid Margin="60" TextBox.LostFocus="Grid_LostFocus">
    <Grid.BindingGroup>
        <BindingGroup x:Name="customGroup">
            <BindingGroup.ValidationRules>
                <local:ValueRule />
            </BindingGroup.ValidationRules>
        </BindingGroup>
    </Grid.BindingGroup>
    <Grid.RowDefinitions>
        <RowDefinition />
        <RowDefinition />
    </Grid.RowDefinitions>
    <TextBox
        x:Name="ddd"
        Grid.Row="0"
        Text="{Binding Path=Max, BindingGroupName=customGroup, UpdateSourceTrigger=PropertyChanged}" />
    <TextBox Grid.Row="1" Text="{Binding Path=Min, BindingGroupName=customGroup, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
  1. There will be no verification at this time. The binding group uses a transaction processing editing system. Verification will only be performed after formal submission. Therefore, an event is added to the Grid and is triggered when the TextBox loses focus.
private void Grid_LostFocus(object sender, RoutedEventArgs e)
{<!-- -->
    customGroup.CommitEdit();
}
  1. If validation fails, the entire Grid will be considered invalid.

    animation

Note:

  1. When there are multiple binding groups, you need to set the Name for the BindingGroup, so that the binding group can be set during specific binding Text="{Binding Path=Max, BindingGroupName=customGroup, UpdateSourceTrigger=PropertyChanged}" />
  2. By default, the data received in the Validate method is the original object, not the newly modified value, so in order to verify the new value, you can use the GetValue method
BindingGroup bindingGroup = (BindingGroup)value;
var d = (Data)bindingGroup.Items[0];
var newValue = bindingGroup.GetValue(d, "Min");