The ability to undo the most recent action is familiar from OS X. The idea is that, provided the user realizes soon enough that a mistake has been made, that mistake can be reversed. Typically, a Mac application will maintain an internal stack of undoable actions; choosing Edit → Undo or pressing Command-Z will reverse the action at the top of the stack, and will also make that action available for redo.
Some iOS apps, too, may benefit from at least a limited undo facility, and this is not difficult to implement. Some built-in views — in particular, those that involve text entry, UITextField and UITextView (Chapter 10) — implement undo already. And you can add it in other areas of your app.
Undo is provided through an instance of NSUndoManager, which basically just maintains a stack of undoable actions, along with a secondary stack of redoable actions. The goal in general is to work with the NSUndoManager so as to handle both undo and redo in the standard manner: when the user chooses to undo the most recent action, the action at the top of the undo stack is popped off and reversed and is pushed onto the top of the redo stack.
In this chapter, I’ll illustrate an NSUndoManager for a simple app that has just one kind of undoable action. More complicated apps, obviously, will be more complicated! On the other hand, iOS apps, unlike OS X apps, do not generally need deep or pervasive undo functionality. For more about the NSUndoManager class and how to use it, read Apple’s Undo Architecture as well as the documentation for the class itself. Also, UIDocument (see Chapter 23) has an undo manager (its undoManager
property), which automatically and appropriately updates the document’s “dirty” state for you.
In our artificially simple app, the user can drag a small square around the screen. We’ll start with an instance of a UIView subclass, MyView, to which has been attached a UIPanGestureRecognizer to make it draggable, as described in Chapter 5. The gesture recognizer’s action target is the MyView instance itself:
func dragging (p : UIPanGestureRecognizer) { switch p.state { case .Began, .Changed: let delta = p.translationInView(self.superview!) var c = self.center c.x += delta.x; c.y += delta.y self.center = c p.setTranslation(CGPointZero, inView: self.superview!) default:break } }
To make dragging of this view undoable, we need an NSUndoManager instance. Let’s store this in a property of MyView itself, self.undoer
:
let undoer = NSUndoManager()
There are three ways to register an action as undoable. I’ll start with the NSUndoManager method registerUndoWithTarget:selector:object:
. This method uses a target–action architecture: you provide a target, a selector for a method that takes one parameter, and the object value to be passed as argument when the method is called. Then, later, if the NSUndoManager is sent the undo
message, it simply sends that action to that target with that argument. The job of the action method is to undo whatever it is that needs undoing.
What we want to undo here is the setting of our center
property. This can’t be expressed directly using a target–action architecture, because the parameter of setCenter:
needs to be a CGPoint; we can’t use a CGPoint as the object:
in registerUndoWithTarget:selector:object:
, because it isn’t an Objective-C object (Swift will complain that it doesn’t conform to AnyObject). Therefore we’re going to have to provide, as our action method, a secondary method that does take an object parameter. This is neither bad nor unusual; it is quite common for actions to have a special representation just for the purpose of making them undoable.
So, in our dragging:
method, instead of setting self.center
to c
directly, we now call a secondary method (let’s call it setCenterUndoably:
):
var c = self.center c.x += delta.x; c.y += delta.y self.setCenterUndoably(NSValue(CGPoint:c))
At a minimum, setCenterUndoably:
should do the job that setting self.center
used to do:
func setCenterUndoably (newCenter:NSValue) { self.center = newCenter.CGPointValue() }
This works in the sense that the view is draggable exactly as before, but we have not yet made this action undoable. To do so, we must ask ourselves what message the NSUndoManager would need to send in order to undo the action we are about to perform. We would want the NSUndoManager to set self.center
back to the value it has now, before we change it as we are about to do. And what method would NSUndoManager call in order to do that? It would call setCenterUndoably:
, the very method we are implementing now! So:
func setCenterUndoably (newCenter:NSValue) { self.undoer.registerUndoWithTarget( self, selector: "setCenterUndoably:", object: NSValue(CGPoint:self.center)) self.center = newCenter.CGPointValue() }
That code has a remarkable effect: it makes our action not only undoable but also redoable! The reason is that NSUndoManager has an internal state, and responds differently to registerUndoWithTarget:selector:object:
depending on its state. If the NSUndoManager is sent registerUndoWithTarget:selector:object:
while it is undoing, it puts the target–action information on the redo stack instead of the undo stack (because redo is the undo of an undo, if you see what I mean).
Here’s how our code works to undo and then redo an action:
setCenterUndoably:
, which calls registerUndoWithTarget:selector:object:
with the old value of self.center
. The NSUndoManager adds this to its undo stack.
undo
to the NSUndoManager.
setCenterUndoably:
with the old value that we passed in earlier when we called registerUndoWithTarget:selector:object:
. Thus, we are going to set the center back to that old value. But before we do that, we send registerUndoWithTarget:selector:object:
to the NSUndoManager with the current value of self.center
. The NSUndoManager knows that it is currently undoing, so it understands this registration as something to be added to its redo stack.
redo
to the NSUndoManager, and sure enough, the NSUndoManager calls setCenterUndoably:
with the value that we previously undid. (And, once again, we call registerUndoWithTarget:selector:object:
with an action that goes onto the NSUndoManager’s undo stack.)
So far, so good. But our implementation of undo is very annoying, because we are adding a single object to the undo stack every time dragging:
is called — and it is called many times during the course of a single drag. Thus, undoing merely undoes the tiny increment corresponding to one individual dragging:
call. What we’d like is for undoing to undo an entire dragging gesture. We can implement this through undo grouping. As the gesture begins, we start a group; when the gesture ends, we end the group:
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() default: break } }
This works: each complete gesture of dragging MyView, from the time the user’s finger contacts the view to the time it leaves, is now undoable (and redoable) as a single unit.
A further refinement would be to animate the “drag” that the NSUndoManager performs when it undoes or redoes a user drag gesture. To do so, we take advantage of the fact that we, too, can examine the NSUndoManager’s state by way of its isUndoing
and isRedoing
properties; we animate the center
change when the NSUndoManager is “dragging,” but not when the user is dragging:
func setCenterUndoably (newCenter:NSValue) { self.undoer.registerUndoWithTarget( self, selector: "setCenterUndoably:", object: NSValue(CGPoint:self.center)) if self.undoer.undoing || self.undoer.redoing { UIView.animateWithDuration( 0.4, delay: 0.1, options: [], animations: { self.center = newCenter.CGPointValue() }, completion: nil) } else { // just do it self.center = newCenter.CGPointValue() } }
Earlier I said that registerUndoWithTarget:selector:object:
was one of three ways to register an action as undoable. The second way is prepareWithInvocationTarget:
. In general, the advantage of prepareWithInvocationTarget:
is that it lets you specify a method with any number of parameters, and those parameters needn’t be objects. You provide the target and, in the same line of code, send to the object returned from this call the message and arguments you want sent when the NSUndoManager is sent undo
or redo
. So, in our example, instead of this line:
self.undoer.registerUndoWithTarget( self, selector: "setCenterUndoably:", object: NSValue(CGPoint:self.center))
You’d say this:
self.undoer.prepareWithInvocationTarget(self) .setCenterUndoably(self.center)
That code seems impossible: how can we send setCenterUndoably:
without calling setCenterUndoably:
? Either we are sending it to self
, in which case it should actually be called at this moment, or we are sending it to some other object that doesn’t implement setCenterUndoably:
, in which case our app should crash. However, under the hood, the NSUndoManager is cleverly using dynamism (similarly to the message-forwarding example in Chapter 12) to capture this call as an NSInvocation object, which it can use later to send the same message with the same arguments to the specified target.
If we’re going to use prepareWithInvocationTarget:
, there’s no need to wrap the CGPoint value representing the old and new center
of our view as an NSValue. So our complete implementation now looks like this:
func setCenterUndoably (newCenter:CGPoint) { // * self.undoer.prepareWithInvocationTarget(self) .setCenterUndoably(self.center) // * if self.undoer.undoing || self.undoer.redoing { UIView.animateWithDuration( 0.4, delay: 0.1, options: [], animations: { self.center = newCenter // * }, completion: nil) } else { // just do it self.center = newCenter // * } } 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(c) // * p.setTranslation(CGPointZero, inView: self.superview!) case .Ended, .Cancelled: self.undoer.endUndoGrouping() default: break } }
New in iOS 9, there’s a third way to register an action as undoable: registerUndoWithTarget:handler:
. The handler:
is a function that will take one parameter, namely whatever you pass as the target:
argument, and will be called when undoing (or, if we register while undoing, when redoing). This gives us a more idiomatic way to do what prepareWithInvocationTarget:
does:
func setCenterUndoably (newCenter:CGPoint) { let oldCenter = self.center self.undoer.registerUndoWithTarget(self) { v in v.setCenterUndoably(oldCenter) } if self.undoer.undoing || self.undoer.redoing { UIView.animateWithDuration( 0.4, delay: 0.1, options: [], animations: { self.center = newCenter // * }, completion: nil) } else { // just do it self.center = newCenter // * } }
My handler code refers to v
, rather than to self
, even though self
and v
are the same object, so that I don’t accidentally capture self
strongly in the closure. The reason for setting oldCenter
before calling registerUndoWithTarget:handler:
is to capture the value of self.center
as it is now; if our handler
function were to call setCenterUndoably(v.center)
, we’d be using the value that v.center
will have at undo time, and we would be setting the center to the value it already has, which would be pointless.
Our code works, but we are failing to take full advantage of the fact that we now have the ability to register with the undo manager a full-fledged function rather than a mere function call. This means that we can move into the handler:
function everything that should happen when undoing. This includes the animation! So our setCenterUndoably:
implementation now looks like this:
func setCenterUndoably (newCenter:CGPoint) { let oldCenter = self.center self.undoer.registerUndoWithTarget(self) { v in UIView.animateWithDuration( 0.4, delay: 0.1, options: [], animations: { v.center = oldCenter }, completion: nil) v.setCenterUndoably(oldCenter) } if !(self.undoer.undoing || self.undoer.redoing) { // just do it self.center = newCenter } }
That’s much cleaner. Our handler:
function still needs to call setCenterUndoably:
, because otherwise we won’t get redo registration during undo. But if we are undoing or redoing, our registerUndoWithTarget:
call is the only thing that happens; we set self.center
to newCenter
only if we were called by the dragging:
gesture recognizer handler.
But wait! In that case, why are we setting self.center
here at all? We can do it back in the dragging:
gesture recognizer handler, just we were doing before we added undo to this app! The result is, I think, the cleanest and clearest implementation of all:
func registerForUndo() { let oldCenter = self.center self.undoer.registerUndoWithTarget(self) { v in UIView.animateWithDuration( 0.4, delay: 0.1, options: [], animations: { v.center = oldCenter }, completion: nil) v.registerForUndo() } } 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.registerForUndo() // * self.center = c // * p.setTranslation(CGPointZero, inView: self.superview!) case .Ended, .Cancelled: self.undoer.endUndoGrouping() default: break } }
(A further refinement might be to move registerForUndo
inside the dragging:
gesture recognizer handler as a local function.)
We must also decide how to let the user request undo and redo. In developing the code from the preceding section, I used two buttons: an Undo button that sent undo
to the NSUndoManager, and a Redo button that sent redo
to the NSUndoManager. This can be a perfectly reasonable interface, but let’s talk about some others.
By default, your app supports shake-to-edit. This means the user can shake the device to bring up an undo/redo interface. We discussed this briefly in Chapter 22. If you don’t turn off this feature by setting the shared UIApplication’s applicationSupportsShakeToEdit
property to false
, then when the user shakes the device, the runtime walks up the responder chain, starting with the first responder, looking for a responder whose inherited undoManager
property returns an actual NSUndoManager instance. If it finds one, it puts up an undo/redo interface, allowing the user to communicate with that NSUndoManager.
You will recall what it takes for a UIResponder to be first responder in this sense: it must return true
from canBecomeFirstResponder
, and it must actually be made first responder through a call to becomeFirstResponder
. Let’s make MyView satisfy these requirements. For example, we might call becomeFirstResponder
at the end of dragging:
, like this:
override func canBecomeFirstResponder() -> Bool { return true } func dragging (p : UIPanGestureRecognizer) { switch p.state { // ... the rest as before ... case .Ended, .Cancelled: self.undoer.endUndoGrouping() self.becomeFirstResponder() default: break } }
Then, to make shake-to-edit work, we have only to provide a getter for the undoManager
property that returns our undo manager, self.undoer
:
let undoer = NSUndoManager() override var undoManager : NSUndoManager? { return self.undoer }
This works: shaking the device now brings up the undo/redo interface, and its buttons work correctly. However, I don’t like the way the buttons are labeled; they just say Undo and Redo. To make this interface more expressive, we should provide a string describing each undoable action by calling setActionName:
. We can appropriately and conveniently do this at the same time that we register our undo action:
self.undoer.setActionName("Move")
Now the undo/redo interface has more informative labels, as shown in Figure 26-1.
Another possible interface is through a menu (Figure 26-2). Personally, I prefer this approach, as I am not fond of shake-to-edit (it seems both violent and unreliable). This is the same menu used by a UITextField or UITextView for displaying the Copy and Paste menu items (Chapter 10). The requirements for summoning this menu are effectively the same as those for shake-to-edit: we need a responder chain with a first responder at the bottom of it. So the code we’ve just supplied for making MyView first responder remains applicable.
We can make a menu appear, for example, in response to a long press on our MyView instance. So let’s attach another gesture recognizer to MyView. This will be a UILongPressGestureRecognizer, whose action handler is called longPress:
. Recall from Chapter 10 how to implement the menu: we get the singleton global UIMenuController object and specify an array of custom UIMenuItems as its menuItems
property. We can make the menu appear by sending the UIMenuController the setMenuVisible:animated:
message. But a particular menu item will appear in the menu only if we also return true
from canPerformAction:withSender:
for that menu item’s action. Delightfully, the NSUndoManager’s canUndo
and canRedo
properties tell us what value canPerformAction:withSender:
should return. We can also get the titles for our custom menu items from the NSUndoManager itself, through its undoMenuItemTitle
property:
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) } } override func canPerformAction( action: Selector, withSender sender: AnyObject!) -> Bool { if action == Selector("undo:") { return self.undoer.canUndo } if action == Selector("redo:") { return self.undoer.canRedo } return super.canPerformAction(action, withSender: sender) } func undo(_:AnyObject?) { self.undoer.undo() } func redo(_:AnyObject?) { self.undoer.redo() }
3.145.204.201