Johan
Johan

Reputation: 512

WPF ValidationRule prevent last value to be set

I use a validation rule on a TextBox to validate the user input string. The Text is binding to a float property on the view model and the WPF binding engine is keen enough to auto convert the string to a float for me.

When the validation fail however, the binding seems to read back the old value. This results in that I get a red border around the textbox even though the text has reverted back to the last acceptable floating point value.

Question: How do I make sure that the faulty input text is not automatically overwritten by the binding engine when the validation failed? The binding needs to be twoway.

I should mention that I do a little trick in my ValidationRule where I let it find the current view model from the view model locator, and uses the INotifyDataErrorInfo approach on the view model. That I found to be a great solution, as it means the ViewModel HasError will collect all the validation errors for me (and it let me apply the validation in the validationrules or in view model when setting the property).The benefit of letting the validation rule apply the validation using the INotifyDataErrorInfo on the view model is that the validation can be applied before the auto conversion from string to float, making sure the validation is performed even when the user types in "Hello World" resulting in an exception (swallowed by the binding engine) during the auto conversion to float. This allows me to keep the type of the property being float on the vm and still get the validation executed.

XAML

<TextBox Grid.Row="2" Grid.Column="2" x:Name="txtPreHeight" 
                         HorizontalAlignment="Stretch" 
                         HorizontalContentAlignment="Stretch"
                         VerticalAlignment="Center" 
                         Template="{DynamicResource TextBoxBaseControlTemplateMainScreen}">
  <TextBox.Text>
                    <Binding 
                     Path="PreHeight"    
                        ValidatesOnExceptions="False"
                     NotifyOnValidationError="True"
                     ValidatesOnNotifyDataErrors="True"  
                     UpdateSourceTrigger="LostFocus" 
                     >
                        <Binding.ValidationRules>
                            <validationrules:PreHeightValidationRule ViewModelType="GotoPositionViewModel" Min="0" Max="100" ValidationStep="RawProposedValue"/>
                        </Binding.ValidationRules>
                    </Binding>
                </TextBox.Text>
                <i:Interaction.Triggers>
                    <helper:RoutedEventTrigger RoutedEvent="{x:Static Validation.ErrorEvent}">
                        <cmd:EventToCommand Command="{Binding SetFocusOnValidationErrorCommand}"
                        PassEventArgsToCommand="True" />
                    </helper:RoutedEventTrigger>
                </i:Interaction.Triggers>
            </TextBox>

ValidationRule

class PreHeightValidationRule : ValidationRule
{
    private ValidationService validationService_;

    private Int32 min_ = Int32.MaxValue;
    private Int32 max_ = Int32.MinValue;
    private string viewModelType_ = null;

    public PreHeightValidationRule()
    {
        validationService_ = ServiceLocator.Current.GetInstance<Validation.ValidationService>();
    }

    public Int32 Min
    {
        get { return min_; }
        set { min_ = value; }
    }

    public Int32 Max
    {
        get { return max_; }
        set { max_ = value; }
    }

    public string ViewModelType
    {
        get { return viewModelType_; }
        set { viewModelType_ = value; }
    }

    public override ValidationResult Validate(object value, CultureInfo cultureInfo, BindingExpressionBase owner)
    {
        ValidationResult result = base.Validate(value, cultureInfo, owner);
        ViewModel.ViewModelBaseWithNavigation vm;
        System.Reflection.Assembly asm = typeof(ViewModelLocator).Assembly;
        Type type = null;

        if (type == null)
            type = asm.GetType(ViewModelType);

        if (type == null)
            type = asm.GetType("TeachpendantControl.ViewModel." + ViewModelType);

        vm = (ViewModel.ViewModelBaseWithNavigation)ServiceLocator.Current.GetInstance(type);
        ICollection<string> validationErrors = new List<string>();

        try
        {
            validationService_.ValidatePreHeight(value.ToString(), ref validationErrors, Min, Max);
        }
        catch (Exception e)
        {
            validationErrors.Add("Failed to validate, Exception thrown " + e.Message);
        }
        finally
        {
            vm.UpdateValidationForProperty(((BindingExpression)owner).ResolvedSourcePropertyName, validationErrors, validationErrors.Count == 0);
        }

        return new ValidationResult(validationErrors.Count == 0, validationErrors);
    }

    public override ValidationResult Validate(object value, CultureInfo cultureInfo)
    {
        return new ValidationResult(false, null);
    }
}

Upvotes: 0

Views: 1874

Answers (1)

Johan
Johan

Reputation: 512

I managed to solve it! I found a hint from Josh that brought my attention in the right direction.

Using a converter one can set the Binding.DoNothing. I modified it into a converter that checks the HasError on the VM. In case of HasError I return Binding.DoNothing otherwise I just forward the value.

using CommonServiceLocator;
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;   

namespace Converters
{
   class HasErrorToBindingDoNothingConverter : DependencyObject, IValueConverter
   {
      public static readonly DependencyProperty ViewModelTypeProperty =
        DependencyProperty.Register("ViewModelType", typeof(string), typeof(HasErrorToBindingDoNothingConverter), new UIPropertyMetadata(""));

    public string ViewModelType
    {
        get { return (string)GetValue(ViewModelTypeProperty); }
        set { SetValue(ViewModelTypeProperty, value); }
    }

    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        try
        {
            ViewModel.ViewModelBaseWithNavigation vm;
            System.Reflection.Assembly asm = typeof(ViewModelLocator).Assembly;
            Type type = null;

            if (type == null)
                type = asm.GetType(ViewModelType);

            if (type == null)
                type = asm.GetType("TeachpendantControl.ViewModel." + ViewModelType);

            vm = (ViewModel.ViewModelBaseWithNavigation)ServiceLocator.Current.GetInstance(type);

            if (vm.HasErrors)
                return Binding.DoNothing;
            else
                return value;
        }
        catch { return value; }
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return value;
    }
}
}

I had to change the XAML to this

  <TextBox.Text>
                    <Binding 
                     Path="PreHeight"    
                     ValidatesOnExceptions="False"
                     NotifyOnValidationError="False"
                     ValidatesOnNotifyDataErrors="False"     
                     UpdateSourceTrigger="PropertyChanged" 
                     Mode="TwoWay"
                     >
                        <Binding.ValidationRules>
                            <validationrules:PreHeightValidationRule ViewModelType="GotoPositionViewModel" Min="0" Max="100" ValidationStep="RawProposedValue"/>
                        </Binding.ValidationRules>
                        <Binding.Converter>
                            <converters:HasErrorToBindingDoNothingConverter ViewModelType="GotoPositionViewModel"/>
                        </Binding.Converter>
                    </Binding>
                </TextBox.Text>

            </TextBox>

IMO this is a great solution well worth to keep in mind.

Pros

  • Validation takes place on the VM using INotifyDataErrorInfo
  • View elements may bind directly to the INotifyDataErrorInfo HasError.
  • Support multiple ValdiationResult (failures) / property.
  • Supports cross property validation.
  • Validation can be done using a ValidationRule on the RawProposedValue (string) no need to add an extra layer of strings in the VM.
  • When no need to perform validation on the RawProposedValue, one could validate at the property setter in the ViewModel.
  • Last point implies validation can be executed before the auto conversion (from string to float in this case) would fail with an exception caught by the WPF binding engine, that would normally prevent the validation to not execute and prevent elements binding to HasError to not update their state.
  • The incorrect value (string in this case) will not be overwritten in the view on validation failure.

Upvotes: 1

Related Questions