[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.AddErrorHandler
為 target
添加事件處理程式,當數據驗證出錯時找到表單範圍內第一個出錯的元素,如果它還沒有獲得焦點就執行 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