Chapter 26. Undo

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.

Undo Manager

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()

Target–Action Undo

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:

  1. We perform an action by way of setCenterUndoably:, which calls registerUndoWithTarget:selector:object: with the old value of self.center. The NSUndoManager adds this to its undo stack.
  2. Now suppose we want to undo that action. We send undo to the NSUndoManager.
  3. The NSUndoManager calls 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.
  4. Now suppose we want to redo that undo. We send 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.)

Undo Grouping

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()
        }
    }

Invocation Undo

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
    }
}

Functional Undo

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.)

Undo Interface

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.

Shake-To-Edit

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.

The shake-to-edit undo/redo interface
Figure 26-1. The shake-to-edit undo/redo interface

Undo Menu

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.

The shared menu as an undo/redo interface
Figure 26-2. The shared menu as an undo/redo interface

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()
}

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.204.201