65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 WPF FocusScope

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (22投票s)

2009 年 7 月 27 日

MIT

5分钟阅读

viewsIcon

158186

downloadIcon

3850

解释了为什么 WPF 在尝试使用 FocusScope 时似乎会中断,并提供了一个简单的解决方案。

引言

通常,在用户界面的不同部分维护单独的焦点很有用。例如,当您有一个选项卡控件,每个页面都有不同的数据录入字段时,记住每个单独页面的焦点是有意义的。默认的 WPF 行为是在每次活动页面更改时将焦点重置为第一个子元素 - 如果您使用 Ctrl+Tab 快捷方式切换选项卡页面来查看您在另一个页面上输入的内容,然后当您 Ctrl+Tab 回到您正在编辑的选项卡页面时,这会非常令人讨厌,您总是必须使用鼠标或按 Tab 键 N 次才能回到您正在编辑的文本框。

解决这个问题的方法是记住每个选项卡页面内的逻辑焦点,并在页面再次激活时将键盘焦点恢复到适当的控件。

Screenshot Test Application

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 中的每个 ToolBarMenu 都有自己的焦点范围。

有了这些知识,我们就可以清楚地看到为什么会出现这些问题。

工具栏按钮不应该在自身上执行命令,而应该在单击工具栏之前拥有焦点的对象上执行命令。为了实现这一点,路由命令会忽略焦点范围内的焦点,而是使用“主”逻辑焦点。
这解释了为什么路由命令在焦点范围内不起作用。

为什么测试应用程序截图中的大文本框仍然显示闪烁的光标?我不知道这个问题的答案 - 但为什么不应该显示呢?诚然,该文本框没有键盘焦点(WPF 焦点范围中的小文本框拥有它);但它仍然拥有活动窗口中的主逻辑焦点,并且是所有路由命令的接收者。

当您使用 Tab 键导航到 WPF 焦点范围内的 CheckBox 并按空格键切换它时,为什么键盘焦点会移到大文本框?

好吧,这正是您单击菜单项或工具栏时所期望的:键盘焦点应该返回到主焦点。所有 ButtonBase 派生的控件都会这样做。

WPF FocusScope 在幕后是如何工作的?

如果您还不了解焦点范围:您可以通过将附加属性 FocusManager.IsFocusScope 设置为 true 来将任何控件变成焦点范围。ToolBarMenu 的默认样式就是这样做的;这些控件没有任何魔法。

每个焦点范围都存储在附加属性 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 日:文章发布
© . All rights reserved.