现代可可观点中的一项习题

最初发布在这里: https //avaidyam.github.io/2018/03/22/Exercise-Modern-Cocoa-Views.html

就像我在上一个“片段”中所说的那样,最近,我想开始为Parrot添加“热键”支持,并且意识到我不喜欢任何现有解决方案:

  1. 使用MASShortcut尝试并证明可在ObjC-land工作的MASShortcutShortcutRecorder
  2. 使用新的但未经验证的Swift热键记录器(那里有一些)。
  3. 自己动手(可能以某种方式使用危险的专用SPI)而不进行任何测试!

显然,我选择了#3。

在这里,我们进入了激动人心的传奇的第二部分,您在其中见证了我打破AppKit和WindowServer并引起从事上述组件工作的工程师的愤怒和愤怒。 我希望你带了爆米花。

我想澄清一下,我们将只构建一个管理单个快捷方式的独立控件。 我希望Parrot与大多数应用程序一样,将不需要像Final Cut Pro这样的命令编辑器,该命令编辑器是专门为视频编辑器而专门设计的,该视频编辑器具有两个以上的键盘和两个以上的两只手可以匹配。

我们的最终结果将是看起来,听起来和感觉像这样,而不是:

在进入精妙的手工对编程之前,如果您想直接跳转到带注释的最终源代码,我将在此处提供一个快照。 这是整个最终产品,精确到1500 LOC,包括之前“ episode”中的代码-相应的键盘快捷键功能! 如果您认为这应该是正式的存储库而已,请通过Twitter或Github @avaidyam与我联系!

现代观点原理

子类化ViewViewController

我们应该回答的第一个问题是,我们要构建什么样的组件? 这个问题的答案是什么决定了我们是什么样的子类,以及我们与应用程序中其他组件的交互模型。

当您构建一个采用程序输入并向用户显示内容的分立组件时,对于控件而言,当您接收用户输入并显示程序输出时,您应该将View子类化。 除此之外的任何东西都应该是ViewController的子类,介导其他组件之间的交互的组件,或处理模型/数据库对象的组件,或者实际上只是其他任何东西。 View应该可以在您要立即构建的环境之外的其他环境中轻松重用,但是ViewController不一定能够履行此合同。

例如,联系人头像最好是View子类,但是联系人头像选择器应该是ViewController ,因为它不仅显示头像图像,而且还允许用户选择图像,并且可以处理与头像的同步。联系人存储(可能是JSON文件,可能是远程API,或者可能是CNContact )。

在macOS上,如果要构建的组件既包含其他组件(例如ViewController )又包含在Window ,则还应考虑将WindowController子类化-联系人头像选择器可能不符合此条件,但需要联系人编辑器面板联系人头像选择器将是一个ViewController嵌套在姓名,电话,电子邮件和其他可编辑字段旁边。

层数

虽然与年轻的兄弟姐妹UIKit相比, AppKit通常被视为恐龙,但重要的是要知道, UIKit所使用的几乎所有设计模式几乎都来自AppKit ,如果不是,它们最终会回到AppKit ,除了极少数。 例如,由底层CALayer类型支持的UIView的概念与由NSCell支持(而是过去?)的NSControl非常相似。 不同之处在于CALayer实际上是驱动显示内容的呈现而不是UIView的呈现,并且NSCell充当特定事件处理和绘制的“橡皮图章”。

当涉及到层支持的视图时, AppKit实际上有几种模式可以实现此目的:

  1. 层托管:
  • self.layer = CALayer(); self.wantsLayer = true
  • NSView拥有CALayer ,并负责创建和管理它。 NSView除了创建其渲染表面外不执行任何操作。
  1. 隐式层支持和绘制:
  • superview.wantsLayer = true
  • NSView有一个显式的层支持父级,并会使用层支持的父级CALayer的渲染表面为自己授予私有CALayer进行绘制。
  1. 隐式Superview层绘制:
  • superview.wantsLayer = true; superview.canDrawSubviewsIntoLayer = true
  • 如果您希望使用动画或任何与图层相关的属性,则必须选择不使用它!
  • NSView具有显式层支持的父对象,其canDrawSubviewsIntoLayer为true,因此不会canDrawSubviewsIntoLayer自身授予CALayer ,而是直接吸引到层支持的父对象的CALayer
  1. 显式层支持和绘制:
  • self.wantsLayer = true
  • NSView已明确声明自己是支持图层的,并将通过其drawRect:调用(即直接绘制到图层中)的结果来设置其图层的contents
  • 如果此视图声明其canDrawSubviewsIntoLayer ,则其子视图将呈现到该视图的layer
  1. 显式层支持:
  • self.wantsLayer = true; self.wantsUpdateLayer = true; self.layerContentsRedrawPolicy = .onSetNeedsDisplay
  • 注意:实际上,您必须重写 自定义 NSView 子类中 wantsUpdateLayer 函数, 以实现此目的,因为该属性没有设置程序。 如果视图被标记为脏,则在每个视图更新周期都会查询此属性–在此不要尝试任何复杂的计算。
  • NSView已明确声明自己为支持层,并且不会将drawRect:插入该层。 取而代之的是,在updateLayer方法期间,该层是手动管理的, 但不属于subview
  • 在此处将layerContentsRedrawPolicy设置为.onSetNeedsDisplay是很重要的,因为layer.contents不再依赖于您的drawRect: layer.contents

注意:在我所说的“渲染表面”中,底层类型是 CAContext ,由WindowServer使用它来渲染进程外 CAViewRef ,也可以 CAViewRef 用作 CA::Render::* 的包装器。 CA::Render::* ,它是在进程中呈现的,但可能不在主线程中。

就个人而言,我认为NSView.layer属性不应该是公开的。 相反,如果是层托管,则视图有责任对其层保持强烈引用,并且在显式的updateLayer视图中, updateLayer方法签名应为- (void)updateLayer:(CALayer *)layer ,以便传递AppKit拥有的层,以更新视图。 太多的iOS开发人员误认为NSViewUIView相同,视情况而定, NSViewUIView几乎正交。 UIView的层所有权模型由层托管最紧密地表示,但是在AppKit中,这意味着不为您管理层层次结构和其他视图功能,因此,最接近它的将是显式的层支持。 这就是我们将采用的方法。 看来您将无法使用drawRect:但是有一些解决方法,我们将很快看到。

NSControl

我们需要解决的下一个问题是,我们应该设计NSControl还是NSView的子NSView ? 在这种特定情况下,答案很简单,因为我们正在设计控件,利用NSControl最有意义……毕竟这就是名称。

但是,您需要考虑以下几点:

  • NSCell 因为此类已被软弃用,所以可以安全地假定可以在没有相应单元格的情况下编写NSControl (扰流器警报:是)。
  • 多态 *Value 属性: NSControl具有多个*Value属性,例如objectValuetake*ValueFrom(_:)方法,与之配合使用,可在Interface Builder和Cocoa Bindings中使用。 从我的经验来看,这些功能实际上并没有太大帮助,如果确实如此,那么为NSView类重新实现它们并不难。
  • 字段编辑器:这是NeXT时代的另一个想法,其中不是将一百个重量级的NSTextView加载到窗口中,这会杀死性能和内存,您只需加载一个,并在一百个轻量级NSTextFieldCell共享即可s。 这在2018年并没有太大的改变,特别是因为您的内存和性能竞争是Electron应用程序: 只要您不使用Electron(或不使用JS开发用于桌面应用程序的开发),您就可以获胜。
  • 目标/动作模型:这就是我们知道并喜欢NSControl (和UIControl )的原因! 控件有一个目标,并且以某种方式激活该控件时会在该目标上触发操作。 UIControl更进一步,允许您为不同的事件类型注册不同的目标,但是NSControl提供了一个条件设置的sendAction(on:)版本。 这是有争议的,它更方便。

总而言之,有几个使用或不使用NSControl理由,但是在权衡了选项之后,作为“控件”的遵循标准,只是已经为您实现了目标/动作模型,这是非常有帮助的,因此我们会的。

使用CGSKeyboardShortcut

可读字符串表示

在开始构建面向用户的控件之前,我们需要确保CGSKeyboardShortcut和好友可以转换为人类可读的字符串表示形式。 这是因为组成快捷键(如⌘D修饰符和虚拟键码是与设备无关的机制,并且注册快捷键时,我们不能使用包含快捷键的字符串。 重要的是要注意,虚拟键码不是ASCII顺序的(即,在ASCII表中是顺序的),并且可能并不总是具有对应的字形来匹配键。 要确定虚拟键盘代码在当前键盘上指向的内容,不幸的是,我们必须返回Carbon.framework进行文本输入服务TIS )和Unicode实用程序UC )。 要转换键代码,我们获取当前输入源的键盘布局,并使用UCKeyTranslate工具确定在当前布局中按此键所产生的unicode字符是什么。 有一些特殊情况,例如打印F13 Fn键或我们要打印Space的空格键。

例如, NSMenu NSButton 使用的可可键等效物 在内部将包含 "x" 的字符串 转换为正确的虚拟键代码。 这留给读者练习。 提示:只需创建一个反向映射表并查找它!

 public extension CGKeyCode { 
public var isFunctionKey: Bool {
switch Int(self) {
case kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8,
kVK_F9, kVK_F10, kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15,
kVK_F16, kVK_F17, kVK_F18, kVK_F19, kVK_F20:
return true
default:
return false
}
}
  public var characters: String { 
if let special = CGKeyCode._special[Int(self)] { return special }

let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource().takeUnretainedValue()
let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData)
let dataRef = unsafeBitCast(layoutData, to: CFData.self)
let keyLayout = unsafeBitCast(CFDataGetBytePtr(dataRef), to: UnsafePointer.self)

let keyTranslateOptions = OptionBits(CoreServices.kUCKeyTranslateNoDeadKeysBit)
var deadKeyState: UInt32 = 0
let maxChars = 256
var chars = [UniChar](repeating: 0, count: maxChars)
var length = 0

let error = CoreServices.UCKeyTranslate(keyLayout, self,
UInt16(CoreServices.kUCKeyActionDisplay),
0, UInt32(LMGetKbdType()),
keyTranslateOptions, &deadKeyState,
maxChars, &length, &chars)

if error != noErr { return "" }
return NSString(characters: &chars, length: length).uppercased
}
private static var _special: [Int: String] = [...]
}
 public extension CGEventFlags { 
public var characters: String {
var string = ""
if self.contains(.maskAlphaShift) { string.append("⇪") }
if self.contains(.maskHelp) { string.append("?⃝") }
if self.contains(.maskControl) { string.append("⌃") }
if self.contains(.maskAlternate) { string.append("⌥") }
if self.contains(.maskShift) { string.append("⇧") }
if self.contains(.maskCommand) { string.append("⌘") }
return string
}
}
 public extension NSEvent.ModifierFlags { 
public var characters: String {
return CGEventFlags(self).characters
}
}

此代码的部分版权归©2016 Shunsuke Furubayashi, Magnet 库的 作者

一个更简单的快捷方式表示

因为我们不想将自己锁定在CGSKeyboardShortcut ,并且我们不完全管理诸如identifierCGSKeyboardShortcut状态,它们是应用程序内部的内在函数,所以我们将定义一个新的更简单的类型: KeyboardShortcutView.Pair = (CGKeyCode, CGEventFlags) ,并创建两个包装函数以将其与NSDictionary 。 这很重要,因为我们需要能够对其进行编码和解码以进行状态恢复和NSCoding

 open class KeyboardShortcutView: NSControl, ... { 
public typealias Pair = (keyCode: CGKeyCode, modifierFlags: CGEventFlags)
}
 public func representation(of pair: KeyboardShortcutView.Pair?) -> NSDictionary? { 
guard let pair = pair else { return nil }
return [
"keyCode": pair.keyCode,
"modifierFlags": pair.modifierFlags.rawValue
] as NSDictionary
}
public func representation(of dict: NSDictionary?) -> KeyboardShortcutView.Pair? {
guard let dict = dict as? [String: Any],
let kc = dict["keyCode"] as? CGKeyCode,
let mf = dict["modifierFlags"] as? CGEventFlags.RawValue
else { return nil }
return (kc, CGEventFlags(rawValue: mf))
}

这样,我们就可以与其他键盘快捷键库或非Swift对象(例如UserDefaults(回想一下,元组不能符合 Codable !)

做个好公民

我们将让记录器控件编写一个标签( NSTextField )和一个记录/停止按钮( NSButton ):

 open class KeyboardShortcutView: NSControl, ... { 
  /*@objc*/ open weak var delegate: KeyboardShortcutViewDelegate? { 
willSet { self.willChangeValue(forKey: #function) }
didSet { self.didChangeValue(forKey: #function) }
}

@objc open override var alignment: NSTextAlignment {
get { return self.textLabel.alignment }
set { self.textLabel.alignment = newValue }
}

@objc open var tintColor: NSColor = .keyboardFocusIndicatorColor {
didSet { /* ... */ }
}
@objc open var placeholderString: String? {
get { return self.textLabel.placeholderAttributedString?.string }
set {
let style = NSMutableParagraphStyle()
style.alignment = self.alignment
let attr = NSAttributedString(string: newValue ?? "", attributes: [
.foregroundColor: NSColor.secondaryLabelColor,
.paragraphStyle: style
])
self.textLabel.placeholderAttributedString = attr
}
}

@objc open override var isEnabled: Bool {
didSet { /* .. */ }
}

open override var isHighlighted: Bool {
// ...
}

private lazy var clearButton: NSButton = {
let button = NSButton()
button.wantsLayer = true
button.translatesAutoresizingMaskIntoConstraints = false
button.image = NSImage(named: .stopProgressTemplate)
button.bezelStyle = .texturedRounded // for template image rendering
button.setButtonType(.momentaryChange)
button.isBordered = false
button.title = ""
button.target = self
button.action = #selector(self.buttonAction(_:))
return button
}()

private lazy var textLabel: NSTextField = {
let label = NSTextField()
label.wantsLayer = true
label.translatesAutoresizingMaskIntoConstraints = false
label.isEditable = false
label.isSelectable = false
label.isContinuous = false
label.isEnabled = true
label.textColor = self.tintColor
label.backgroundColor = .clear
label.refusesFirstResponder = true
label.drawsBackground = false
label.isBezeled = false
label.lineBreakMode = .byClipping
label.setValue(true, forKey: "ignoreHitTest")
return label
}()
  public override init(frame frameRect: NSRect) { 
super.init(frame: frameRect)
self.commonInit()
}
  public required init?(coder: NSCoder) { 
super.init(coder: coder)
self.commonInit()
}

private func commonInit() {
self.wantsLayer = true
self.layerContentsRedrawPolicy = .onSetNeedsDisplay
self.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.clearButton)
self.addSubview(self.textLabel)
self.isEnabled = true // NSControl.isEnabled is false by default.
self.alignment = .center
}
}

这段代码是非常基本的子视图设置,并且我们在视图中使用了延迟初始化,但是没有必要这样做。 此外,我们添加或覆盖了一些转发到subviewssuperclass属性。 记住要始终设置wantsLayerlayerContentsRedrawPolicy 并且绝对不要忘了 translatesAutoresizingMaskIntoConstraints 否则您会哭泣。 注意,我们没有在commonInit()碰到约束,我将尽快解释原因。

说到这一点,在commonInit()和初始值设定项本身中调用setter有一个特殊的副作用:它允许我们在属性上调用didSet ! 请注意,并仔细检查didSet是否没有意外的副作用。 不过请注意属性中的will/didChangeValue -我们使用#function作为键路径,但这不是我们在Swift 4.1中获得的神奇的Swift KeyPath (例如\.isRecording )! 是什么赋予了? 实际上,这是因为我们无法将属性标记为@objc因为它们无法在ObjC中表示。 解决方法是,我们可以使ObjC KeyPath正常工作,但这绝不是一个好的解决方案。 如果您有更好的解决方案,请告诉我!

请注意,我们必须将placeholderString NSAttributedString包装在NSAttributedString因为默认标签颜色太暗/半透明,因此很难阅读。 这会导致意外的非更新效果:应该在设置placeholderString之前设置alignment ,因为我们不会在updateLayer更新placeholderStringparagraphStyle updateLayeralignment ,因此placeholderString如果仅设置一次,则使用为其赋予的确切值那时候。

有关子视图的更多注意事项:

  • ignoreHitTest专用SPI ! 但是,我们需要它,因为没有此标志,标签将窃取我们的firstMouse / responder状态。 如果您不想这样做,请子类NSTextField并覆盖hitTest:以返回nil 。 它具有相同的效果。
  • 要在当前NSAppearance渲染模板图像(即isTemplatetrue或名称以Template结尾的NSImage ), NSButton必须为纹理样式。 这会在按钮周围增加一些填充,因此在使用约束时我们将考虑到这一点。

由于我们还将使用NSControl目标/动作模型,因此我们的delegate主要应该用于决策。 如果我们想专注于控件状态的通知,最好使用NSNotificationCenter ,某些Cocoa文本类可以这样做。

 public protocol KeyboardShortcutViewDelegate: class { 
func keyboardShortcutViewShouldBeginRecording(_ keyboardShortcutView: KeyboardShortcutView) -> Bool
func keyboardShortcutView(_ keyboardShortcutView: KeyboardShortcutView,
canRecordShortcut shortcut: KeyboardShortcutView.Pair) -> Bool
func keyboardShortcutViewDidEndRecording(_ keyboardShortcutView: KeyboardShortcutView)
}

那里-现在我们有了一个非常有效的API客户端界面,可以确定控件在给定情况下应执行的操作以及可视化的基础。

接受键视图

控件的重要部分是允许用户激活和停用它,我们只需在控件范围之内或之外单击即可。 成为关键视图或第一响应者非常容易:

 open override var acceptsFirstResponder: Bool { 
return self.isEnabled
}
 open override var canBecomeKeyView: Bool { 
return super.canBecomeKeyView && NSApp.isFullKeyboardAccessEnabled
}
 open override var needsPanelToBecomeKey: Bool { 
return true
}
 open override func acceptsFirstMouse(for event: NSEvent?) -> Bool { 
return true
}
 open override func performClick(_ sender: Any?) { 
self.window?.makeFirstResponder(self)
}

这就是您需要做的! 现在,在控件内单击将为我们提供关键输入,我们将在下一部分中重点介绍。

响应用户输入

这是控件的内容:实际记录快捷方式。 我们应该做的第一件事是正确地将isEnabledisHighlightedisRecording属性连接在一起。 我们不应该设置突出显示状态,因为这是用户要做的。 相反,将其吸气剂连接到isRecording 。 这两个主要属性是shortcutinputModifiers ,我们将使用后者来跟踪用户输入快捷方式时设置了哪些修饰符标志。 设置shortcut ,我们将使用NSControl目标/操作机制将action发送到target 。 如果我们没有定义的target (即nil ),则消息会自动沿着响应者链上行! 如果客户端(而不是用户)直接设置shortcut ,它仍然会触发操作,因此请记住这一点–解决方案是将其包装在if self.isRecording ,但在这种情况下,我们可能想要的行为。

 @objc open override var isEnabled: Bool { 
didSet {
if !self.isEnabled { self.endRecording() }
}
}
 open override var isHighlighted: Bool { 
get { return self.isRecording }
set { }
}
 @objc open private(set) var isRecording = false { 
willSet { self.willChangeValue(forKey: #function) }
didSet { self.didChangeValue(forKey: #function) }
}
/*@objc*/ open var shortcut: KeyboardShortcutView.Pair? {
willSet {
guard self.shortcut?.keyCode != newValue?.keyCode &&
self.shortcut?.modifierFlags != newValue?.modifierFlags else { return }
self.willChangeValue(forKey: #function)
}
didSet {
guard self.shortcut?.keyCode != oldValue?.keyCode &&
self.shortcut?.modifierFlags != oldValue?.modifierFlags else { return }

_ = self.sendAction(self.action, to: self.target)

// ...
self.didChangeValue(forKey: #function)
}
}
 private var inputModifiers: NSEvent.ModifierFlags = [] { 
didSet {
// ...
}
}

现在,我们必须实际处理鼠标激活和键盘事件:

 open override func mouseDown(with event: NSEvent) { 
guard self.isEnabled else {
super.mouseDown(with: event); return
}
  let locationInView = self.convert(event.locationInWindow, from: nil) 
if self.mouse(locationInView, in: self.bounds) && !self.isRecording {
self.inputModifiers = []
_ = self.beginRecording()
} else {
super.mouseDown(with: event)
}
}

如果启用了此功能,并且鼠标在我们的范围之内,请重置记录状态并开始记录。 接下来,如果我们的flagsChanged(with:)被调用,请更改输入修饰符的状态(我们将很快介绍其在UI中的反映方式)。 编写快捷方式记录器的简单之处在于,我们可以使用performKeyEquivalent(with:) ! 当按下“等效按键” (阅读:“键盘快捷键”) ,我们是第一响应者时,就会调用它。 基本上,这已经为我们完成了所有工作,我们只需要使用委托和结束记录来验证新的快捷方式。

 open override func performKeyEquivalent(with event: NSEvent) -> Bool { 
if !self.isEnabled || !self.isRecording { return false }
if self.window?.firstResponder != self { return false }
let shortcut = (keyCode: CGKeyCode(event.keyCode),
modifierFlags: CGEventFlags(event.modifierFlags))

// inline validation func if we have no delegate response:
func validate() -> Bool {
guard !shortcut.keyCode.isFunctionKey else { return true }
return !shortcut.modifierFlags.intersection(.maskUserFlags).isEmpty
}

let del = (self.delegate ?? self.target as? KeyboardShortcutViewDelegate)
if del?.keyboardShortcutView(self, canRecordShortcut: shortcut) ?? validate() {
self.shortcut = shortcut
self.endRecording()
return true
}
return false
}
 open override func flagsChanged(with event: NSEvent) { 
self.inputModifiers = self.isRecording ? event.modifierFlags : []
super.flagsChanged(with: event)
}

如果没有delegate ,我们将检查target是否可以成为我们的delegate -这主要是方便,有点像您有一个delegate和一个用于集合或表视图的dataSource ,但通常是实现这两者的视图控制器。 如果delegatetarget都不能帮助我们验证输入,则只要它不是单个非Fn键(即您不应该仅将Q作为热键),我们就允许它通过。会导致很多问题!)。

 open override func keyDown(with event: NSEvent) { 
guard !self.performKeyEquivalent(with: event) else { return }
super.keyDown(with: event)
}
 open override func resignFirstResponder() -> Bool { 
self.endRecording()
return true
}

performKeyEquivalent(with:) ,我们已经完成了事件处理:如果收到杂散的keyDown(with:)消息,只需调用performKeyEquivalent(with:) ,因为在前面所述的情况下可能会发生这种情况,其中单个非Fn键被按下。 同样, 允许这样做是不好的做法,并且会干扰系统键盘输入! 但是我们要判断谁呢?

记录动作主要发生在beginRecording()endRecording() ; 我们可以将这些方法的主体放置在isRecording.didSet但这将是非常blo肿且不合适的代码样式。 确保正确处理状态并防止对这些方法的过分调用,因为这可能导致意外的用户可见状态泄漏。

 public func beginRecording() -> Bool { 
if !self.isEnabled { return false }
if self.isRecording { return true }

let del = (self.delegate ?? self.target as? KeyboardShortcutViewDelegate)
guard del?.keyboardShortcutViewShouldBeginRecording(self) ?? true else {
NSSound.beep(); return false
}
self.isRecording = true
return true
}
 public func endRecording() { 
if !self.isRecording { return }
self.inputModifiers = []
self.isRecording = false

if self.window?.firstResponder == self && !self.canBecomeKeyView {
self.window?.makeFirstResponder(nil)
}
let del = (self.delegate ?? self.target as? KeyboardShortcutViewDelegate)
del?.keyboardShortcutViewDidEndRecording(self)
}

这非常不言自明,因为我们要做的就是咨询(或通知) delegate / target ,如果没有可用的记录,则回退到允许我们记录的假设。 当我们resignFirstResponder()endRecording() ,我们最终调用了相反的方法,但是由于NSWindow.makeFirstResponder(_:)为我们进行了验证,因此这些调用不会重新进入。 最后,我们只需要连接clearButton动作来执行特定于上下文的操作:如果我们不进行记录但没有设置的shortcut ,则beginRecording() ; 如果我们有设置的shortcut ,请清除它并结束录制。 否则,我们已经在录制– endRecording()而不会丢失先前设置的shortcut (如果有)。

 @objc private func buttonAction(_ button: NSButton) { 
if !self.isRecording && self.shortcut == nil { // cleared state
self.window?.makeFirstResponder(self)
_ = self.beginRecording()
} else if self.isRecording && self.shortcut != nil { // cleared state
self.endRecording()
} else {
if self.shortcut != nil { self.shortcut = nil }
self.endRecording()
}
}

updateLayer

让我们花一点时间来提供便捷功能,以可视方式向用户呈现整个快捷方式:

 private var stringRepresentation: String { 
var modifiers: NSEvent.ModifierFlags = self.inputModifiers
if self.isRecording {
return modifiers.characters
} else {
if let shortcut = self.shortcut {
modifiers.formUnion(NSEvent.ModifierFlags(shortcut.modifierFlags))
}
return modifiers.characters + (self.shortcut?.keyCode.characters ?? "")
}
}

它不是太多:如果正在录制,请以字符串形式返回输入修饰符,但如果不是,则使用shortcutmodifier.characters 字符输入修饰符以及keyCode作为字符串最后。 这将与NSMenuItem看到的规范字符串表示形式匹配。 由于我们显式地支持图层并请求updateLayer ,因此我们绝对应该在此处控制视觉外观:

 open override var allowsVibrancy: Bool { 
return false
}
 open override var wantsUpdateLayer: Bool { 
return true
}
 open override func updateLayer() { 
// ...
  self.textLabel.textColor = self.isEnabled ? self.tintColor : .disabledControlTextColor 

let str = self.stringRepresentation
self.textLabel.stringValue = str
self.toolTip = Localized.tooltipPrefix + ": " + (str.isEmpty ? Localized.noShortcut : str)
+ "\n\n" + Localized.help

self.clearButton.isEnabled = self.isEnabled
let canStop = self.isRecording || self.shortcut != nil
self.clearButton.image = NSImage(named: canStop ? .stopProgressFreestandingTemplate
: .statusUnavailable)
}

您会注意到我们也在此处设置了tooltip 。 每个视图都这样做很重要,或者如果我们使用不同的工具提示来绘制特定区域,则可以使用addToolTipRect...和朋友; 这些是基于鼠标悬停的上下文线索,可提供有关鼠标下方视图的内容的信息,并且没有iOS等效的线索。 现在,请忽略Localized...值,我们将在后面讨论。

self.needsDisplay = true调用添加到属性的didSettintColorshortcutisEnabledinputModifers 。 这会将视图标记为需要updateLayer() ,然后我们可以在其中更新视觉外观。

一个人画一个按钮

在设计和实现新控件时,重要的事情是始终看起来和感觉像系统控件! 当某个应用程序不执行此操作时,这对于用户来说是非常明显的,它给人以廉价或设计不佳的应用程序的感觉,从而可能导致用户不满意。 对于此控件,让我们就像按钮和文本输入之间的混合,因为我们就像两者之间的混合。 一个已经具有这种外观的控件的很好的例子是NSSearchField中的NSToolbar (在常规NSView ,它看上去是凹进的,但在工具栏中,它具有按钮状的外观)。 但是,相反,我们将直接使用NSButtonCell窃取NSButton的外观。 就像我在本文前面所说的那样, NSCell封装了事件处理视觉外观,而不是实际视图! 我们可以非常简单地在控件的所有实例之间共享一个按钮单元,然后使用drawBezel(withFrame:in:)方法将其绘制到updateLayer()的子层中。 确保将子层添加到我们的层次结构中,并设置正确的bezelStyle

 private static var stampCell: NSButtonCell = { 
let c = NSButtonCell()
c.bezelStyle = .texturedRounded
return c
}()
 private lazy var underlayer = CALayer() 
 private func commonInit() { 
// ...
self.layer?.addSublayer(self.underlayer)
// ...
}
 open override func updateLayer() { 
var b = self.bounds.size; b.height = 22
let img = NSImage(size: b, flipped: false) { r in
KeyboardShortcutView.stampCell.drawBezel(withFrame: r, in: self)
return true
}

CATransaction.begin()
CATransaction.setDisableActions(true)
self.underlayer.contents = img
self.underlayer.contentsScale = self.layer!.contentsScale // inherit
self.underlayer.contentsCenter = CGRect(x: 0.25, y: 0.25, width: 0.5, height: 0.5)
CATransaction.commit()
  // ... 
}
 open override func layout() { 
super.layout()
  CATransaction.begin() 
CATransaction.setDisableActions(true)
self.underlayer.frame = self.layer!.bounds
CATransaction.commit()
  // ... 
}

-[NSButtonCell updateLayerWithFrame:inView:] 实际上使用私有的 NSLayerContentsFacet 和CoreUI来优化图形 CALayer 内容,但这对我们来说不是问题。

如果我们可以使用drawRect:可能会更直接,但是如果underlayer框架和content大小不匹配,我们将失去设置CALayer.contentsCenter的能力,该CALayer.contentsCenter会自动为我们分9个部分进行图像切片。 您可能会注意到,如果只设置layer.frame ,它会设置动画并看起来很奇怪,因为它捕捉到一个新的框架中,我们不希望发生这种情况。 为了解决这个问题,我们只需要打开一个显式的子事务,该子事务将禁用CALayer动作。 避免frame隐式动画的另一种可能方法是修改图层的actions字典:

 var implicits = layer.actions 
implicits["position"] = NSNull()
implicits["bounds"] = NSNull()
layer.actions = implicits

另一个小视觉问题是,除非我们使用NSAppearance.current告诉它,否则我们正在绘制的NSCell不了解控件的effectiveAppearance NSAppearance.current

 open override func updateLayer() { 
// ...
let img = NSImage(size: b, flipped: false) { r in
self.effectiveAppearance.using {
KeyboardShortcutView.stampCell.drawBezel(withFrame: r, in: self)
}
return true
}
// ...
}
 // ... 
 public extension NSAppearance { 
public func using(_ handler: () -> ()) {
let x = NSAppearance.current
NSAppearance.current = self
handler()
NSAppearance.current = x
}
}

现在,如果将整个windowappearance为,例如, vibrantDark ,则无论该外观大小vibrantDark ,控件都将完美呈现! (对我来说有点困惑的是, NSButton不会拉伸他们的图形,但是哦。)

调焦环

现在,作为可以在其window成为关键和第一响应者的控件,我们应该使用聚焦环通知用户我们已经这样做了。 在macOS Lion之后,我们不再需要自己绘制它们,而是提供它们的边界和蒙版以匹配我们视图的形状。 让我们只要求我们的图层将自身渲染蒙版,因为只有alpha通道用于蒙版!

 open override var focusRingMaskBounds: NSRect { 
return (self.isEnabled && self.window?.firstResponder == self) ? self.bounds : .zero
}
 open override func drawFocusRingMask() { 
guard self.isEnabled && window?.firstResponder == self else { return }
self.underlayer.render(in: NSGraphicsContext.current!.cgContext)
}

现在,我们只需要向isEnabled.didSet添加一个self.noteFocusRingMaskChanged()调用,即可自动显示和隐藏聚焦环。 CALayer.render(in:)在大多数情况下不是一个好主意,因为它实际上使用CG渲染路径,而不是Core Animation可以用来加速GPU上的图层的OGLMetal渲染路径,因此实际上层实际外观的视觉近似。 尽管这实际上并没有打扰我们,因为我们只需要正确的形状,但是在设计带有子图层的视图时通常要记住这一点。

layoutupdateConstraintsintrinsicContentSize

因为我们有子层和子视图,所以我们应该在这里调整它们的布局。 我们已经照顾了underlayer ,所以现在让我们动态调整font大小以匹配控件的高度。 令人惊讶的是,这并不简单,因为可以在NSFontManager而非NSFont上找到执行此操作的工具,但是使用该工具非常容易。

 open override func layout() { 
// ...

if let font = self.font {
self.textLabel.font = NSFontManager.shared.convert(font, toSize: self.bounds.height / 1.7)
} else {
self.textLabel.font = NSFont.systemFont(ofSize: self.bounds.height / 1.7)
}
}

这是我们最终设置并处理约束的地方! 我们将使用约束来处理子视图,因为子层只需要匹配我们的框架。 但是,我们可以将NSLayoutGuide添加到控件中,在该指南上设置约束,然后将其框架同步到子层框架,但这并不值得。

 open override class var requiresConstraintBasedLayout: Bool { 
return true
}
 private var childConstraints: [NSLayoutConstraint] = [] 
 open override func updateConstraints() { 
if self.childConstraints.count == 0 {
self.childConstraints = [
self.textLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4.0),
self.textLabel.trailingAnchor.constraint(equalTo: self.clearButton.leadingAnchor, constant: 0.0),
self.clearButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0.0),
self.clearButton.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 1.0),
self.clearButton.widthAnchor.constraint(equalTo: self.clearButton.heightAnchor, multiplier: 1.0),
self.textLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: -1.0),
self.clearButton.centerYAnchor.constraint(equalTo: self.centerYAnchor),
]
NSLayoutConstraint.activate(self.childConstraints)
}
super.updateConstraints()
}

要点是, updateConstraints()称为初始化后,每当我们设置self.needsUpdateConstraints = true ,如果我们没有配置任何约束,我们将缓存约束并激活它们。 在这种方法中这样做很有帮助,因为将来我们可能希望动态调整约束,我们可以按照此处的布局和显示周期来进行调整。

作为良好的公民控件,我们应该做的最后一件事是提供一个intrinsicContentSize :这是我们的控件在不添加任何大小限制的情况下将占用的大小。 通常,它只是控件内容的最小边界框,我们可以将其总和取到我们的textLabelclearButton 。 由于它们可能会提供.noIntrinsicMetric ,并且我们的控件确实具有宽度的内在度量(实际上, textLabelclearButton都具有),因此我们应该谨慎clearButton这种情况。 我们也将从上面的约束中考虑填充。

 open override var intrinsicContentSize: NSSize { 
var _t = self.textLabel.intrinsicContentSize
var _b = self.clearButton.intrinsicContentSize
if _t.width == NSView.noIntrinsicMetric { _t.width = 0.0 }
if _b.width == NSView.noIntrinsicMetric { _b.width = 0.0 }

// Compute the sum/max of the intrinsicContentSizes of our subviews.
return NSSize(width: _t.width + _b.width + 12.0 /* padding */,
height: max(_t.height, _b.height) /* 22.0? */)
}

我们还可以采用NSButton和类似控件所采用的方法,即将固有大小限制为22px的高度。 对于带有光标的平台,对于包含标签的可单击控件,“正确高度”为22px ;对于依赖触摸输入的平台,其44px44px之间。 (来自某处的Apple HIG。)

悬停和游标

为了支持任何类型的光标更改或鼠标悬停,我们需要使用NSTrackingArea以及NSView用于添加和删除它们的相关方法。 不建议在其中使用任何带有TrackingRectCursorRect的方法-自Leopard以来,它们已被NSTrackingArea取代。

 private func commonInit() { 
// ...
self.addTrackingArea(NSTrackingArea(rect: .zero,
options: [.activeInKeyWindow, .inVisibleRect, .cursorUpdate],
owner: self, userInfo: nil))
}
 open override func cursorUpdate(with event: NSEvent) { 
NSCursor.pointingHand.set()
}

我们甚至不必实现updateTrackingAreas()即可处理变化的框架或其他任何事情。 当包含视图的窗口为键时, cursorUpdate(with:)确保仅在视图的可见框架中调用cursorUpdate(with:) 。 简直太简单了; 如果我们想支持鼠标悬停,以显示建议或以某种方式更改我们的图形,则只需将.cursorUpdate更改为.mouseEnteredAndExited.mouseMoved 。 当我们是第一响应者时,也许我们只是想更改图形或光标? 只需将.activeInKeyWindow更改为.activeWhenFirstResponder ,请注意,这些options是被掩盖在一起的三个单独的选项集。 例如,您不能同时使用.activeInKeyWindow.activeWhenFirstResponder

触觉和音频反馈

有些控件可能想要添加音频反馈支持,例如NSButton.sound ,但我认为这是对audo反馈的可用性和实现的假设(如果客户端希望使用AVAudioPlayer怎样?); 相反,在我们的委托或目标/操作方法中播放声音可能是一个更好的主意。 但是,如果我们确实想通过NSSound支持音频反馈,则非常简单:

 @objc open var sound: NSSound? = nil 
 // ... 
 open func doSomething() { 
// ...
self.sound?.play()
}

同样,某些控件可能希望添加触觉反馈,但是(应该)主要依赖于(具体地)强制触摸交互,例如拖动或深度按压,而我们的快捷方式录制控件主要依赖于键盘输入。 如果我们愿意的话,添加简单的触觉响应也一样简单,尽管有点罗word:

 @objc open var hapticPerformer: NSHapticFeedbackPerformer? = NSHapticFeedbackManager.defaultPerformer 
 // ... 
 open func doSomething() { 
// ...
self.hapticPerformer?.perform(.levelChange, performanceTime: .drawCompleted)
}

在这种特定情况下,虽然当前在macOS上,但是只有defaultPerformer ,我们可以期望将来会有更多,例如触摸条。 在预期此类变化时,最好让我们的客户设置所需的hapticPerformer如果将其设置为nil ,则就像我们禁用了触觉反馈一样。

窗口键控

如上所示,macOS上的一个重要概念是窗口“键性”和“主体性”。 控件应该相应绘制,以便在视觉上向用户说明是否可以进行交互。 通过使用viewWillMove(toWindow:)方法,然后使用NSNotificationCenter viewWillMove(toWindow:) ,我们可以监视父window的状态而无需任何对window的强烈引用。

 @objc private func windowKeyednessChanged(_ note: Notification) { 
guard let window = self.window, (note.object as? NSWindow) == window else { return }
// do nothing for now
}
 open override func viewWillMove(toWindow newWindow: NSWindow?) { 
let n = NotificationCenter.default // shorthand
if let oldWindow = self.window {
n.removeObserver(self, name: NSWindow.didBecomeKeyNotification,
object: oldWindow)
n.removeObserver(self, name: NSWindow.didResignKeyNotification,
object: oldWindow)
}
if let newWindow = newWindow {
n.addObserver(self, selector: #selector(self.windowKeyednessChanged(_:)),
name: NSWindow.didBecomeKeyNotification, object: newWindow)
n.addObserver(self, selector: #selector(self.windowKeyednessChanged(_:)),
name: NSWindow.didResignKeyNotification, object: newWindow)
}
}
 deinit { 
NotificationCenter.default.removeObserver(self) // just in case
}

现在,只要键控发生更改,我们就将获得windowKeyednessChanged(_:)通知(注意,我们也可以对主体进行相同的操作),而不必担心我们包含在哪个窗口中。 接下来要做的是将我们的第一个响应者状态连接到方法中,这听起来是不合适的,但是由于我们仅使用该方法来处理绘图和事件状态,因此不必费神就可以这样做。

 open override func becomeFirstResponder() -> Bool { 
DispatchQueue.main.async {
self.windowKeyednessChanged(Notification(name: NSWindow.didBecomeKeyNotification,
object: self.window, userInfo: nil))
}
return true
}
 open override func resignFirstResponder() -> Bool { 
DispatchQueue.main.async {
self.windowKeyednessChanged(Notification(name: NSWindow.didResignKeyNotification,
object: self.window, userInfo: nil))
}
// ...
return true
}

我们需要将windowKeyednessChanged调用加入到主队列中,因为直到返回become/resignFirstResponder方法之前,我们还没有成为第一响应者。

全局热键干扰

至此,我们有了功能齐全的键盘快捷键记录器! 但是…试图记录某些快捷方式,您会发现,这种方式无法正常工作,而且很神秘。 在尝试录制了几种不同的快捷方式之后,您会为之震惊:这些是我们在系统偏好设置中设置的符号快捷方式! 我们不小心触发了它们,而不是记录了快捷方式! 您肯定想知道,系统偏好设置如何允许您记录快捷方式而又不会故意触发符号快捷方式? 答案在于一条龙窝:

 // Here lie dragons! 
fileprivate typealias CGSConnectionID = UInt
fileprivate enum CGSGlobalHotKeyOperatingMode: UInt {
case enable = 0, disable = 1, universalAccessOnly = 2
}
@_silgen_name("CGSMainConnectionID")
fileprivate func CGSMainConnectionID() -> CGSConnectionID
@_silgen_name("CGSGetGlobalHotKeyOperatingMode")
fileprivate func CGSGetGlobalHotKeyOperatingMode(_ connection: CGSConnectionID,
_ mode: UnsafeMutablePointer) -> CGError
@_silgen_name("CGSSetGlobalHotKeyOperatingMode")
fileprivate func CGSSetGlobalHotKeyOperatingMode(_ connection: CGSConnectionID,
_ mode: CGSGlobalHotKeyOperatingMode) -> CGError

记录已保留的热键的答案是……在您是第一响应者时全局关闭热键。 我发誓这正是系统偏好设置的目的-如果您不相信我,请自己看看! 缓存现有的全局状态,关闭热键非常简单,然后在我们退出第一响应者后,重置全局状态。 看起来像这样:

 private var savedOperatingMode: CGSGlobalHotKeyOperatingMode? = nil 

deinit {
if self.savedOperatingMode != nil {
_ = CGSSetGlobalHotKeyOperatingMode(CGSMainConnectionID(), self.savedOperatingMode!)
}
// ...
}
 @objc private func windowKeyednessChanged(_ note: Notification) { 
// ...
if window.isKeyWindow && window.firstResponder == self { // becomeKey
guard self.savedOperatingMode == nil else { return }

_ = CGSGetGlobalHotKeyOperatingMode(CGSMainConnectionID(), &self.savedOperatingMode)
_ = CGSSetGlobalHotKeyOperatingMode(CGSMainConnectionID(), .disable)
} else { // resignKey
guard self.savedOperatingMode != nil else { return }
  _ = CGSSetGlobalHotKeyOperatingMode(CGSMainConnectionID(), self.savedOperatingMode!) 
self.savedOperatingMode = nil
window.makeFirstResponder(nil) // resign ourselves if window resigned
}
}

免责声明: 滚动功能齐全的快捷方式记录器时无需包括此内容。可以忽略以下事实:用户将尝试记录快捷方式,然后对它不起作用感到失望。 可以! 让您的用户失望! 而且,如果您要将应用程序提交给MAS,请记住这一点。 我们不是为了邪恶而使用此工具,而是要准确响应用户的行为。 不幸的缺点是,当用户两次双击我们的应用程序时,符号/其他热键将被触发,除非我们仅声明使用快捷键(请参阅这样的原因!)。

字符串本地化

好吧,我们现在完成了吗? 没有人使用这种控件居住在美国以外的地区(甚至那些住在其中的人,他们肯定会说流利的英语,对吗?)而且我们的用户都没有残障吗? 错误。 始终认为您(作为开发人员)不是工具(控件或应用程序)的目标受众,并且始终专注于i18n可访问性 ,因为无论您是否喜欢它,它都不是仁慈,这是一种责任。

幸运的是,除了困难的部分(本地化本身)之外,本地化支持非常容易。 我选择将其包装到带有帮助函数value(_:default:comment:)Localized容器中,该函数从包含该类的包中查找字符串。 这很重要,因为您的控件可能位于也可能不在main捆绑包中。 要使用它,只需在下面创建静态属性,例如Localized.voiceOverBegin

 private enum Localized { 
private static func value(_ key: String, `default`: String, comment: String) -> String {
return NSLocalizedString(key, tableName: nil, bundle: Bundle(for: KeyboardShortcutView.self),
value: `default`, comment: comment) // helper!
}
  // ... 

fileprivate static var actionName: String {
return value("action_name", default: "Record Shortcut",
comment: "The action name for undo and redo")
}

fileprivate static var voiceOverBegin: String {
return value("voiceover_begin", default: "Now recording a shortcut",
comment: "The notification name for VoiceOver if the control began recording")
}
  // ... 
}

始终为您的翻译人员提供有用的注释,并以预期的目标语言提供default ,以防您没有加载好的strings文件,或者您当前的捆绑包中没有简单的strings文件。

辅助功能

我不是辅助功能专家,但是对于与按钮类似并且包含其他子控件的控件,这是您应该实现的最低要求。 确保将KeyboardShortcutView符合NSAccessibilityButtonNSAccessibilityGroup

 open override func isAccessibilityElement() -> Bool { 
return true
}
open override func accessibilityHelp() -> String? {
return Localized.help
}
open override func accessibilityRole() -> NSAccessibilityRole? {
return .button
}
open override func accessibilityLabel() -> String? {
let str = self.stringRepresentation
return str.isEmpty ? Localized.noShortcut : str
}
open override func accessibilityValue() -> Any? {
return self.accessibilityLabel()
}
open override func accessibilityRoleDescription() -> String? {
return Localized.tooltipPrefix
}
open override var accessibilityFocusedUIElement: Any? {
return self.window?.firstResponder == self
}
open override func accessibilityChildren() -> [Any]? {
return [self.clearButton]
}
open override func accessibilityPerformPress() -> Bool {
guard self.isEnabled else { return false }
self.performClick(nil)
return true
}

尽管这为可访问性守护程序和工具提供了可访问的属性, NSAccessibilityPostNotificationshortcut更改时,我们还应该使用NSAccessibilityPostNotification发布通知,并且当我们更改录制状态或从用户输入中设置快捷方式时,肯定会发出VoiceOver通知。

 /*@objc*/ open var shortcut: KeyboardShortcutView.Pair? { 
didSet {
// ...
NSAccessibilityPostNotification(self, .valueChanged)
// ...
}
}
open override func performKeyEquivalent(with event: NSEvent) -> Bool {
// ...
if /* we should record the new shortcut */ {
// ...
NSAccessibilityPostNotificationWithUserInfo(self, .announcementRequested, [
.announcement: Localized.voiceOverRecorded,
.priority: NSAccessibilityPriorityLevel.high
])
return true
}
// ...
}

 public func beginRecording() -> Bool { 
// ...
NSAccessibilityPostNotificationWithUserInfo(self, .announcementRequested, [
.announcement: Localized.voiceOverBegin,
.priority: NSAccessibilityPriorityLevel.high
])
return true
}
 @objc private func buttonAction(_ button: NSButton) { 
// ...
if /* we should clear the shortcut set */ {
// ...
NSAccessibilityPostNotificationWithUserInfo(self, .announcementRequested, [
.announcement: Localized.voiceOverCleared,
.priority: NSAccessibilityPriorityLevel.high
])
}
}
 open override func updateLayer() { 
// ...
self.clearButton.setAccessibilityLabel(canStop ? Localized.buttonRecordLabel
: Localized.buttonClearLabel)
}
 in KeyboardShortcutView.clearButton.getter: 
button.toolTip = Localized.buttonTooltip
button.setAccessibilityHelp(Localized.buttonTooltip)

我们不只是将VoiceOver通知放在willSet / didSet中,因为它们是记录状态更改的原因的唯一原因。

NSCoding和状态还原

如果您要兼容Interface Builder,最关键的事情之一就是支持NSCoding ,并通过NSResponder状态还NSResponder支持自动终止。 状态恢复应该只对类通常为NSCoding编码或解码的密钥的子集进行编码和解码:实际上是面向用户的状态。 如Apple所说, “您必须存储足够的数据来重新配置响应器,并在随后启动应用程序时将其恢复为当前状态。”

在这里,您实际上应该将 NSCoding 方法 传递 NSResponder 的状态恢复。 I leave the proper implementation as an exercise for the reader. (read: I was lazy about it.) I’ve decided to not implement it correctly as I’m currently experimenting with a Codable NSCoding adapter specific to my implementation of this control. I’ll possibly write another post on that later.

 open static var supportsSecureCoding: Bool { 
return true
}
 public required init?(coder: NSCoder) { 
super.init(coder: coder)
self.restoreState(with: coder)
self.commonInit()
}
 open override func encode(with coder: NSCoder) { 
super.encode(with: coder)
self.encodeRestorableState(with: coder)
}
 open override func encodeRestorableState(with coder: NSCoder) { 
//super.encodeRestorableState(with: coder)

coder.encode(self.suggestions.map { representation(of: $0) }, forKey: "suggestions")
coder.encode(representation(of:self.shortcut), forKey: "shortcut")
coder.encode(self.isEnabled as NSNumber, forKey: "isEnabled")
}
 open override func restoreState(with coder: NSCoder) { 
//super.restoreState(with: coder)

let s = coder.decodeObject(of: NSArray.self, forKey: "suggestions") as? [NSDictionary]
self.suggestions = (s ?? []).compactMap { representation(of: $0) }
self.shortcut = representation(of: coder.decodeObject(of: NSDictionary.self, forKey: "shortcut"))
self.isEnabled = coder.decodeObject(of: NSNumber.self, forKey: "isEnabled") as? Bool ?? false
}

Be sure to conform KeyboardShortcutView to NSSecureCoding . When we change certain properties (the ones we’re encoding and decoding), we should call invalidateRestorableState to ensure our current state is encoded (automatic termination is like the grim reaper: it arrives without warning, and thus we must protect our interests before its arrival). Add the self.invalidateRestorableState() call to the didSet of shortcut , suggestions , and isEnabled .

Now when you make a change, quit the app, reopen it, and you’ll find that things were as you left it before you quit the app!

Context NSMenu Popups

It may be desirable to allow the user to select a keyboard shortcut from a list of suggestions, like the Siri System Preferences pane, for example. To support this, let’s add a suggestions property that takes an [KeyboardShortcutView.Pair] . When a Pair is selected from the list, the shortcut property is overridden with the selected value.

 /*@objc*/ open var suggestions: [KeyboardShortcutView.Pair] = [] { 
willSet { self.willChangeValue(forKey: #function) }
didSet {
self.didChangeValue(forKey: #function)
// ...
}
}

Now, how do we actually display the list of suggestions to the user, in-band with the control itself? NSView offers a menu property, but we can also override menu(for:) to return an NSMenu based on where the menu-opening right click occurred, specifically. Since we don’t need that level of granularity, let’s go ahead and override menu directly and map suggestions into NSMenuItem s. Note that to be able to manually enable or disable menu items, we should set NSMenu.autoenablesItems to false , because otherwise, NSMenu will validate the existence of each menu item’s action method existing on the item’s target .

 open override var menu: NSMenu? { 
get {
let menu = NSMenu()
menu.autoenablesItems = false
for (i, x) in self.suggestions.enumerated() {
let str = Localized.menuPrefix + " " + x.modifierFlags.characters + x.keyCode.characters
let item = NSMenuItem(title: str, action: #selector(self.selectAction(_:)), keyEquivalent: "")
item.tag = i
item.target = self
item.isEnabled = self.isEnabled
menu.addItem(item)
}
return menu
}
set { }
}
 @objc private func selectAction(_ item: NSMenuItem) { 
guard self.isEnabled else { return }
self.endRecording()
self.shortcut = self.suggestions[item.tag]
}

We’ve also overridden menu.setter to become a no-op, because we no longer want to allow a client to set a menu that may have no context with our control. In effect, we’re forcing our clients to use suggestions only. An alternative could be to override menu(for:) and return a new NSMenu with self.menu.items + self.suggestions.map { $0.toMenuItem() } (pseudocode).

Astute readers will spot the race condition possible with this code: suggestions may have been modified between menu.getter and selectAction(_:) , causing self.suggestions[item.tag] to become inconsistent or crash. Solving this race condition is an exercise left to the reader… (I’m just using that phrase to avoid solving the problems myself aren’t I?)

Undo Management

Because macOS applications use variations of the ⌘Z shortcut to undo or redo, it might seem a little odd to support undo management in a control that’s designed to record keyboard shortcuts themselves. However, as long as the control isn’t the first responder, the undo action will always trigger, except in cases where the app has registered ⌘Z as a global shortcut (the delegate should ideally prevent things like that).

Adding support for undo management is actually very easy; since setting the shortcut property changes what the user sees, and we set this property internally in performKeyEquivalent(_:) , we can register our undo action there! This does have an unintended/unwanted side effect of also pushing the undo stack when the app is programmatically setting the shortcut value. However, I think it’s perfectly acceptable to keep a continuous undo stack between the app and user actions in this case.

 /*@objc*/ open var shortcut: KeyboardShortcutView.Pair? { 
didSet {
// ...
self.undoManager?.registerUndo(withTarget: self) { [oldValue] _ in
self.shortcut = oldValue
}
self.setActionName(Localized.actionName)
// ...
}
}

Each NSResponder has an undoManager that we can just register the undo action with – an undo action takes self as the target, with a handler to execute when the user un-does the current action. Notice that we capture oldValue at registration-time, instead of invocation-time: this is so we don’t set the undone shortcut value to itself accidentally.

Running an application with an existing saved state, we’ll notice an unusual glitch: it appears that even though the app was just launched (and restored its state), the user sees an undo action, even though they haven’t interacted with the control yet! This is because the one edge-case we need to control in setting the shortcut property is the initializer(s) setting it. A Swift initializer does not invoke any property observers when a member’s value is set, however , restoreState(_:) is not an initializer! It’s a normal method, and thus, will invoke the didSet , which is harmless, except for this particular side effect. The solution is to only register an action with the undo manager if we aren’t restoring state.

 /*@objc*/ open var shortcut: KeyboardShortcutView.Pair? { 
didSet {
// ...
if !self.isRestoringState {
self.undoManager?.registerUndo(withTarget: self) { [oldValue] _ in
self.shortcut = oldValue
}
self.setActionName(Localized.actionName)
}
// ...
}
}
 // ... 
 private var isRestoringState: Bool = false 
 open override func restoreState(with coder: NSCoder) { 
// ...
self.isRestoringState = true
defer {self.isRestoringState = false }
// ...
}

Now, running the app from an existing saved state won’t cause unexpected undo actions to register! The astute reader will also observe that we could just flip the condition on isRestoringState , calling the boolean shouldRegisterUndo , and only enable it during performKeyEquivalent(_:) . That’s a perfectly fine solution as well, depending on what undo registration behavior you want to achieve.

What about redo actions? We’ve only set up undo actions: if the user types ⌘L and then undoes the action, how do we allow the user to redo that (that is, set the shortcut again to ⌘L )? It turns out that UndoManager handles this logic already, and if we’re currently executing the registered undo action’s handler, isUndoing is true , and the “undo action of the undo action” is translated into a “redo action” for us. We’re all done here!

结论

With that, we’ve completely designed and implemented a keyboard shortcut recording control, following correct practice and masquerading as a first class AppKit citizen! If you have any questions, comments, or concerns, contact me on Twitter or Github @avaidyam!