Patrick
Patrick

Reputation: 106

How to set a TextBox to a specific build-in VisualState via Behavior and VisualStateManager

I am trying to use validation to display validation errors on windows elements (actually text boxes), but I failed to get the text boxes that are not focused/edited to update their validation when conditions for failure changed (neither using INotifyDataErrorInfo nor IDataErrorInfo).

Let's say, TextBox1 validate to Error when TextBox2 holds a specific path. Now after changing the path in TextBox2, TextBox1 should clear its error automatically, but this just did not happen, I always hat to enter the TextBox and change its content for validation to update...

Therefore I intended to use Behaviors in order to bind them to a Validation Boolean value and let the behavior set the TextBox in the appropriate VisualState using the default Validation States (Valid, InvalidFocused, InvalidUnfocused).

This is my Behavior (currently only a PoC so no Dependency Property):

/// <summary>
/// Behavior for setting the visual style depending on a validation value
/// </summary>
public class TextBoxValidationBindingBehavior : BehaviorBase<TextBox>
{
    /// <summary>
    /// Setup the behavior
    /// </summary>
    protected override void OnAttached()
    {
        base.OnAttached();

        this.AssociatedObject.TextChanged += this.AssociatedObject_TextChanged;
    }

    /// <summary>
    /// Set visual state
    /// </summary>
    private void AssociatedObject_TextChanged(object sender, TextChangedEventArgs e)
    {
        TextBox textBox = this.AssociatedObject as TextBox;

        if (textBox.Text == "Test")
        {
            VisualStateManager.GoToState(textBox, "Valid", false);
        }
        else
        {
            if (textBox.Focus())
            {
                VisualStateManager.GoToState(textBox, "InvalidFocused", true);
            }
            else
            {
                VisualStateManager.GoToState(textBox, "InvalidUnfocused", true);
            }
        }
    }

    /// <summary>
    /// Clean-up the behavior
    /// </summary>
    protected override void OnCleanup()
    {
        this.AssociatedObject.TextChanged -= this.AssociatedObject_TextChanged;

        base.OnCleanup();
    }
}

And the TextBox definition:

<TextBox   Grid.Row                 = "0" 
           Grid.Column              = "1"
           Margin                   = "0, 2, 0, 2"
           VerticalAlignment        = "Stretch"
           VerticalContentAlignment = "Center"
           Text                     = "{Binding NewBookName}">

    <b:Interaction.Behaviors>
        <behavior:TextBoxValidationBindingBehavior />
    </b:Interaction.Behaviors>

</TextBox>

Setting breakpoints I can see that the code gets called as expected. But the VisualStateManager.GoToState has absolutely no impact on the TextBox!

If I define a template for the text box and set custom VisualStates the behavior will work. However, the point was not to redefine visual states for the TextBox but rather to use the existing states just by associating a Behavior bound top a validation boolean and a message to display...

I'd really appreciate any hint!!! Also, I'd be happy to provide more information if required.

Upvotes: 0

Views: 209

Answers (1)

Patrick
Patrick

Reputation: 106

For the time being, I had to give up on just setting the Visual States 😣

I needed to create a Behavior that will when bound to the control add an ad-hoc ValidationRule. In principle:

  • For the custom validation to work (the point is only to get the default visual style on an error), the TextChanged event needs to disable the validation, update the source, then enable validation and re-update the source for the validation to happen
  • The IsBindingValid dependency property when changed will also update the source to trigger validation

All in all, this works:

/// <summary>
/// Behavior for setting the visual style depending on a validation value
/// </summary>
public class TextBoxValidationBindingBehavior : BehaviorBase<TextBox>
{
    #region Internal Validation Class

    /// <summary>
    /// A validation rule that validates according to a dependency property binding rather than on control content
    /// </summary>
    private class BindingValidationRule : ValidationRule
    {
        #region Initialization

        /// <summary>
        /// Constructor
        /// </summary>
        /// <param name="that">Behavior holding this class</param>
        public BindingValidationRule(TextBoxValidationBindingBehavior that) { this._that = that; }

        #endregion

        /// <summary>
        /// Reference to behavior holding the object
        /// </summary>
        private readonly TextBoxValidationBindingBehavior _that;

        /// <summary>
        /// Flag indication that the next validation check is to be disabled / set to true
        /// </summary>
        public bool DisableValidationOnce = true;

        /// <summary>
        /// Validates the control
        /// </summary>
        /// <param name="value">Value to validate (ignored)</param>
        /// <param name="cultureInfo">Culture Information</param>
        /// <returns>Returns the <see cref="ValidationResult"/> of this validation check</returns>
        public override ValidationResult Validate(object value, CultureInfo cultureInfo)
        {
            if (this._that is { } that)
            {
                ValidationResult validationResult;

                if (that.IsBindingValid || this.DisableValidationOnce)
                    validationResult = new ValidationResult(true, null);

                else
                    validationResult = new ValidationResult(false, that.ErrorText);

                // Re-enable validation
                this.DisableValidationOnce = false;

                // Set Error Tooltip
                that.AssociatedObject.ToolTip = validationResult.IsValid ? null : new ToolTip() { Content = validationResult.ErrorContent };

                // return result
                return validationResult;
            }
            else throw new Exception($"Internal TextBoxValidationBindingBehavior error.");
        }
    }

    #endregion

    #region DepProp: IsBindingValid

    public static readonly DependencyProperty IsBindingValidProperty = DependencyProperty.Register("IsBindingValid", typeof(bool), typeof(TextBoxValidationBindingBehavior), new PropertyMetadata(false, IsBindingValidProperty_PropertyChanged));

    public bool IsBindingValid
    {
        get => (bool)this.GetValue(IsBindingValidProperty);
        set => this.SetValue(IsBindingValidProperty, value);
    }

    private static void IsBindingValidProperty_PropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {   
        if (d is TextBoxValidationBindingBehavior _this)
        {
            // Avoid unnecessary notification propagation (the prop probably changed du to us updating the source property)
            if (_this._isValidating) return;

            // Trigger validation
            if (_this.AssociatedObject is { } textBox && textBox.GetBindingExpression(TextBox.TextProperty) is { } bindingExpression)
                bindingExpression.UpdateSource();
        }
    }

    #endregion

    #region DepProp: ErrorText

    public static readonly DependencyProperty ErrorTextProperty = DependencyProperty.Register("ErrorText", typeof(string), typeof(TextBoxValidationBindingBehavior), new PropertyMetadata("Error"));

    public string ErrorText
    {
        get => (string)this.GetValue(ErrorTextProperty);
        set => this.SetValue(ErrorTextProperty, value);
    }

    #endregion

    #region Private properties

    /// <summary>
    /// The custom validation rule to handle bound validation
    /// </summary>
    private BindingValidationRule _bindingValidationRule { get; set; }

    /// <summary>
    /// Indicate if validation already happening to avoid verbose notifications in the application
    /// </summary>
    private bool _isValidating;

    #endregion

    /// <summary>
    /// Setup the behavior
    /// </summary>
    protected override void OnAttached()
    {
        base.OnAttached();

        // Set handler(s)
        this.AssociatedObject.TextChanged += this.AssociatedObject_TextChanged;

        // Create custom validation rule
        this._bindingValidationRule = new BindingValidationRule(this);

        // Add rule
        if (this.AssociatedObject is { } textBox && BindingOperations.GetBinding(textBox, TextBox.TextProperty) is { } binding)
        {
            // We must be able to handle updating the source in order to set value bypassing validation
            if (binding.UpdateSourceTrigger == UpdateSourceTrigger.PropertyChanged) throw new Exception("Cannot set UpdateSourceTrigger to PropertyChanged when using TextBoxValidationBindingBehavior");

            // Add custom validation rule
            binding.ValidationRules.Add(this._bindingValidationRule);
        }
    }

    /// <summary>
    /// Set visual state
    /// </summary>
    private void AssociatedObject_TextChanged(object sender, TextChangedEventArgs e)
    {
        if (this.AssociatedObject is { } textBox && textBox.GetBindingExpression(TextBox.TextProperty) is { } bindingExpression)
        {
            this._isValidating = true;

            // Remove validation before updating source (or validation will prevent source from updating if it errors)
            this._bindingValidationRule.DisableValidationOnce = true;

            // Update Source
            bindingExpression.UpdateSource();

            // Ensure we are not disabled (if UpdateSource did not call Validation)
            this._bindingValidationRule.DisableValidationOnce = false;

            // Trigger validation
            bindingExpression.UpdateSource();

            this._isValidating = false;
        }
    }

    /// <summary>
    /// Clean-up the behavior
    /// </summary>
    protected override void OnCleanup()
    {
        this.AssociatedObject.TextChanged -= this.AssociatedObject_TextChanged;

        // Remove rule
        if (this.AssociatedObject is { } textBox && BindingOperations.GetBinding(textBox, TextBox.TextProperty) is { } binding)
        {
            binding.ValidationRules.Remove(this._bindingValidationRule);
        }

        base.OnCleanup();
    }
}

And the XAML code:

<TextBox   Grid.Row                 = "0" 
           Grid.Column              = "1"
           Margin                   = "0, 2, 0, 2"
           VerticalAlignment        = "Stretch"
           VerticalContentAlignment = "Center"
           Text                     = "{Binding NewBookName}">

    <b:Interaction.Behaviors>
        <behavior:TextBoxValidationBindingBehavior IsBindingValid = "{Binding IsValidName}"
                                                   ErrorText      = "Invalid name or a book with the same name already exists."/>
    </b:Interaction.Behaviors>

</TextBox>

However, there are a few things I really don't like about this way of proceeding:

  • This procedure is very verbose in that it triggers potentially a lot of notification bindings every time it updates the source just to validate the content
  • It may not be thread-safe?!
  • While it is theoretically possible to use it with other validation rules, it would probably need a lot of code to get it to work
  • I find this quite hacky...

I hope this can help others or if one has a better idea: YOU ARE WELCOME!!! 😊

Upvotes: 0

Related Questions