“撤销”功能在OS X上很常见,想法就是:假如用户及时意识到犯的错误,可以回退错误的动作。通常,Mac应用会维持内部撤销动作(undoable actions)的栈,选择Edit
->Undo或Command-Z
来回退该栈顶部的动作,当然,动作也可以重做(redo)。
一些iOS应用,可能也需要有限的撤销功能,当然,这并不难实现。一些内置的视图,特别是涉及到文本输入(text entry)的,比如UITextField和UITextView,已经实现了撤销功能。你可以将撤销功能添加到应用的其它部分。
撤销(undo)功能通过NSUndoManager
实例来提供,它仅会维持可撤销动作(redoable actions)的一个栈,和可重做动作(redoable actions)的一个栈。当用户选择撤销最近的动作时,撤销栈(undo stack)顶部的动作(即栈顶动作)就会被弹出(pop),实现回退;弹出的动作会被压进(push)重做栈(redo stack)的顶部。
一.Undo Manager(撤销管理器)
有三种方式可以将一个动作(action)注册成可撤销的(undoable):
1.Target-Action撤销
使用NSUndoManager的方法registerUndoWithTarget:selector:object:
,这个方法使用了target-action架构。稍后,如果向NSUndoManager发送undo
消息时,就会执行target的action方法。action方法的工作是撤销需要撤销的一切。
先看一段示例代码:
DragView.swift代码:
import UIKit
class DragView: UIView {
let undoer = NSUndoManager()
override var undoManager: NSUndoManager?{
return self.undoer
}
override init(frame: CGRect) {
super.init(frame: frame)
let p = UIPanGestureRecognizer(target: self, action: Selector("dragging:"))
let l = UILongPressGestureRecognizer(target: self, action: Selector("longPress:"))
self.addGestureRecognizer(p)
self.addGestureRecognizer(l)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setCenterUndoably (newCenter: NSValue) {
self.undoer.registerUndoWithTarget(self, selector: Selector("setCenterUndoably:"), object: NSValue(CGPoint: self.center))
self.undoer.setActionName("Move")
if self.undoer.undoing || self.undoer.redoing {
UIView.animateWithDuration(0.4, delay: 0.1, options: [], animations: { () -> Void in
self.center = newCenter.CGPointValue()
}, completion: nil)
}else {
self.center = newCenter.CGPointValue()
}
}
func dragging (p: UIPanGestureRecognizer) {
switch p.state {
case .Began:
self.undoer.beginUndoGrouping()
fallthrough
case .Began,.Changed:
let delta = p.translationInView(self.superview)
var c = self.center
c.x += delta.x
c.y += delta.y
self.setCenterUndoably(NSValue(CGPoint: c))
p.setTranslation(CGPointZero, inView: self.superview)
case .Ended, .Cancelled:
self.undoer.endUndoGrouping()
self.becomeFirstResponder()
default:break
}
}
func longPress(g: UIGestureRecognizer) {
if g.state == .Began {
let m = UIMenuController.sharedMenuController()
m.setTargetRect(self.bounds, inView: self)
let mi1 = UIMenuItem(title: self.undoer.undoMenuItemTitle, action: "undo:")
let mi2 = UIMenuItem(title: self.undoer.redoMenuItemTitle, action: "redo:")
m.menuItems = [mi1,mi2]
m.setMenuVisible(true, animated: true)
}
}
func undo(_: AnyObject?){
self.undoer.undo()
}
func redo(_: AnyObject?){
self.undoer.redo()
}
override func canBecomeFirstResponder() -> Bool {
return true
}
}
使用该DragView:
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let drag = DragView(frame: CGRectMake(20,20,100,100))
drag.backgroundColor = UIColor.orangeColor()
self.view.addSubview(drag)
}
}
现在讲解下上面的示例代码:
这里我们想撤销的是DragView的center
属性,首先使DragView可以拖拽,并且完成self.center
设置的动作(action)。接下来让这个动作可撤销的,为了撤销我们将要执行的动作,NSUndoManager需要发送什么消息呢?我们想要NSUndoManager将self.center退回到现在的值。本例中,NSUndoManager调用setCenterUndoably:方法。
调用registerUndoWithTarget
方法会将想要撤销回的状态属性放入对应的栈(undo栈或者redo栈)中。
上面代码中,出现了Undo Grouping的概念,对于拖拽这个手势,从触碰到视图到离开视图,dragging:被调用了多次,而每次都会调用setCenterUndoably:方法,所以这样的话,撤销并不是针对整个拖拽手势的,而是一小部分。所以我们用到了Undo Grouping,当手势开始时,开始group;当手势结束时,关闭group。
2.Invocation撤销
此种方法中,涉及到prepareWithInvocationTarget:方法,NSUndoManager使用到动态机制(NSInvocation)。
3.Functional撤销
iOS9引入第三种方式,registerUndoWithTarget:handler:
三种方式的思想是一致的。
二.撤销的界面
我们必须要决定如何让用户请求undo和redo,可以通过两个按钮分别向NSUndoManager发送undo和redo消息。下面来讨论下其它的方式:
1.Shake-To-Edit
默认,应用是支持shake-to-edit。这意味着用户可以摇动设备来产生undo/redo界面。你可以设置共享的UIApplication的applicationSupportsShakeToEdit为false来关闭这个功能。没有关闭该功能时,用户摇动设备时,运行时(runtime)会遍历响应者链,从第一个响应者开始,寻找一个响应者,它继承(即上例子中的override)的undoManager属性返回真正的NSUndoManager实例。如果发现一个,会跳出一个undo/redo界面,允许用户与NSUndoManager交互。
回忆一下,如何让一个UIResponder成为第一响应器:canBecomeFirstResponder
必须要返回true,而且,必须要通过方法becomeFirstResponder
来成为第一响应器。
为了让shake-to-edit工作,我们必须要为undoManager属性提供getter方法来返回我们的撤销管理器(undo manager)。
效果图:
注意:图中出现的Move是通过self.undoer.setActionName(“Move”)
2.撤销菜单(Undo Menu)
这与UITextField、UITextView展示Copy(复制)和Paste(粘贴)菜单项是相同的。激活这种菜单的要求与shake-to-edit是相同的:我们需要一个响应者链,在它的底部是第一响应者。
回忆一下如何实现菜单:获取全局的单例对象UIMenuController对象UIMenuController.sharedMenuController()
,声明一个自定义的UIMenuItem数组作为菜单的menuItems属性。通过向UIMenuController发送setMenuVisible:animated:
消息来显示菜单。只有canPerformAction:withSender:
方法返回true时,特定的菜单项才会出现在菜单中。
效果图如下: