macOS 开发之 NSTextField 支持文本快捷键(二): 撤销和重做

在文章macOS 开发之 NSTextField 支持文本快捷键(一): 基本操作中探讨了 app 开发中键盘事件以及 NSTextfield 支持基本文本快捷键的实现方法,本文跟十里一起实现另外的两个操作:

  • 撤销: Command + z
  • 重做: Shift + Command + z

实现平台

  • macOS 10.14.3
  • swift 4.2.1
  • xcode 10.1

示例工程

我们继续使用 macOS 开发之 NSTextField 支持文本快捷键(一): 基本操作 中的 Demo 工程,点击下面链接可以下载:

TextFieldKeyDemo

添加撤销和重做支持

这里先说一下怎么做,然后再解释。

确认勾选 undo

NSTextField是默认支持撤销和重做的,打开 Main.storyboard 文件,点击 view controller 中的 Text Field,按快捷键 Option + Command + 4 打开 Text Field 的 Attribute Inspector 这是就会看到 Undo 是默认勾选的:

添加快捷键支持

既然支持了 Undo,那么我们只需按照相应快捷键添加相应的操作就可以了,打开文件 AppDelegate.swift,可以看到上一篇文章中复写的 performKeyEquivalent: 方法,添加撤销和重做的代码如下:

// NSTextField 支持快捷键
extension NSTextField {
    open override func performKeyEquivalent(with event: NSEvent) -> Bool {
        if event.modifierFlags.isDisjoint(with: .command) {
            return super.performKeyEquivalent(with: event)
        }
        
        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)
        case "z":
            self.window?.firstResponder?.undoManager?.undo()
            return true
        case "Z":
            self.window?.firstResponder?.undoManager?.redo()
            return true
        default:
            return super.performKeyEquivalent(with: event)
        }
    }
}

添加完上述代码,按下快捷键 Command + r 运行程序,在运行起来的窗口中的文本框中输入一些字符,然后尝试按快捷键 Command + zCommand + Z ,恭喜你完成了撤销和重做操作的添加!

  • Shift + Command + z 可以理解为 Command + (Shift + z),而 Shift + z 其实就是 Z,所以最终这个重做快捷键其实就是 Command + Z(大写的 z)
  • 当文本框编辑的时候其就是窗口的第一响应对象,这里使用 self.window?.firstResponder 就是要快速定位到正在编辑的文本对象,而不是通过 NSTextField 的对象层级关系查找键盘事件的响应对象
  • 在 macOS 中,撤销和重做被做了统一封装,由 UndoManager 类进行统一管理这两个操作,而对于继承 NSResponder 类的子类都是默认有一个 undoManager 属性的,类型就是 UndoManager,不需要手动创建。同样,NSTextField 也不例外。macOS 中文本对象的操作都是默认包含 undoManager 的管理的也不需要我们手动添加相关管理实现。

撤销和重做的实现原理

上面已经提到 App 中的撤销和重做操作是由 UndoManager 类进行管理的,虽然我们知道了如何实现 NSTextField 的撤销和重做,但是 UndoManager 实现原理和使用还是得了解一下的,后面有需求要做不是文本操作的撤销和重做功能时也就更容易上手了。

理解撤销和重做操作流程

UndoManager 通过管理两个操作栈(撤销栈和重做栈)的压栈和弹栈实现 undo 和 redo,两个操作栈中保存的是最小的操作过程,这个小的操作过程被封装为 NSInvocation 对象,然后保存在两个栈中,示意大概如下:

NSInvocation 是一种包含执行方法及参数的对象。

操作流程中的操作压栈和弹栈过程分三种情况:

  • 正常操作时:将马上要进行的正常操作的逆操作进行封装压入撤销栈,然后执行正常操作
  • 要执行撤销操作时:先从撤销栈中弹出得到要进行的操作,将这一步操作的逆操作压入重做栈,然后执行刚弹出的操作
  • 要执行重做操作时:从重做栈中弹出得到要进行的操作,将其逆向操作的封装压入撤销栈,然后进行刚弹出的操作

撤销和重做操作的管理

UndoManager 对象的创建

如上面提到的,大多数情况下对象中都有一个现成的 UndoManager 对象属性,可以直接使用这个属性 undoManager。继承 NSResponder 的子类由有:NSApplication、NSPopover、NSView、NSViewController、NSWindow 和 NSWindowController。

撤销操作压栈

要将操作压入撤销栈其实就是一个注册操作的过程,有三个方法:

  • registerUndo(withTarget:handler:) 方法,是将对象、处理一起进行注册,使用闭包的形式,例如:
self.undoManager?.registerUndo(withTarget: self) { (textField: NSTextField) in
    textField.stringValue = "hello"
}
  • registerUndo(withTarget:selector:object:) 方法注册

  • 使用 prepare(withInvocaionTarget:) 方法注册:

if let target = self.undoManager?.prepare(withInvocationTarget: self) as? NSTextField {
    target.stringValue = "hello"
}

注:

UndoManager 对象的 undoRegistionEnabled 是 true 的时候,撤销操作可以注册,但是如果是 false 就会禁用注册功能。

清除所有的撤销操作

  • removeAllActions: 删除撤销栈中所有的撤销操作
  • removeAllActions(withTarget:): 删除撤销栈中指定对象的所有撤销操作

需要注意的一点是:UndoManager 会强引用保留对象,所以在一个对象被删除的时候一定要清除这个对象在撤销栈中的所有撤销操作。

为撤销操作命名

可以使用 setActionName 为撤销操作进行命名,这样在全局菜单中的编辑菜单中的就能看到撤销和重做对应的具体实际的动作。

实例感受撤销和重做的实现

这里继续使用上面的工程,实现一个可以撤销和重做的整数加法器,具体实现过程如下:

  • 另外添加两个 Text Field,三个文本框分别是加数、被加数和结果,并将其作为属性绑定到 View Controller 中
  • 添加三个按钮,分别是计算、撤销和重做,同样绑定动作到 View Controller 中
  • 添加三个 Action 的实现,在撤销和重做的实现中,可以参考下面的模板实现:
private func doThing() {
    // do the thing here
}

private func undoThing() {
    // undo the thing here
}

func undoablyDoThing() {
    undoManager?.registerUndoWithTarget(self, handler: { me in
        me.redoablyUndoThing()
    })
    // 可以添加有意义的操作名称: undoManager?.setActionName("Thing")
    doThing()
}

func redoablyUndoThing() {
    undoManager?.registerUndoWithTarget(self, handler: { me in
        me.undoablyDoThing()
    })
    // 可以添加有意义的操作名称: undoManager?.setActionName("Thing")
    undoThing()
}

另外还要注意的是,最好是也要将撤销按钮和重做按钮与 undoManager 的 canUndo 和 canRedo 同步,形式如下:

self.undoButton.isEnabled = (self.undoManager?.canUndo)!
self.redoButton.isEnabled = (self.undoManager?.canRedo)!

最终实现效果如下视频,视频中可以看到 ViewController 类的实现:

具体实现代码可以参考 Demo 源码:TextFieldKeyDemo_UndoManager

参考