原作:http://www.tuicool.com/articles/quEzuyJ
本文将介绍: Cocoa 中应用内(不包含全局快捷键)键盘事件处理路径,如何在路径的每个阶段重载相应的方法来处理事件,并且给出具体实现方法(基于 Swift 2.2)。
在 Mac 上完全通过键盘操控一个应用无疑是最愉悦的体验,在 CurrencyX 1.2 版本中,我们希望增加一些快捷键来提升用户体验:
在汇率列表中实现:
NSTableView
默认行为)。⌘ + F
跳转至开始搜索界面;在搜索汇率中实现:
我们只需要了解从 按键事件发生 到 某个 Responder 处理事件 过程中发生了什么,然后根据需求在合适的地方实现自定义的方法即可。
本文总结了按键事件处理相关要点,并且给出每一种快捷键我们使用的不同的具体实现方法(基于 Swift 2.2)。
敲下一个键后, NSApp
接收到一个按键事件( NSEvent
)。该 event 的 characters
即物理按键根据 键值绑定规则 中对应的字符串,可以用作唯一标识。
NSApp
需要根据按键事件的类型来做出不同的处理,而判断其类型的过程相对复杂。下图是一个按键事件在应用中真正被处理之前可能经过的路径:
按照 NSApp
对按键事件的处理顺序,有三个关键步骤,具体说明如下:
Key Equivalents
NSApp
向 Key Window 发送 performKeyEquivalent:
消息,并沿着视图层次(View Hierarchy) 向下传递 ( NSView
中该方法默认实现即向 subViews
依次发送该消息),直到某个对象返回 YES。NSApp
便将 performKeyEquivalent:
消息发送给 Menu 的控件,直到某个对象返回 YES。如果 Menu 中也没有对象处理事件,进行下一步。
Apple 的文档中不建议重载 performKeyEquivalent:
方法,建议利用如 NSButton
, NSMenu
, NSMatrix
, NSSavePanel
等默认实现了 performKeyEquivalent:
方法的控件,设置其 keyEquivalent
属性为对应的键值,控件在键值匹配时会自动触发点击事件。
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。
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。
了解系统处理按键事情的过程后,在每一步中都有不同的方法可以处理:
等价键判断
keyEquivalent
属性;设置 Menu 中 某些元素的 keyEquivalent
属性。
详见 Handling Key Equivalents
键盘界面控制
设置 View Hierarchy 中某些元素的 acceptsFirstResponder
返回 YES,影响 canBecomeKeyView
返回 YES,并通过设置 nextKeyView
和 previousKeyView
为 First Responder 设置 Key-view loop。
详见 Handling Keyboard Interface Control
键盘动作
重载 keyDown:
方法:
interpretKeyEvents:
并为按键绑定的 Command 提供自定义实现, 在重载 Command 的时候需要注意,很多方法 NSResponder
仅声明而没有实现,因此调用 super
会抛出异常 ;characters
与 NSEvent
定义的 常量 和 NSText
定义的 常量判断特殊按键,并直接调用自定义实现。详见 Overriding the keyDown: Method
我们决定通过三种方法实现期望的功能:
通过重载 keyDown:
实现:
通过 Menu Item 的快捷键设置实现:
⌘ + F
跳转至开始搜索界面。通过重载 ESC 绑定的 Command 实现
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 来实现组合快捷键有以下优点:
实现起来也很简单,在 First Responder 中实现自定义方法,并且通过 MainMenu.xib 将新增的 Menu Item 与 Responder 关联起来即可。 具体步骤如下:
ViewController
(任意 canBecomeFirstResponder
的类都可以)中实现的 search(sender: AnyObject?)
,那么只需要设置新增的一栏中 Action 为 search: (注意函数名后的冒号), Type 为默认的 id 即可;⌘ + F
;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:。