Search code examples
c#wpfdata-bindingdependency-propertiesitemscontrol

Why all TextBoxes in ItemsControl use the last dynamically-added ValidationRule?


I have a custom TextBox that adds MinMaxValidationRule when both Min and Max properties are set

public class TextBox2 : TextBox
{
    public static readonly DependencyProperty MinProperty = DependencyProperty.Register(
        nameof(Min), typeof(double?), typeof(TextBox2),
        new PropertyMetadata(null, MinMaxChangeCallback));

    public double? Min
    {
        get => (double?)GetValue(MinProperty);
        set => SetValue(MinProperty, value);
    }

    public static readonly DependencyProperty MaxProperty = DependencyProperty.Register(
        nameof(Max), typeof(double?), typeof(TextBox2),
        new PropertyMetadata(null, MinMaxChangeCallback));

    public double? Max
    {
        get => (double?)GetValue(MaxProperty);
        set => SetValue(MaxProperty, value);
    }

    private static void MinMaxChangeCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var textBox2 = (TextBox2)d;
        if (textBox2.Min == null || textBox2.Max == null)
            return;
        var binding = textBox2.GetBindingExpression(TextProperty);
        var validationRules = binding
            .ParentBinding
            .ValidationRules;
        validationRules.Clear();
        var minMaxValidationRule = new MinMaxValidationRule((double)textBox2.Min, (double)textBox2.Max)
        {
            ValidatesOnTargetUpdated = true
        };
        validationRules.Add(minMaxValidationRule);
        binding.UpdateTarget(); // triggers the validation rule
    }
}

The validation rule is defined as follows

public class MinMaxValidationRule : ValidationRule
{
    public double Min { get; }
    public double Max { get; }

    public MinMaxValidationRule(double min, double max)
    {
        Min = min;
        Max = max;
    }

    public override ValidationResult Validate(object value, CultureInfo cultureInfo)
    {
        if (double.TryParse((string)value, out var d)
            && d <= Max
            && d >= Min)
            return ValidationResult.ValidResult;

        return new ValidationResult(false, $"Value must be in [{Min},{Max}]");
    }
}

I've created a ItemsControl to display a list of ItemInfo objects

<ItemsControl
    Margin="8"
    ItemsSource="{Binding ItemInfos}">
    <ItemsControl.ItemTemplate>
        <DataTemplate DataType="{x:Type testApp:ItemInfo}">
            <testApp:TextBox2
                Style="{StaticResource MaterialDesignOutlinedTextBox}"
                Width="400"
                Margin="12"
                Padding="8"
                Min="{Binding Min}"
                Max="{Binding Max}"
                Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}" />
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>
public partial class MainWindow
{
    public MainWindow()
    {
        InitializeComponent();
        DataContext = this;
        ItemInfos = new()
        {
            new()
            {
                Min = 0,
                Max = 100,
                Value = 50
            },
            new()
            {
                Min = 200,
                Max = 300,
                Value = 250
            },
        };
    }

    public List<ItemInfo> ItemInfos { get; }
}

public class ItemInfo
{
    public double Min { get; set; }
    public double Max { get; set; }
    public double Value { get; set; }
}

When writing "2" in the first TextBox (changing the value from 50 to 502), WPF is using the validation rule of the second textBox, but I expect that each TextBox uses its own ValidationRule.

Any idea on how to fix this?

Demo of the actual behaviour


Solution

  • This seems to be a known limitation when using Templates. There is a workaround to this, by using another override of Validate of the ValidationRule so that you can access the control and the updated value.

    You can edit the MinMaxValidationRule class as follow

    public class MinMaxValidationRule : ValidationRule
    {
        public MinMaxValidationRule()
        {
            ValidatesOnTargetUpdated = true;
        }
        
        public override ValidationResult Validate(object value, CultureInfo cultureInfo)
        {
            throw new NotImplementedException();
        }
    
        public override ValidationResult Validate(object value, CultureInfo cultureInfo, BindingExpressionBase owner)
        {
            // This will be the actual validation (use the BindingExpressionBase to get acess to the TextBox2 control)
            var textBox2 = (TextBox2)owner.Target;
            if (textBox2.Min != null
                && textBox2.Max != null
                && double.TryParse((string)value, out var d) is var b
                && (!b || d > textBox2.Max || d < textBox2.Min)
               )
                return new ValidationResult(false, $"Value must be in [{textBox2.Min},{textBox2.Max}]");
            return ValidationResult.ValidResult;        
        }
    }
    

    Now since we read the current value of the Min and Max property, you don't need the MinMaxChangeCallback so you can just add the Validation rule on the loaded event.

    public TextBox2()
    {
        this.Loaded += TextBox2_Loaded;
    }
    
    private void TextBox2_Loaded(object sender, RoutedEventArgs e)
    {
        var binding = this.GetBindingExpression(TextProperty);
        var validationRules = binding
            .ParentBinding
            .ValidationRules;
        validationRules.Clear();
        var minMaxValidationRule = new MinMaxValidationRule();
        validationRules.Add(minMaxValidationRule);
        binding.UpdateTarget(); // triggers the validation rule
    }