[WPF] 讓第一個數據驗證出錯(Validation.HasError)的控件自動獲得焦點

1. 需求

在上一篇文章 《在 ViewModel 中讓數據驗證出錯(Validation.HasError)的控件獲得焦點》中介紹了如何讓 Validation.HasError 的控件自動獲得焦點,之後引申了另一個問題:如果有多個 HasError 的控件,如何只讓第一個自動獲得焦點。

這需求比較常見,所以我試着解決這個問題,最終完成了一個 Demo,XAML 如下:

<StackPanel local:ValidationService.IsValidationScope="True">
    <StackPanel.Resources>
        <Style BasedOn="{StaticResource {x:Type TextBox}}"
               TargetType="TextBox">
            <Setter Property="local:ValidationService.AutoFocusWhenValidationError"
                    Value="True" />
        </Style>
    </StackPanel.Resources>
    <TextBox Text="{Binding Name, Mode=TwoWay, NotifyOnValidationError=True}" />
    <TextBox Text="{Binding Name, Mode=TwoWay, NotifyOnValidationError=True}" />
    <TextBox Text="{Binding Name, Mode=TwoWay, NotifyOnValidationError=True}" />
    <Button Margin="5"
            Command="{Binding SubmitCommand}"
            Content="Submit" />
</StackPanel>

為了實現這個功能用到了幾個入門知識,這篇文章講解如何組合這幾個入門知識實現需求:

2. Validation.Error 附加事件

為了實現自動獲得焦點這個需求,我們首先需要一個和數據驗證錯誤相關的事件通知。Validation 類 提供了很多支持數據驗證的方法和附加屬性,其中這次用到的是 Validation.Error 附加事件,它在綁定元素遇到驗證錯誤時觸發。使用方式如下:

Validation.AddErrorHandler(target, (s, e) =>
{
   //some code
});

注意,為了使用這個事件,數據綁定中的 NotifyOnValidationError 必須設置為 true

Text="{Binding Name, Mode=TwoWay, NotifyOnValidationError=True}"

3. WPF 中的樹

使用 VisualTreeHelper 遍歷 VisualTree,再通過 Validation.GetHasError 判斷元素是否具有 ValidationError,這樣就可以找出所有數據驗證錯誤的元素。我在以前的文章中提供了一個用於遍歷 VisualTree 的擴展方法類 VisualTreeExtensions,這次我直接使用它找出第一次數據驗證出錯的元素:

var root = Window.GetWindow(target).Content as UIElement;
var errorElement = root.GetVisualDescendants().OfType<UIElement>().FirstOrDefault(u => Validation.GetHasError(u));

4. 附加屬性

附加屬性是由 XAML 定義的概念。 附加屬性旨在用作可在任何對象上設置的一類全局屬性。通常來說附加屬性有兩種用法:純粹作為屬性值,或者在屬性值改變的回調函數里執行代碼。而這次我兩種方式都有用到。

在上面的代碼中,我先獲得要獲得焦點的控件的根節點元素,然後再找到第一次數據驗證出錯的元素。如果在結構複雜的 UI 中這個操作稍微有點耗時,而且說不定找到的是別的表單中的控件。這篇文章提到的「讓第一個 HasError 的元素獲得焦點」這個需求,通常還有一個隱含的條件:同一個表單以內。一般業務來說,同一個表單里的輸入控件並不會太多,起碼 VisualTree 會比一整個 Window 的 VisualTree 簡單很多。所以需要用一個附加屬性,將表單的根節點標記出來。在這裡我參考 Grid.IsSharedSizeScope 附加屬性 自定義了一個 IsValidationScope 屬性作為標識:

public static bool GetIsValidationScope(DependencyObject obj) => (bool)obj.GetValue(IsValidationScopeProperty);

public static void SetIsValidationScope(DependencyObject obj, bool value) => obj.SetValue(IsValidationScopeProperty, value);

public static readonly DependencyProperty IsValidationScopeProperty =
    DependencyProperty.RegisterAttached("IsValidationScope", typeof(bool), typeof(ValidationService), new PropertyMetadata(default(bool)));

在 XAML 中,將 StackPanel 標識為 ValidationScope:

<StackPanel local:ValidationService.IsValidationScope="True">

然後查找表單根節點的代碼修改成這樣:

var root = target.GetVisualAncestors().OfType<UIElement>().FirstOrDefault(d => GetIsValidationScope(d));
if (root == null)
    root = Window.GetWindow(target).Content as UIElement;

IsValidationScope 是純粹作為屬性值的附加屬性,我還需要定義另一個暑假屬性, 並在它的屬性值改變的回調函數中執行上面的邏輯。完整代碼如下:

public static bool GetAutoFocusWhenValidationError(DependencyObject obj) => (bool)obj.GetValue(AutoFocusWhenValidationErrorProperty);

public static void SetAutoFocusWhenValidationError(DependencyObject obj, bool value) => obj.SetValue(AutoFocusWhenValidationErrorProperty, value);

public static readonly DependencyProperty AutoFocusWhenValidationErrorProperty =
    DependencyProperty.RegisterAttached("AutoFocusWhenValidationError", typeof(bool), typeof(ValidationService), new PropertyMetadata(default(bool), OnAutoFocusWhenValidationErrorChanged));

private static void OnAutoFocusWhenValidationErrorChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
    var oldValue = (bool)args.OldValue;
    var newValue = (bool)args.NewValue;
    if (newValue == oldValue || newValue == false)
        return;

    var target = obj as UIElement;
    Validation.AddErrorHandler(target, (s, e) =>
    {
        var root = target.GetVisualAncestors().OfType<UIElement>().FirstOrDefault(d => GetIsValidationScope(d));
        if (root == null)
            root = Window.GetWindow(target).Content as UIElement;

        var errorElement = root.GetVisualDescendants().OfType<UIElement>().FirstOrDefault(u => Validation.GetHasError(u));
        if (errorElement != null && errorElement.IsKeyboardFocused == false)
            errorElement.Focus();
    });
}

OnAutoFocusWhenValidationErrorChanged 這個回調函數裏面,我們可以拿到被 「附加」的元素 target,以及附加屬性的值。如果這個值為 true (在這種用法里通常都是 true,類似一個簡單的 Behavior),則通過 Validation.AddErrorHandlertarget 添加事件處理程序,當數據驗證出錯時找到表單範圍內第一個出錯的元素,如果它還沒有獲得焦點就執行 Focus 函數。

在 XAML 中,為了讓表單中所有元素都附加上這個行為,可以通過全局樣式:

<StackPanel.Resources>
    <Style BasedOn="{StaticResource {x:Type TextBox}}"
           TargetType="TextBox">
        <Setter Property="local:ValidationService.AutoFocusWhenValidationError"
                Value="True" />
    </Style>
</StackPanel.Resources>

5. 最後

這種做法需要每個數據綁定中的 NotifyOnValidationError 必須設置為 true,在實際業務中比較麻煩。還有一種方法是主動遍歷所有元素並使用 Validation.GetHasError 找到目標元素,這樣做法簡單很多,但不夠自動,而且和本文的方法大同小異,就不另外寫出來了。

6. 源碼

//github.com/DinoChan/Wpf_Focus_Demo