以编程方式启用全键盘访问行为





5.00/5 (2投票s)
展示了如何在 Mac OS X 应用程序中以编程方式启用全键盘访问制表行为。
介绍
我们有时会开发包含表单的 Mac OS X 应用程序,用户需要在其中输入不同类型的数据(文本字段、弹出式按钮、单选按钮等)。如果表单包含大量需要填写的字段,或者用户需要经常填写,我们希望使填写表单的过程快速而轻松。如果用户可以通过 Tab 键在所有字段之间切换并输入所有信息而无需使用鼠标,(希望)就能实现这一点。
Mac OS X 的“键盘”设置中有一个非常适合此目的的选项。它被称为“全键盘访问”。
问题在于,默认情况下,“全键盘访问”是关闭的。这意味着当你按下 Tab 键时,焦点会在文本框和列表之间跳转,并跳过所有其他 GUI 组件,例如弹出式按钮、按钮、滑块等。
编辑说明: 本文的一位读者提醒我,有一种比本文其余部分描述的方法更简单的方法可以实现全键盘访问行为。请阅读 评论 以及本文底部的 回复。
背景
在考虑解决方案之前,我们先暂停一下,粗略回顾一下 Mac OS X 应用程序中的键盘事件是如何工作的。在 Mac OS X 中,总有一个应用程序是活动的,您可以通过按 Cmd
+Tab
快速切换活动应用程序。活动应用程序是接收键盘输入的应用程序。更具体地说,如果一个应用程序有多个窗口,则只有一个窗口是该应用程序当前的“关键窗口”(key window)。之所以称为关键窗口,是因为它是接收来自应用程序的键盘事件的窗口。此外,窗口还有一个“第一响应者”(first responder),它是该窗口中当前接收所有键盘事件的组件。如果窗口中的一个文本字段当前获得焦点,它就会接收所有键盘事件,以便您可以键入该文本字段,这时我们就说该文本字段是当前的
“第一响应者”(为什么是第一响应者?基本上,因为如果文本字段不处理键盘事件,事件就会沿着所谓的“响应者链”(responder chain)向上移动,该链包括第一响应者视图,然后是其父视图,最后是关键窗口)。如果您想了解更多关于键盘事件的信息,“Cocoa Programming for Mac OS X”一书中“Keyboard Events”一章(作者 Hillegass & Preble)对该主题进行了很好的概述。
回到当前问题,假设我们的应用程序的用户将“全键盘访问”(Full Keyboard Access)设置为默认设置,“关闭”(仅限文本框和列表)。我们可以尝试告诉用户启用此设置,但这并不理想。通过调用 NSApplication
类中的 isFullKeyboardAccessEnabled
方法很容易查询此设置是否已启用,但我找不到以编程方式更改此设置的方法。此外,自动更改用户的系统设置通常不是一个好主意。
也许我们可以通过某种方式以编程方式覆盖我们应用程序中的默认 Tab 切换行为。关于这一点,当用户从一个视图 Tab 切换到下一个视图时,关键窗口上的视图是如何成为第一响应者的?当用户按下 Tab 键时,键盘事件会发送到关键窗口的第一响应者。如果第一响应者(或其任何父视图)不处理该事件(大多数视图都不会处理 Tab 键的按下),关键窗口将接收该事件并按如下方式处理:它会查询当前第一响应者的 nextKeyView
属性以查找下一个视图(每个视图的 nextKeyView
属性都可以使用 Xcode 的界面生成器设置)。关键窗口会询问下一个视图是否接受第一响应者状态,方法是调用其 acceptsFirstResponder
方法。如果视图拒绝,则关键窗口会查询该视图的 nextKeyView
属性并继续。
基于这个事实,我们能否通过子类化弹出式按钮并使其 acceptsFirstResponder
方法始终返回 YES
,来使其在 Tab 键进入时成为第一响应者(在全键盘访问关闭时,它通常不会成为第一响应者)?显然不行。事实证明,NSPopupButton
实际上总是返回 YES
,而不管当前的全键盘访问设置如何。通过调查,我们可以确定,在弹出式按钮成为第一响应者之前,窗口实际上调用了它的 acceptsFirstResponder
方法(该方法返回 YES
),但它从未调用 becomeFirstResponder
方法,也从未使弹出式按钮成为第一响应者,而是继续处理其 nextKeyView
。这种行为非常有趣(为什么还要调用 acceptsFirstResponder
?O.o),但它确实表明窗口以某种方式识别弹出式按钮为那些在全键盘访问关闭时不会成为第一响应者的视图之一。
一个具有第一响应者状态的弹出式按钮
我们能否以某种方式操纵关键窗口识别的“成为第一响应者的视图”的 NSView
对象类型?好吧,我真的不知道这些视图是如何被关键窗口识别的,所以对我来说,这是一个死胡同。编辑:在他 评论中,uchuugaka 对此问题提供了答案:关键窗口调用 NSView
的 canBecomeKeyView
方法。不知何故,这个方法完全被我忽略了!O.o
那么这该如何工作?!我们能否以某种方式覆盖窗口查找下一个要设为第一响应者的视图的逻辑?显然,这是可能的!这就是本文采用的方法,并在下一节中进行了解释。
实现
当用户按下 Tab 键时,会在我们的应用程序中触发一个键盘事件。首先会询问关键窗口的第一响应者来处理该事件。如果它选择不处理,键盘事件就会沿着响应者链向上移动,直到有人处理它。我们可以注册一个事件监视器来监听特定类型的事件,并在我们选择处理时处理它们。这可以通过以下代码语句实现:
id keyDownMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:TypeOfEventMask handler:^ NSEvent* (NSEvent* event) {
if ( /* the event interests us */ )
{
// Handle the event.
// Handled event - discard it
return nil;
}
return event;
}];
如果我们愿意,我们以后可以像这样取消注册监视器
[NSEvent removeMonitor:keyDownMonitor];
我们对匹配 NSKeyDownMask
掩码且 NSEvent
的 keyCode
为 48
(制表键)的事件感兴趣,所以我们开始吧!
首先,我们需要处理一个尴尬的技术细节。我们经常会发现,关键窗口的第一响应者是某个我们真正感兴趣的熟悉视图的某个晦涩的子视图。例如,如果一个文本字段是第一响应者,您可能会发现 [NSApplication sharedApplication].keyWindow.firstResponder
返回的是 NSTextView
类型的对象。该对象的 superview
是 _NSKeyboardFocusClipView
,而它的 superview
则是 NSTextField
。以下代码可以找到我们想要处理的视图:
NSView* currentKeyView = (NSView*) keyWindow.firstResponder; while ((currentKeyView) && (! [currentKeyView.class isSubclassOfClass:[NSTextField class]]) && (! [currentKeyView.class isSubclassOfClass:[NSDatePicker class]]) && (! [currentKeyView.class isSubclassOfClass:[NSPopUpButton class]]) && (! [currentKeyView.class isSubclassOfClass:[NSButton class]]) && (! [currentKeyView.class isSubclassOfClass:[NSStepper class]]) && (! [currentKeyView.class isSubclassOfClass:[NSSegmentedControl class]]) && (! [currentKeyView.class isSubclassOfClass:[NSMatrix class]]) && (! [currentKeyView.class isSubclassOfClass:[NSSlider class]])) currentKeyView = currentKeyView.superview;
当然,我们现在只支持有限数量的视图类型。我可没说我的解决方案是完美的,所以你打算怎么办?
下一步是在视图上调用 nextKeyView
方法(或者,如果用户同时按下 Shift 键和 Tab 键,则调用 previousKeyView
)。在返回的视图上,我们通过调用其 acceptsFirstResponder
方法来询问它是否准备好成为第一响应者。如果返回 YES
,我们就找到了新的第一响应者,否则我们调用该视图的 nextKeyView
来查找下一个候选视图。最后,为了真正赋予该视图第一响应者状态,我们调用关键窗口的 makeFirstResponder:
方法。
这导致了以下代码语句
id keyDownMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSKeyDownMask handler:^ NSEvent* (NSEvent* event) { NSWindow* keyWindow = [NSApplication sharedApplication].keyWindow; if ((event.keyCode==48 /*Tab*/) && ([keyWindow.firstResponder.class isSubclassOfClass:[NSView class]])) { NSView* currentKeyView = (NSView*) keyWindow.firstResponder; while ((currentKeyView) && (! [currentKeyView.class isSubclassOfClass:[NSTextField class]]) && (! [currentKeyView.class isSubclassOfClass:[NSDatePicker class]]) && (! [currentKeyView.class isSubclassOfClass:[NSPopUpButton class]]) && (! [currentKeyView.class isSubclassOfClass:[NSButton class]]) && (! [currentKeyView.class isSubclassOfClass:[NSStepper class]]) && (! [currentKeyView.class isSubclassOfClass:[NSSegmentedControl class]]) && (! [currentKeyView.class isSubclassOfClass:[NSMatrix class]]) && (! [currentKeyView.class isSubclassOfClass:[NSSlider class]])) currentKeyView = currentKeyView.superview; if (currentKeyView) { NSView* nextKeyView = currentKeyView; // Cycle-detection. NSMutableArray* chain = [[NSMutableArray alloc] init]; do { [chain addObject:nextKeyView]; nextKeyView = (event.modifierFlags & NSShiftKeyMask) ? nextKeyView.previousKeyView : nextKeyView.nextKeyView; } while ((nextKeyView) && (! [chain containsObject:nextKeyView]) && (! nextKeyView.acceptsFirstResponder)); [chain release]; if (nextKeyView) [keyWindow makeFirstResponder:nextKeyView]; // Handled event - discard it. return nil; } } return event; }];
好了——我们用一个(巨大的)赋值语句解决了这个问题!
最后的润色
现在,通过 UI 元素 Tab 切换工作得很顺利。但是,如果用户单击弹出式按钮,它仍然不会成为第一响应者。要使用键盘更改弹出式按钮选择的值,用户必须单击类似文本字段的元素,然后 Tab 切换到弹出式按钮(顺便说一句,启用全键盘访问后也是如此)。
为了使弹出式按钮在被单击时获得第一响应者状态,我们只需捕获鼠标单击事件并调用 makeFirstResponder
并传入弹出式按钮。遗憾的是,我找不到一个优雅的方法来捕获鼠标单击事件。我们可以创建每种类型视图的子类,并覆盖 mouseDown:
方法,通过 NSNotificationCentre
广播鼠标单击通知。但这不切实际,因为我们需要用我们特殊的自定义鼠标单击广播视图替换 xib 中的所有视图。尽管如此,覆盖 mouseDown:
方法实际上是我所采取的方法——但没有进行子类化。诚然,这有点丑陋,但它有效。
由于 Objective-C 方法调用的动态特性,我们可以在运行时用我们自己的实现替换类的某个方法的实现!这可以通过一种称为“方法混淆”(method swizzling)的技术来实现。Mike Ash 在 这里解释了这项技术,因此我将不再赘述(该资源还解释了为什么在类别中编写方法不行)。
基本上,我创建了一个类别(也可以是一个常规类),其中最常见的、通常不会通过鼠标单击获得第一响应者状态的视图的 mouseDown:
方法被替换为首先广播通知然后调用原始方法的那些方法。之后,我就可以注册接收这些通知,并将被单击的视图设为第一响应者。要了解如何实现,您可以在本文的 源代码 中查看 NSView+EventNotifications
类别。
使用代码
如果您阅读了前面的部分,您可能已经知道该做什么以及该复制哪些代码。但是,为了让事情更容易一些,我创建了一个演示应用程序(请参阅本文的 源代码),它演示了这些概念的实际应用,并包含两个类,您可以将它们复制并在自己的项目中使用。演示应用程序的外观如下:
当勾选“覆盖默认行为”复选框时,“全键盘访问”复选框控制您是否可以 Tab 切换所有视图(除非在 Mac 设置中启用了“全键盘访问”设置 - 那么您无论如何都会 Tab 切换所有视图:\)。
当“覆盖默认行为”未选中时,“全键盘访问”复选框被禁用,其勾选指示 Mac OS X 设置中的“全键盘访问”是否已打开。
要在您自己的项目中使用该代码,请将文件夹 FullKeyboardAccess/FullKeyboardAccess/FullKeyboardAccess 中的四个源文件复制到您的项目中。要启用全键盘访问,只需调用 FullKeyboardAccess
类的 enableFullKeyboardAccess
方法即可。如果您不想启用如上文最后的润色部分所述的“单击即可聚焦”功能,只需在 FullKeyboardAccess.h 中注释掉以下行即可:
#define ENABLE_FOCUS_ON_CLICK
这将移除 FullKeyboardAccess
对 NSView+EventNotifications
类的依赖,并且你无需将 NSView+EventNotifications.h 和 -.m 文件复制到你的项目中。
历史
- 2013/02/03
- 初始版本。
- 2013/04/02
- 添加了链接到 uchuugaka 的评论,并在文章中添加了相关的注释。