使用 WPF FocusScope






4.97/5 (22投票s)
解释了为什么 WPF 在尝试使用 FocusScope 时似乎会中断,并提供了一个简单的解决方案。
引言
通常,在用户界面的不同部分维护单独的焦点很有用。例如,当您有一个选项卡控件,每个页面都有不同的数据录入字段时,记住每个单独页面的焦点是有意义的。默认的 WPF 行为是在每次活动页面更改时将焦点重置为第一个子元素 - 如果您使用 Ctrl+Tab 快捷方式切换选项卡页面来查看您在另一个页面上输入的内容,然后当您 Ctrl+Tab 回到您正在编辑的选项卡页面时,这会非常令人讨厌,您总是必须使用鼠标或按 Tab 键 N 次才能回到您正在编辑的文本框。
解决这个问题的方法是记住每个选项卡页面内的逻辑焦点,并在页面再次激活时将键盘焦点恢复到适当的控件。

KeyboardNavigationMode.Once - 一个解决方案?
您只需在 GroupBox 上设置 KeyboardNavigation.ControlTabNavigation="Once"
,它就会突然开始工作 - 只要您只使用键盘进行导航。但在我的用例中,当鼠标单击 GroupBox 时,还需要恢复之前的焦点。不幸的是,WPF 没有为 KeyboardNavigationMode.Once
后面的魔法提供任何 API;似乎无法在不使用反射访问 WPF 内部(我希望有人能证明我错了)的情况下以编程方式恢复焦点。
如果您觉得充满冒险精神,可以通过反射调用 KeyboardNavigation.GetActiveElement
,然后跳过本文的其余部分(示例代码对最左边的组框就是这样做的)。
Focus Scope - 一个解决方案?
等等 - 将逻辑焦点与键盘焦点分开?WPF 已经这样做了!
似乎我们可以简单地设置 FocusManager.IsFocusScope="True"
,WPF 就会为我们完成繁重的工作。不幸的是,这有一些非常糟糕的副作用。
MSDN 线程“A FocusScope Nightmare (Bug?)”很好地捕捉了我最初的反应。
- 为什么这个看似无害的更改会完全破坏 WPF 的路由命令?
- 我遇到了 WPF 的 bug 吗?
- 我该如何摆脱这个噩梦?
本文解释了 WPF 的焦点范围为何如此工作;并提供了一个简单的解决方案,使其按照我们想要的方式工作。
WPF FocusScope 有什么问题?
- 路由命令在焦点范围内不起作用。
- 它导致其他控件认为它们仍然拥有焦点。在文章开头截图中,两个文本框都显示了一个闪烁的光标。
- 一些控件(如按钮和复选框)在按下时会将焦点移到别处。
FocusScope 是为什么设计的?
Microsoft 在 WPF 中使用 FocusScope
来创建临时的二级焦点。WPF 中的每个 ToolBar
和 Menu
都有自己的焦点范围。
有了这些知识,我们就可以清楚地看到为什么会出现这些问题。
工具栏按钮不应该在自身上执行命令,而应该在单击工具栏之前拥有焦点的对象上执行命令。为了实现这一点,路由命令会忽略焦点范围内的焦点,而是使用“主”逻辑焦点。
这解释了为什么路由命令在焦点范围内不起作用。
为什么测试应用程序截图中的大文本框仍然显示闪烁的光标?我不知道这个问题的答案 - 但为什么不应该显示呢?诚然,该文本框没有键盘焦点(WPF 焦点范围中的小文本框拥有它);但它仍然拥有活动窗口中的主逻辑焦点,并且是所有路由命令的接收者。
当您使用 Tab 键导航到 WPF 焦点范围内的 CheckBox
并按空格键切换它时,为什么键盘焦点会移到大文本框?
好吧,这正是您单击菜单项或工具栏时所期望的:键盘焦点应该返回到主焦点。所有 ButtonBase 派生的控件都会这样做。
WPF FocusScope 在幕后是如何工作的?
如果您还不了解焦点范围:您可以通过将附加属性 FocusManager.IsFocusScope
设置为 true
来将任何控件变成焦点范围。ToolBar
和 Menu
的默认样式就是这样做的;这些控件没有任何魔法。
每个焦点范围都存储在附加属性 FocusManager.FocusedElement
中的逻辑焦点。当一个控件获得键盘焦点时,WPF 会查找其父焦点范围(最近的 IsFocusScope
为 true 的父级),并将其分配给 FocusedElement
属性,从而使该控件在该范围内的逻辑焦点。
WPF 窗口本身就是一个焦点范围,所以主逻辑焦点就是 Window
实例上的 FocusedElement
属性。路由命令只是在主焦点上执行。
解决方案
实际上,既然我们知道了正在发生的事情,那就只有一个问题需要解决:我们需要确保主逻辑焦点被设置。
WPF 只会为控件提供最近的父焦点范围内的逻辑焦点。我们将简单地为其提供所有父焦点范围内的逻辑焦点。
但是我们不想破坏工具栏和菜单。相反,我们将实现新的焦点逻辑作为一种附加行为。这允许像现有的那样轻松使用我们改进的焦点范围:t:EnhancedFocusScope.IsEnhancedFocusScope="True"
。
如果我们遇到一个不是我们“增强型”的焦点范围,我们将停止,就像 WPF 一样。
static void OnGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
IInputElement focusedElement = e.NewFocus;
for (DependencyObject d = focusedElement as DependencyObject;
d != null; d = VisualTreeHelper.GetParent(d)) {
if (FocusManager.GetIsFocusScope(d)) {
d.SetValue(FocusManager.FocusedElementProperty, focusedElement);
if (!(bool)d.GetValue(IsEnhancedFocusScopeProperty)) {
break;
}
}
}
}
基本上,这会将焦点范围从“临时”(如菜单/工具栏)转换为永久焦点范围。
现在,只剩下恢复焦点的问题了,例如当单击 GroupBox
上的空白区域时。这可以通过以下方式轻松完成
IInputElement storedFocus = FocusManager.GetFocusedElement(groupBox);
if (storedFocus != null)
Keyboard.Focus(storedFocus);
结论
希望读完这篇文章后,焦点范围对您来说不再是神秘的事物。
我们不必费很多工夫就能让它们工作,但我们最终还是手动保存和读取了焦点。
这就引发了一个问题,我们是否应该使用 WPF 的焦点范围——我们可以自己发明一种新的类型。
历史
- 2009 年 7 月 27 日:文章发布