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

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2013 年 2 月 8 日

CPOL

9分钟阅读

viewsIcon

30154

downloadIcon

222

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

介绍 

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

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

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

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

背景 

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

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

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

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


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

我们是否能以某种方式操作关键窗口识别的“成为第一响应者的视图”的类型?嗯,我真的不知道这些视图是如何被关键窗口识别的,所以对我来说,这是一个死胡同。编辑: 在他的 评论 中,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 元素进行 Tab 切换工作得很顺利。但是,如果用户单击弹出按钮,它仍然不会成为第一响应者。要使用键盘更改弹出按钮选择的值,用户必须单击类似文本字段的某个东西,然后通过 Tab 键切换到弹出按钮(顺便说一句,即使启用了全键盘访问,情况也是如此)。

要使弹出按钮在单击时获得第一响应者状态,我们只需要捕获 mouse-down 事件并调用 makeFirstResponder 并传入弹出按钮。可惜,我找不到优雅的方法来捕获 mouse-down 事件。我们可以创建每种类型的视图的子类,并重写 mouseDown: 方法通过 NSNotificationCentre 广播 mouse-down 通知。但这不切实际,因为我们必须用我们特殊的自定义 mouse-down-broadcaster 视图替换所有 xib 中的视图。尽管如此,重写 mouseDown: 方法实际上是我采用的方法——但没有进行子类化。诚然,这有点丑陋,但它有效。

由于 Objective-C 方法调用的动态性,我们可以在运行时替换一个类的实现!这可以通过一种称为“方法交换”(method swizzling)的技术来实现。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.