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

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2013 年 2 月 8 日

CPOL

9分钟阅读

viewsIcon

30153

downloadIcon

222

展示了如何在 Mac OS X 应用程序中以编程方式启用全键盘访问制表行为。

介绍 

我们有时会开发包含表单的 Mac OS X 应用程序,用户需要在其中输入不同类型的数据(文本字段、弹出式按钮、单选按钮等)。如果表单包含大量需要填写的字段,或者用户需要频繁填写,我们希望使表单填写过程快速简便。如果用户能够通过制表键在所有字段之间切换并输入所有信息,而无需使用鼠标,这(希望)就能实现这一点。

Mac OS X 的“键盘”设置中有一个非常适合此目的的选项。它被称为“全键盘访问”。

问题在于,默认情况下,“全键盘访问”是关闭的。这意味着当你按下 Tab 键时,焦点会在文本框和列表之间跳转,并跳过所有其他 GUI 组件,例如弹出式按钮、按钮、滑块等。

编辑说明:一位读者向我指出,有一种比本文其余部分描述的方法更简单的方法可以实现全键盘访问行为。请阅读文章底部的评论及其回复

背景 

在考虑解决方案之前,让我们先暂停一下,粗略回顾一下 Mac OS X 应用程序中的键盘事件是如何工作的。在 Mac OS X 中,总有一个应用程序是活动的,你可以通过按 Cmd+Tab 快速切换活动应用程序。活动应用程序是接收键盘输入的应用程序。更具体地说,如果一个应用程序有多个窗口,那么只有一个窗口是该应用程序的当前键窗口。它被称为键窗口,因为它是接收应用程序键盘输入的窗口。此外,窗口有一个第一响应者,它是该窗口中当前接收所有键盘输入的组件。如果窗口上的一个文本字段当前获得焦点,它就会接收所有键盘输入,以便你可以输入到该文本字段中,我们称该文本字段为当前
“第一响应者”(为什么是**第一**响应者?基本上,因为如果文本字段不处理键盘事件,事件就会沿着所谓的响应者链向上移动,该链由第一响应者视图、其父视图以及键窗口组成)。如果你想了解更多关于键盘事件的信息,“Cocoa Programming for Mac OS X”一书中 Hillegass & Preble 的“键盘事件”章节对此主题进行了很好的概述。

回到我们手头的问题,假设我们的应用程序用户将“全键盘访问”设置为默认设置,即“关闭”(仅限文本框和列表)。我们可以尝试告诉用户打开此设置,但这并不理想。通过调用 NSApplication 类中的 isFullKeyboardAccessEnabled 方法很容易查询此设置是否已打开,但我找不到以编程方式更改此设置的方法。此外,自动更改用户系统设置通常不是一个好主意。

也许我们可以以某种方式以编程方式覆盖我们应用程序中的默认制表行为。关于这一点,当用户从一个视图制表到下一个视图时,键窗口上的视图如何成为第一响应者?当用户按下 Tab 键时,键盘事件会发送到键窗口的第一响应者。如果第一响应者或其任何父视图都不处理该事件(大多数视图都不处理 Tab 键按下),键窗口将接收该事件并按以下方式处理:它会查询当前第一响应者的 nextKeyView 属性来查找下一个视图(可以使用 Xcode 的界面生成器设置每个视图的 nextKeyView 属性)。键窗口会询问下一个视图它是否接受第一响应者状态,方法是调用其 acceptsFirstResponder 方法。如果视图拒绝,键窗口会查询该视图的 nextKeyView 属性并继续。

基于这个事实,我们能否通过子类化弹出式按钮并使其 acceptsFirstResponder 方法始终返回 YES,来让一个弹出式按钮(在全键盘访问关闭时不会成为第一响应者)在被制表时成为第一响应者?显然不行。事实证明,NSPopupButton 实际上总是返回 YES,而与当前的全键盘访问设置无关。通过调查,我们可以确定在弹出式按钮成为第一响应者之前,窗口实际上会调用其 acceptsFirstResponder 方法(返回 YES),但它从未调用其 becomeFirstResponder 方法,也从未使弹出式按钮成为第一响应者,而是继续处理其 nextKeyView。这种行为很有趣(为什么它甚至会调用 acceptsFirstResponder?O.o),但它确实表明窗口以某种方式将弹出式按钮识别为在全键盘访问关闭时不会成为第一响应者的视图之一。


一个具有第一响应者状态的弹出式按钮 

我们能否以某种方式操作键窗口识别哪些类型的 NSView 对象为“被设为第一响应者的视图”?嗯,我真的不知道键窗口是如何识别这些视图的,所以对我来说,这是一个死胡同。编辑:在他评论中,uchuugaka 提供了这个问题的答案:键窗口调用 NSViewcanBecomeKeyView 方法。不知何故,这个方法完全逃过了我!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 掩码且 NSEventkeyCode48(制表键)的事件感兴趣,所以我们开始吧!

首先,我们需要处理一个棘手的技术细节。我们通常会发现,键窗口的第一响应者是某个我们真正感兴趣的熟悉视图的某个晦涩子视图。例如,如果文本字段是第一响应者,你可能会发现 [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;
 

当然,我们现在只支持有限数量的视图类型。我可没说我的解决方案是完美的,所以你打算怎么办? Wink | <img src=

下一步是在视图上调用 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 元素制表可以很好地工作。但是,如果用户单击弹出式按钮,它仍然不会成为第一响应者。要使用键盘更改弹出式按钮选择的值,用户必须单击类似文本字段的内容,然后通过制表键移至弹出式按钮(顺便说一句,即使启用了全键盘访问也是如此)。

要使弹出式按钮在单击时获得第一响应者状态,我们只需捕获鼠标按下事件并使用弹出式按钮调用 makeFirstResponder。可惜,我找不到一种优雅的方法来捕获鼠标按下事件。我们可以创建每个视图类型的子类并覆盖 mouseDown: 方法,通过 NSNotificationCentre 广播鼠标按下通知。但这不切实际,因为我们需要将 XIB 中的所有视图替换为我们特殊的自定义鼠标按下广播视图。尽管如此,覆盖 mouseDown: 方法确实是我的做法——但没有进行子类化。诚然,这有点丑陋,但它有效。

由于 Objective-C 方法调用的动态特性,我们可以在运行时用我们自己的实现替换类方法的实现!这可以通过一种称为“方法交换”的技术来实现。Mike Ash 在这里解释了这项技术,所以我不会详细介绍(该资源还解释了为什么在类别中编写方法行不通)。

基本上,我创建了一个类别(它也可以是一个常规类),其中大多数通常不会通过鼠标单击获得第一响应者状态的视图的 mouseDown: 方法被替换为先广播通知然后调用原始方法的那些方法。在其他地方,我可以注册接收这些通知,并使被单击的视图成为第一响应者。要了解如何操作,您可以查看本文源代码中的 NSView+EventNotifications 类别。

使用代码


如果你阅读了前面的部分,你可能已经知道该怎么做以及复制代码。但是,为了让事情变得更容易一些,我创建了一个演示应用程序(请参阅本文的源代码),它演示了这些概念的实际应用,并包含两个你可以复制到自己的项目中的类。演示应用程序的外观如下:

当选中“覆盖默认行为”复选框时,“全键盘访问”复选框控制你是否可以制表浏览所有视图(除非 Mac 设置中的“全键盘访问”已打开 - 那么你仍然可以通过所有视图制表:)。
当“覆盖默认行为”未选中时,“全键盘访问”复选框被禁用,其勾选指示 Mac OS X 设置中的“全键盘访问”是否已打开。

要在你自己的项目中使用这些代码,请将 FullKeyboardAccess/FullKeyboardAccess/FullKeyboardAccess 文件夹中的四个源文件复制到你的项目中。要启用全键盘访问,只需调用 FullKeyboardAccess 类的 enableFullKeyboardAccess 方法。如果你不想启用点击聚焦功能(如上面“最后的润色”部分所述),只需在 FullKeyboardAccess.h 中注释掉以下行:

#define ENABLE_FOCUS_ON_CLICK  

这将移除 FullKeyboardAccessNSView+EventNotifications 类的依赖,并且你无需将 NSView+EventNotifications.h-.m 文件复制到你的项目中。

历史  

  • 2013/02/03 
    • 初始版本。
  • 2013/04/02
    • 添加了链接到 uchuugaka 的评论,并在文章中添加了相关的注释。
© . All rights reserved.