原文链接:http://nshipster.com/nsundomanager/
前言
Foundation框架中的NSUndoManager
为我们提供了去撤销与重复操作的健壮API。
默认的话,每个应用窗口都有一个撤销管理者,并且在响应者链的任意对象可以管理一个自定义的撤销管理从而实现对本地各自视图撤销和重复操作。除了UITextField
和UITextArea
自动配有撤销功能之外,其余对苹果开发者都是一个待修的课题。
撤销操作
要想进行一个动作撤销,要先注册一个“撤销操作”。
注册一个简单的撤销操作
调用NSUndoManger -registerUndoWithTarget:selector:object:
到要执行撤销操作的对象上。同时指定一个撤销动作的名字,使用NSUndoManager -setActionName:
。撤销对话框展示动作的名字,所以必须本地化。
func updateScore(score: NSNumber) {
undoManager.registerUndoWithTarget(self, selector:Selector("updateScore:"), object:score)
undoManager.setActionName(NSLocalizedString("actions.update", comment: "Update Score"))
myMovie.score = score
}
通过NSInvocation
来注册一个复杂的撤销操作
简单得撤销操作可能让功能过于呆板了,因为撤销一个动作可能需要的参数不止一个。在这种情况下,我们可以拔高通过NSInvocation来记录选择器与必要的参数。调用prepareWithInvocationTarget:
记录哪个对象将要收到XX将要变化的消息。
func movePiece(piece: ChessPiece, row:UInt, column:UInt) {
let undoController : ViewController = undoManager?.prepareWithInvocationTarget(self) as ViewController
undoController.movePiece(piece, row:piece.row, column:piece.column)
undoManager?.setActionName(NSLocalizedString("actions.move-piece", "Move Piece"))
piece.row = row
piece.column = column
updateChessboard()
}
这里的原理是NSUndoManager
实现了forwardInvocation:
。在撤销管理者收到撤销-movePiece:row:column:
的消息的时候,因为自身没有实现这个方法的缘故它将这个消息丢给了目标对象。
执行撤销
注册完撤销操作以后就可以愉快的使用NSUndoManager -undo and NSUndoManager -redo
进行撤销重做进行玩耍了。
与iOS摇晃手势的交互
默认的,用户在摇晃设备的时候出发撤销操作。如果视图控制器需要响应撤销请求,那么视图控制器需要:
- 能够成为第一响应者
- 当视图出现得时候成为第一个响应者
- 当视图消失的时候脱离第一响应者
当视图控制器接收到动作事件,操作系统会弹出一个对话框让用户选择是不是执行撤销或者重做的动作。(如果有这个功能的话)视图控制器的undoManager
属性会完成用户做好选择后的活儿。
class ViewController: UIViewController {
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
override func viewWillDisappear(animated: Bool) {
super.viewWillDisappear(animated)
resignFirstResponder()
}
override func canBecomeFirstResponder() -> Bool {
return true
}
// ...
}
自定义撤销栈
组动作
所有的撤销操作注册到同一个run loop中会导致都不执行,除非指定一个“撤销组”。组的概念让撤销和重做的动作可以同时一起进行。尽管所有的动作可以被单独的执行以及撤销,如果用户同时执行两个操作,同时撤销两个有利于保留用户体验一致性。
func readAndArchiveEmail(email: Email) {
undoManager?.beginUndoGrouping()
markEmail(email, read: true)
archiveEmail(email)
undoManager?.setActionName(NSLocalizedString("actions.read-archive", comment:"Mark as Read and Archive"))
undoManager?.endUndoGrouping()
}
func markEmail(email: Email, read:Bool) {
let undoController: ViewController = undoManager?.prepareWithInvocationTarget(self) as ViewController
undoController.markEmail(email, read:email.read)
undoManager?.setActionName(NSLocalizedString("actions.read", comment:"Mark as Read"))
email.read = read
}
func archiveEmail(email: Email) {
let undoController: ViewController = undoManager?.prepareWithInvocationTarget(self) as ViewController
undoController.moveEmail(email, toFolder:"Inbox")
undoManager?.setActionName(NSLocalizedString("actions.archive", comment:"Archive"))
moveEmail(email, toFolder:"All Mail")
}
清空栈
有时候需要清理撤销管理者的动作列表来避免造成用户混淆从而导致其预期不到的结果。最常用的方式是党上下文动态变化的时候,比如改变iOS上可见的视图控制器或是打开了一个文件夹。当此类情况发生的时候,我们可以调用NSUndoManager -removeAllActions or NSUndoManager -removeAllActionsWithTarget:
来清理我们的撤销管理者栈。
警告
如果一个动作对应多个撤销与重做,要核对所设置的操作名称以确保撤销对话框所反映的行动将被撤销称号的前撤消操作是否已经发生。下面栗子展示一对反操作,比如添加和删除对象:
func addItem(item: NSObject) {
undoManager?.registerUndoWithTarget(self, selector: Selector("removeItem:"), object:item)
if undoManager?.undoing == false {
undoManager?.setActionName(NSLocalizedString("action.add-item", comment: "Add Item"))
}
myArray.append(item)
}
func removeItem(item: NSObject) {
if let index = find(myArray, item) {
undoManager?.registerUndoWithTarget(self, selector: Selector("addItem:"), object:item)
if undoManager?.undoing == false {
undoManager?.setActionName(NSLocalizedString("action.remove-item", comment: "Remove Item"))
}
myArray.removeAtIndex(index)
}
}
如果你使用像是Kiwi的测试框架,那么请在用例teardown
中进行撤销栈的清除。不然其他的测试用例将会共享当前用例的撤销状态并且用例中调用NSUndoManager -undo
会导致意想不到的结果。