macOS 开发之 NSTextField 支持文本快捷键(一): 基本操作

在日常 macOS 开发中经常会用到 NSTextField 控件,但是会发现一个问题,如果开发的应用没有顶栏应用菜单,编辑控件中的文本内容的时候,按下文本操作快捷键无效,而得到的是系统警告音。本文就跟十里一起看一下如何让没有应用菜单的 app 中的 NSTextField 支持常用的文本操作快捷键。

  • 复制: Command + c
  • 剪切: Command + x
  • 粘贴: Command + v
  • 全选: Command + a

实现平台

  • macOS 10.14.3
  • swift 4.2.1
  • xcode 10.1

键盘事件

每个 app 都有自己的主循环线程(Main Run Loop),在这个循环中会遍历系统的事件消息队列,逐一发给对应的响应对象进行处理。具体来说就是 NSApp 的 sendAction:sendEvent: 方法发送事件消息给 NSWindow,NSWindow 再分发给具体的视图对象,最后由相应的事件响应方法进行处理。其中一种事件,就是这里要说的键盘事件,NSApp 对不同的键盘事件处理方式会不同,主要是以下三种:

  • 快捷键:如果按下快捷键,系统会先向当前的活动窗口菜单发送 performKeyEquivalent: 消息,此时窗口会依次遍历所有子视图控件按照响应链传递给 performKeyEquivalent: 的响应者。
  • 控制键:NSApp 会将消息转发给键盘窗口,完成不同的控件切换控制的功能
  • 其它按键:NSApp 会将消息转发给键盘窗口,窗口对象会先定位到第一响应对象,根据响应链优先级寻找对 keyDown 键盘事件响应的视图对象,如果可以找到就由相应对象处理,否则就会按照 insertText 方法进行处理(如果是个文本控件,就会插入相应键盘文本)

很明显,我们要实现本文的主题,关注快捷键的处理流程就可以了,其实 NSTextField 有上述 performKeyEquivalent: 消息的处理,只不过是没有任何功能实现而已,所以我们只需为 NSTextField 复写一下这个消息的处理即可,这个消息的处理返回值是一个布尔类型,返回 true 则标志着按键事件消息传递的结束。

新建 Demo 工程

为了更好的说明实现方法,我们还是要通过一个实际的Demo演示!新建的工程包含 storyboard ,新建完成后添加一个文本框控件,如果您对这些操作很熟悉可以跳过本小节。

  • 打开 Xcode,按下快捷键 Shift + Command + N 就会触发新建工程的导航窗口

  • 选择 macOS -> Cocoa App,点击 Next

  • 工程取名为 TextFieldKeyDemo,勾选 Use Storyboards,点击 Next 选择合适的目录,点击create 创建

  • 进入工程后,单击 Main.storyboard ,按下快捷键 Shift + Command + l 打开控件选择器,搜索 textfield ,将 Text Field 拖入 ViewController ,水平垂直居中放置:

运行工程

运行结果

点击快捷键 Command + r 运行工程,在打开的窗口中可以看到刚刚添加的 Text Field,在其中输入一些文本,然后试一下快件键,比如全选、复制、粘贴、剪切,发现都支持。

此时你会不会有疑问,我们并没有为 NSTextField 类复写 performKeyEquivalent: 方法呀,为什么文本框就支持这些快捷键了,原因是最终键盘信息消息传给了应用菜单,应用菜单中有关于文本编辑的快捷键的处理,那么问题又来了,为什么应用菜单就会对 Text Field 进行操作呢?主要两点:

  • 菜单栏上编辑一组中的操作正是对应文章一开头说的几个快捷键,每个操作对应都绑定了一个对象那就是 First Responder,比如 Copy 对应的绑定 action:

  • 当前活动窗口中的 First Responder 就是文本框

试验操作

现在打开工程,打开 Main.storyboard 文件,删除 菜单栏,就是下图标注的东西:

此时再次运行工程,在文本框中尝试使用快捷键,你就会发现如我们所想失效了!

快捷键加持

有时我们开发 app 就是不想保留菜单栏,比如状态栏小工具。所以继续研究怎么在没有菜单栏的情况下让 Text Field 支持快捷键是有必要的!很自然的我们就会想到上面说的为 NSTextField 复写 performKeyEquivalent: 方法的方式来解决!

这里直接贴出代码:

// NSTextField 自身支持快捷键
extension NSTextField {
    open override func performKeyEquivalent(with event: NSEvent) -> Bool {
        switch event.charactersIgnoringModifiers {
        case "a":
            return NSApp.sendAction(#selector(NSText.selectAll(_:)), to: self.window?.firstResponder, from: self)
        case "c":
            return NSApp.sendAction(#selector(NSText.copy(_:)), to: self.window?.firstResponder, from: self)
        case "v":
            return NSApp.sendAction(#selector(NSText.paste(_:)), to: self.window?.firstResponder, from: self)
        case "x":
            return NSApp.sendAction(#selector(NSText.cut(_:)), to: self.window?.firstResponder, from: self)
        default:
            return super.performKeyEquivalent(with: event)
        }
    }
}

上面的代码贴到合适位置即可,比如 AppDelegate.swift 文件 AppDelegate 类的后面。

可以看到上面的方法实现了四个快捷键的功能,分别是:全选、复制、粘贴和剪切,都是通过 NSApp 向文本框所在窗口的 First Responder(也就是文本框自己) 发起相应行为,而这些行为是 NStext 类的基本方法。另外:

  • event.charactersIgnoringModifiers: 除去修饰键剩余的按键

charactersIgnoringModifiers: The characters generated by a key event as if no modifier key (except for Shift) applies

  • 修饰键类型为 NSevent:ModifierFlags 类型,是一个集合,有 capsLock、shift、control、option、command、numericPad、help、function 和 deviceIndependentFlagsMask

运行工程,窗口中的 Text Field 支持四个快捷键了!但是此时有一个问题,将 Command 键替换为 Ctrl 按下快捷键也能实现相应文本操作,所以在 switch 语句之前加一个判断,如果修饰键不包含 Command 就按照正常响应:

if event.modifierFlags.isDisjoint(with: .command) {
    return super.performKeyEquivalent(with: event)
}
  • event.modifierFlags.isDisjoint(with: .command): 判断快捷键中的修饰键(modifier)是不是不包含 Command 键,不包含就返回 true

isDisjoint: Returns a Boolean value that indicates whether the set has no members in common with the given set.

此时,还是不完美的,如果按下文本操作快捷键的同时按下了其它有效修饰键(比如 Ctrl) 同样可以完成文本操作,目前还没想到其他更好的处理方式!后面有所发现就会更新这里!

不知道您会不会有疑问:怎么不支持撤销和重做两个快捷键?不是不做,撤销重做 操作实现是挺大的一块内容,所以放到这个主题的第二篇文章介绍。

本文工程下载:TextFieldKeyDemo

参考