如何通过 Behavior 和 VisualStateManager 将 TextBox 设置为特定的内置 VisualState

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

我正在尝试使用验证来显示 windows 元素(实际上是文本框)的验证错误,但我未能让不是 focused/edited 的文本框在条件时更新它们的验证对于失败更改(既不使用 INotifyDataErrorInfo 也不使用 IDataErrorInfo)。

比方说,当 TextBox2 持有特定路径时,TextBox1 验证为错误。现在在 TextBox2 中更改路径后, TextBox1 应该会自动清除其错误,但这并没有发生,我总是不得不输入 TextBox 并更改其内容以进行验证以更新。 ..

因此,我打算使用 Behaviors 将它们绑定到验证布尔值,并让行为使用默认验证状态( Valid, InvalidFocused, InvalidUnfocused).

这是我的行为(目前只有一个 PoC,所以没有依赖性 属性):

/// <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();
    }
}

和文本框定义:

<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>

设置断点我可以看到代码按预期被调用。但是 VisualStateManager.GoToState 对 TextBox 完全没有影响!

如果我为文本框定义一个模板并设置自定义 VisualStates,该行为将起作用。但是,关键不是要重新定义 TextBox 的视觉状态,而是仅通过将行为绑定到验证布尔值和要显示的消息相关联来使用现有状态...

如有任何提示,我将不胜感激!!!另外,如果需要,我很乐意提供更多信息。

目前,我不得不放弃只设置视觉状态

我需要创建一个 Behavior,它将在绑定到控件时添加一个临时 ValidationRule。原则上:

  • 要使自定义验证生效(重点只是在出错时获取默认视觉样式),TextChanged 事件需要禁用验证,更新源,然后启用验证并重新更新源验证发生
  • IsBindingValid 依赖项 属性 更改时也会更新源以触发验证

总而言之,这有效:

/// <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();
    }
}

和 XAML 代码:

<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>

但是,对于这种处理方式,有几点我真的不喜欢:

  • 这个过程非常冗长,因为它每次更新源时都可能触发大量通知绑定,只是为了验证内容
  • 它可能不是线程安全的?!
  • 虽然理论上可以将它与其他验证规则一起使用,但可能需要大量代码才能使其正常工作
  • 我觉得这很老套...

我希望这可以帮助其他人,或者如果有人有更好的主意:欢迎您!!!