天天看点

Cocoa:应用内键盘事件处理

原作:http://www.tuicool.com/articles/quEzuyJ

本文将介绍: Cocoa 中应用内(不包含全局快捷键)键盘事件处理路径,如何在路径的每个阶段重载相应的方法来处理事件,并且给出具体实现方法(基于 Swift 2.2)。

前言

在 Mac 上完全通过键盘操控一个应用无疑是最愉悦的体验,在 CurrencyX 1.2 版本中,我们希望增加一些快捷键来提升用户体验:

在汇率列表中实现:

  1. 回车键定位到输入栏;
  2. 退格键定位到输入栏并且删除最后一个符号(如果有的话);
  3. 数字键直接开始编辑输入栏;
  4. 字母键将汇率列表中首字母符合的汇率变为基本汇率( 

    NSTableView

     默认行为)。
  5. ⌘ + F

     跳转至开始搜索界面;

在搜索汇率中实现:

  1. 字母键直接开始编辑搜索栏;
  2. ESC 键返回汇率列表。

我们只需要了解从 按键事件发生 到 某个 Responder 处理事件 过程中发生了什么,然后根据需求在合适的地方实现自定义的方法即可。

本文总结了按键事件处理相关要点,并且给出每一种快捷键我们使用的不同的具体实现方法(基于 Swift 2.2)。

一、按键事件的路径

敲下一个键后, 

NSApp

 接收到一个按键事件( 

NSEvent

 )。该 event 的 

characters

 即物理按键根据 键值绑定规则 中对应的字符串,可以用作唯一标识。

NSApp

 需要根据按键事件的类型来做出不同的处理,而判断其类型的过程相对复杂。下图是一个按键事件在应用中真正被处理之前可能经过的路径:

Cocoa:应用内键盘事件处理

按照 

NSApp

 对按键事件的处理顺序,有三个关键步骤,具体说明如下:

  1. Key Equivalents
    • 首先, 

      NSApp

       向 Key Window 发送 

      performKeyEquivalent:

       消息,并沿着视图层次(View Hierarchy) 向下传递 ( 

      NSView

       中该方法默认实现即向 

      subViews

       依次发送该消息),直到某个对象返回 YES。
    • 如果视图层次中没有对象处理事件, 

      NSApp

       便将 

      performKeyEquivalent:

       消息发送给 Menu 的控件,直到某个对象返回 YES。
    • 如果 Menu 中也没有对象处理事件,进行下一步。

      Apple 的文档中不建议重载 

      performKeyEquivalent:

       方法,建议利用如 

      NSButton

       , 

      NSMenu

       , 

      NSMatrix

       , 

      NSSavePanel

       等默认实现了 

      performKeyEquivalent:

       方法的控件,设置其 

      keyEquivalent

       属性为对应的键值,控件在键值匹配时会自动触发点击事件。
  2. Keyboard Interface Control
    • NSWindow

       默认以 First Responder 为起点,通过 

      NSView

       的 

      nextKeyView

       和 

      previousKeyView

       属性组成首尾相连的 Key-view loop。 

      NSWindow

       会将特定按键或按键组合事件与控制焦点视图(Key-view)的 Commands 绑定(如 Tab 键切换到下一个焦点等)。
    • 如果按键不属于特定按键,或者特定按键的对应 Command 没有实现,进行下一步。

      可以通过 

      NSView

       中 Key-view Loop Management 相关方法在代码中控制 Key-view Loop。
  3. Keyboard Actions
    • NSApp

       通过 

      sendEvent:

       将按键事件传给 Key Window, 

      NSWindow

       调用 First Responder 的 

      keyDown:

       方法(当只有修饰键时,调用 

      flagsChanged:

       ),并沿着 Responder Chain 向上传递 ( 

      NSResponder

       中该方法默认实现即将消息传递给 Next Responder),直到某个 Responder 响应并处理事件。
    • 如果没有 Responder 响应,进行下一步。

      NSResponder

       把特定按键事件与相应 Commands 绑定,Responder 可以通过实现 Command 并在 

      keyDown:

       中调用 

      interpretKeyEvents:

       将事件传递给系统 Input Manager,该方法将根据按键事件是否有绑定的 Command,向调用者发送 

      doCommandBySelector:

       或 

      insertText:

       消息,这两种方法默认实现都是将消息发送给 Next Responder,当没有 Next Responder 时将 Beep。

二、自定义按键事件处理方法

了解系统处理按键事情的过程后,在每一步中都有不同的方法可以处理:

  1. 等价键判断
    • 设置 View Hierarchy 中某些元素的 

      keyEquivalent

       属性;
    • 设置 Menu 中 某些元素的 

      keyEquivalent

       属性。

      详见 Handling Key Equivalents

  2. 键盘界面控制
    • 设置 View Hierarchy 中某些元素的 

      acceptsFirstResponder

       返回 YES,影响 

      canBecomeKeyView

       返回 YES,并通过设置 

      nextKeyView

       和 

      previousKeyView

       为 First Responder 设置 Key-view loop。

      详见 Handling Keyboard Interface Control

  3. 键盘动作
    • 重载 

      keyDown:

       方法:
      • 在方法中调用 

        interpretKeyEvents:

         并为按键绑定的 Command 提供自定义实现, 在重载 Command 的时候需要注意,很多方法 

        NSResponder

         仅声明而没有实现,因此调用 

        super

         会抛出异常 ;
      • 在方法中通过事件的 

        characters

         与 

        NSEvent

         定义的 常量 和 

        NSText

         定义的 常量判断特殊按键,并直接调用自定义实现。
      详见 Overriding the keyDown: Method

三、实现过程

我们决定通过三种方法实现期望的功能:

  1. 通过重载 

    keyDown:

     实现:
    • 回车键定位到输入栏;
    • 退格键定位到输入栏并且删除最后一个符号(如果有的话);
    • 数字键直接开始编辑输入栏;
    • 字母键直接开始编辑搜索栏。
  2. 通过 Menu Item 的快捷键设置实现:
    • ⌘ + F

       跳转至开始搜索界面。
  3. 通过重载 ESC 绑定的 Command 实现
    • ESC 键返回汇率列表。

1. 重载 

keyDown:

override func keyDown(theEvent: NSEvent) {  
    // Pseudo-code 
    guard IsValidateKeyDownEvent else {
        super.keyDown(theEvent)
    } 
    FocusTextField
    if (IsDeleteKey) {
        DeleteLastCharIfExits
    } else if (IsEnterKey) {
        DoNothing
    } else {
        InsertText
    }
    SendControlTextDidChangeNotification
}                 func keyDown(theEvent: NSEvent) {  
    // Pseudo-code 
    guard IsValidateKeyDownEvent else {
        super.keyDown(theEvent)
    } 
    FocusTextField
    if (IsDeleteKey) {
        DeleteLastCharIfExits
    } else if (IsEnterKey) {
        DoNothing
    } else {
        InsertText
    }
    SendControlTextDidChangeNotification
}      

NSEvent

 的 

characters

 属性是 String,而 

NSEvent

 定义的 常量 和 

NSText

 定义的 常量 都是 

Int

 ,因此可以通过:

extension NSEvent {
    var keyCharacter: Int? {
        guard let char = charactersIgnoringModifiers?.utf16.first else {
            // Handling dead keys
            return nil
        }
        return Int(char)
    }
}                 NSEvent {
    var keyCharacter: Int? {
        guard let char = charactersIgnoringModifiers?.utf16.first else {
            // Handling dead keys
            return nil
        }
        return Int(char)
    }
}      
func isDeleteKeyDownEvent(theEvent: NSEvent) -> Bool {  
    if let char = theEvent.keyCharacter where char == NSDeleteCharacter {
        return true
    }
    return false
}

func isEnterKeyDownEvent(theEvent: NSEvent) -> Bool {  
    if let char = theEvent.keyCharacter where char == NSCarriageReturnCharacter {
        return true
    }
    return false
}                 isDeleteKeyDownEvent(theEvent: NSEvent) -> Bool {  
    if let char = theEvent.keyCharacter where char == NSDeleteCharacter {
        return true
    }
    return false
}

func isEnterKeyDownEvent(theEvent: NSEvent) -> Bool {  
    if let char = theEvent.keyCharacter where char == NSCarriageReturnCharacter {
        return true
    }
    return false
}      

在 

keyDown:

 通过调用 

isDeleteKeyDownEvent:

 和 

isEnterKeyDownEvent:

 判断即可。

实际上在阅读文档之前,一直使用 

theEvent.keyCode

 直接与数字进行对比,只是这样代码可读性不高,实际上也是可行的方法。

2. 增加 Menu Item

通过在菜单栏中创建新的 Item 来实现组合快捷键有以下优点:

  1. 可以在 Interface Builder 中很方便的定义按键;
  2. 对于不熟悉应用的用户来说,菜单栏能够直观的表示快捷键的作用;
  3. 没有键盘的用户也可以通过点击菜单栏使用该功能。

实现起来也很简单,在 First Responder 中实现自定义方法,并且通过 MainMenu.xib 将新增的 Menu Item 与 Responder 关联起来即可。 具体步骤如下:

  1. 打开 MainMenu.xib;
  2. 选择 First Responder;
  3. 选择 Utilities 面板中的 Attributes Inspector;
  4. 现在 Inspector 中有内容为空的 User Defined 列表,点击下方“+”按钮添加新的 Action,即自定义函数。我们现在希望增加一项名为“搜索”的菜单栏,调用 

    ViewController

     (任意 

    canBecomeFirstResponder

     的类都可以)中实现的 

    search(sender: AnyObject?)

     ,那么只需要设置新增的一栏中 Action 为 search: (注意函数名后的冒号), Type 为默认的 id 即可;
  5. 在 MainMenu.xib 文件中新增菜单栏。可以通过拖拽 Utilities 中的控件或者复制粘贴已有的控件来实现;
  6. 将新增项的 Title 设为 “搜索”;
  7. 将新增项的 Key Equivalent 设为 

    ⌘ + F

     ;
  8. 选择 Connections Inspector;
  9. 如果通过复制粘贴创建控件,需要先删除任何已有的连接;
  10. 将 Sent Actions -> action 后的小圆圈连接至 First Responder,出现一个弹窗;
  11. 在弹窗中选择相应的方法,可以看到第四步中定义的 

    search:

     。

这样便实现了通过快捷键调用自定义方法。如果重新打开工程文件后发现第四步中的 User Defined 列表内容为空也不用紧张,之前的功能依然能够正常运行。

3. 重载 ESC 键对应的 Command

ESC 键对应的 Command 是 

cancelOperation:

 ,因此只需在对应的 ViewController 中添加相应重载代码:

override func cancelOperation(sender: AnyObject?) {  
    // Pseudo-code 
    if DisplayingSearchView {
        DisplayCurrencyListView        
    }
}                 func cancelOperation(sender: AnyObject?) {  
    // Pseudo-code 
    if DisplayingSearchView {
        DisplayCurrencyListView        
    }
}      

需要注意的是,在 View Controller 为 First Responder 时按键事件才会正确响应。

讨论

1. NSEvent 的 keyCode 是什么?

在讨论如何处理键盘事件的问题中,一般的回答是在 

keyDown:

 方法中通过 

theEvent.keyCode

 与固定的某些值进行判断。Apple 的 Guide 中却完全没有提到 

keyCode

 ,在文档中对这一属性的说明:

The property’s value is hardware-independent. The value returned is the same as the value returned in the kEventParamKeyCode when using Carbon Events.

可能在 Cocoa 调用 Carbon 键盘处理相关函数时需要用到 

keyCode

 属性进行转换。

2. NSEvent 的 Characters 是什么?

在判断是否按下 'A' 键时,常用的方法也有判断 

theEvent.characters

 或者 

theEvent.charactersIgnoringModifiers

 是不是 'A' 来实现。

这样的问题在于,如果切换了输入法之后,同一按键的值是有可能会变的:

override func keyDown(theEvent: NSEvent) {  
        print(theEvent.keyCode)
        print(theEvent.characters)
}

// Output when press 'A':
//
// U.S. Input Source
// 0
// Optional("a")
// 
// Georgian - QWERTY Input Source
// 0
// Optional("ა")
// 
// Cangjie Input Source
// 0
// Optional("日")                 func keyDown(theEvent: NSEvent) {  
        print(theEvent.keyCode)
        print(theEvent.characters)
}

// Output when press 'A':
//
// U.S. Input Source
// 0
// Optional("a")
// 
// Georgian - QWERTY Input Source
// 0
// Optional("ა")
// 
// Cangjie Input Source
// 0
// Optional("日")      

实际上由于 

characters

 发生变化,在代码中似乎并没有除了 

keyCode

 之外合适的途径来判断按下的究竟是哪个键了。

在 Menu 中设置的 Key Equivalent 则不受输入法的影响,可以正常工作,猜测这一方法最终实现依赖 

keyCode

 。

另外在常用软件 Sketch 中,切换了奇怪的输入法 Menu 中的快捷键也无法响应,猜测 Sketch 的 Menu 中的 Button 是自定义实现的,并且很有可能是用 characters 来判断按键事件。

终于写完了,Happy Coding :smile:。

参考

  • Handling Key Events
  • NSEvent Class Reference
  • NSResponder Class Reference
  • NSResponder Class Reference
  • objective c - NSTableView + Delete Key - Stack Overflow
  • Handling keyboard events in AppKit with Swift
  • Notes from a Swift developer: Adding menu items and their actions