Chapter 26. Undo

This chapter has been revised for Early Release. It reflects iOS 14, Xcode 12, and Swift 5.3. But screenshots have not been retaken; they still show the Xcode 11 / iOS 13 interface.

The idea of undo is that the user can reverse a recently performed action. Behind the scenes, the app maintains an internal stack of undoable actions; undoing reverses the action at the top of the stack, and also makes that action available for redo through a secondary stack.

A pervasive undo capability is characteristic primarily of desktop macOS applications, but some iOS apps may also benefit from a limited undo facility, and certain built-in views — in particular, those that involve text entry (Chapter 11) — implement it already. UIDocument (see Chapter 23) integrates with your undo facility to update the document’s “dirty” state automatically.

Undo operates through an undo manager — an instance of UndoManager. Every time the user performs an action that is to be undoable, you register that action with the undo manager. When the user asks to undo, you send undo to the undo manager; when the user asks to redo, you send redo to the undo manager. In both cases, the undo manager performs the registered action and adjusts its internal undo and redo stacks appropriately.

I’ll introduce the UndoManager class with a simple example; for more information, read Apple’s Undo Architecture in the documentation archive, along with the class documentation.

Target–Action Undo

I’ll illustrate an UndoManager for a simple app that has just one kind of undoable action. In my example, the user can drag a small square around the screen. Our goal is to make the drag undoable.

We’ll start with an instance of a UIView subclass, MyView, to which has been attached a UIPanGestureRecognizer to make it draggable; the gesture recognizer’s action target is the MyView instance itself, which implements the typical drag action function described in Chapter 5:

@objc func dragging (_ p : UIPanGestureRecognizer) {
    switch p.state {
    case .began, .changed:
        let delta = p.translation(in:self.superview!)
        var c = self.center
        c.x += delta.x; c.y += delta.y
        self.center = c
        p.setTranslation(.zero, in: self.superview!)
    default:break
    }
}

We will need an UndoManager instance. Let’s store it in a property of MyView itself, self.undoer:

let undoer = UndoManager()

To test my app’s undo capability, the interface also contains two buttons: an Undo button that sends undo to the view’s undo manager, and a Redo button that sends redo to the view’s undo manager.

We need to tell the undo manager to register the drag action as undoable. There are two main ways of doing that. One way is to call this UndoManager method:

  • registerUndo(withTarget:selector:object:)

This method uses a target–action architecture: you provide a target, a selector for an action method that takes one parameter, and a value that will be that parameter. Later, if the UndoManager is sent the undo or redo message, it calls the action method on that target with the object as argument. The job of the action method is to undo whatever it is that needs undoing.

Let’s use registerUndo(withTarget:selector:object:) to configure undo in our app. How? Well, what we want to undo here is the setting of our center property:

var c = self.center
c.x += delta.x; c.y += delta.y
self.center = c // *

We need to express this as a method taking one parameter, so that the undo manager can call it as the selector: action method. So, in our dragging(_:) method, instead of setting self.center to c directly, we now call a secondary method:

var c = self.center
c.x += delta.x; c.y += delta.y
self.setCenterUndoably(c) // *

We have posited a method setCenterUndoably(_:) that doesn’t exist. Let’s write it. What should it do? At a minimum, it should do the job that setting self.center used to do! At the same time, we want the undo manager to be able to call this method. The undo manager doesn’t know the type of the parameter that it will be passing to us, so its object: parameter is typed as Any. Therefore, the parameter of this method also needs to be typed as Any:

func setCenterUndoably (_ newCenter:Any) {
    self.center = newCenter as! CGPoint
}

This works, in the sense that the view is draggable exactly as before; but we have not yet made this action undoable — because we have not called registerUndo(withTarget:selector:object:). To do so, we must ask ourselves what message the UndoManager would need to send in order to undo the action we are about to perform. Clearly, we would want the UndoManager to set self.center back to the value it has now. And what method would the UndoManager call in order to do that? It would call setCenterUndoably(_:), the very method we are implementing! So now we have this:

@objc func setCenterUndoably (_ newCenter:Any) {
    self.undoer.registerUndo(withTarget: self,
        selector: #selector(setCenterUndoably),
        object: self.center)
    self.center = newCenter as! CGPoint
}

That code works; it makes our action undoable!

Not only is our action now undoable; it is redoable as well. How can this be? Well, it turns out that UndoManager has an internal state, and responds differently to registerUndo(withTarget:selector:object:) depending on that state. If the UndoManager is sent registerUndo(withTarget: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).

Confused? Here’s how our code works to undo and then redo an action:

  1. We set self.center by way of setCenterUndoably(_:), which calls registerUndo(withTarget:selector:object:) with the old value of self.center. The UndoManager adds this to its undo stack.

  2. Now suppose we want to undo that action. We send undo to the UndoManager.

  3. The UndoManager calls setCenterUndoably(_:) with the old value that we passed it in step 1. So we are going to set the center back to that old value. But before we do that, we send registerUndo(withTarget:selector:object:) to the UndoManager with the current value of self.center. The UndoManager 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 UndoManager, and sure enough, the UndoManager calls setCenterUndoably(_:) with the value that we previously undid! And, once again, we call registerUndo(withTarget:selector:object:) with an action that goes onto the UndoManager’s undo stack — and we’re back to step 1.

Undo Grouping

So far, so good. But our implementation of undo is very annoying, because we are adding a new object to the undo stack every time dragging(_:) is called — and it is called many times during the course of a single drag! This means that undoing once 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 .changed:
        let delta = p.translation(in:self.superview!)
        var c = self.center
        c.x += delta.x; c.y += delta.y
        self.setCenterUndoably(c)
        p.setTranslation(.zero, in: self.superview!)
    case .ended, .cancelled:
        self.undoer.endUndoGrouping() // *
    default:break
    }
}

This works: each complete drag gesture, from the time the user’s finger first touches the view and drags it to the time the user’s finger is lifted, is now undoable (and redoable) as a single unit.

A further refinement would be to animate the “drag” that the UndoManager performs when it undoes or redoes a user drag gesture. To do so, we take advantage of the fact that we can examine the UndoManager’s state by way of its isUndoing and isRedoing properties; we animate the center change when the UndoManager is “dragging,” but not when the user is dragging:

@objc func setCenterUndoably (_ newCenter:Any) {
    self.undoer.registerUndo(withTarget: self,
        selector: #selector(setCenterUndoably),
        object: self.center)
    if self.undoer.isUndoing || self.undoer.isRedoing {
        UIView.animate(withDuration:0.4, delay: 0.1) {
            self.center = newCenter as! CGPoint
        }
    } else { // just do it
        self.center = newCenter as! CGPoint
    }
}

Functional Undo

I said earlier that there are two main ways of registering an action as undoable with the undo manager. The second way is to call this UndoManager method:

  • registerUndo(withTarget:handler:)

The handler: is a function that will be called when we call undo or redo. It must take one parameter, which will be whatever you pass here as the target: argument. This is a more modern idiom than the target–action architecture for expressing the registration of an action. If we adopt this approach, then our setCenterUndoably(_:) no longer needs to take an Any as its parameter; it can take a CGPoint:

func setCenterUndoably (_ newCenter:CGPoint) {
    self.undoer.registerUndo(withTarget: self) {
        [oldCenter = self.center] myself in
        myself.setCenterUndoably(oldCenter)
    }
    if self.undoer.isUndoing || self.undoer.isRedoing {
        UIView.animate(withDuration: 0.4, delay: 0.1) {
            self.center = newCenter
        }
    } else { // just do it
        self.center = newCenter
    }
}

Let’s look more closely at my call to registerUndo(withTarget:handler:) and the anonymous handler: function that accompanies it:

self.undoer.registerUndo(withTarget: self) {
    [oldCenter = self.center] myself in
    myself.setCenterUndoably(oldCenter)
}

The example shows what the target: parameter is for — it’s to avoid retain cycles. By passing self as the target: argument, I can retrieve it as the parameter in the handler: function (I’ve called the parameter myself). In the body of the handler: function, I never have to refer to self and there is no retain cycle.

I’ve also taken advantage of a little-known feature of Swift anonymous function capture lists, allowing me to get the value of self.center as it is now and capture it in a local reference (oldCenter) inside the anonymous function. The reason is that if the anonymous function were to call setCenterUndoably(myself.center), we’d be using the value that myself.center will have at undo time, and would be pointlessly setting the center to itself.

Our code works perfectly, but we can go further. 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 body rather than a mere function call. That fact means that the handler: function can contain everything that should happen when undoing, including the animation:

self.undoer.registerUndo(withTarget: self) {
    [oldCenter = self.center] myself in
    UIView.animate(withDuration: 0.4, delay: 0.1) {
        myself.center = oldCenter
    }
    myself.setCenterUndoably(oldCenter)
}

But we can go further still. Let’s ask ourselves: Why are we setting self.center here at all? We can do it back in the gesture recognizer’s dragging(_:) action method, just as we were doing before we added undo to this app. And in that case, we no longer need a separate setCenterUndoably method! True, we still need some function that calls registerUndo with a call to itself, because that’s how we get redo registration during undo; but this can be a local function inside the dragging(_:) method. Our dragging(_:) method can provide a complete undo implementation internally, resulting in a far more legible and encapsulated architecture:

@objc func dragging (_ p : UIPanGestureRecognizer) {
    switch p.state {
    case .began:
        self.undoer.beginUndoGrouping()
        fallthrough
    case .began, .changed:
        let delta = p.translation(in:self.superview!)
        var c = self.center
        c.x += delta.x; c.y += delta.y
        func registerForUndo() {
            self.undoer.registerUndo(withTarget: self) {
                [oldCenter = self.center] myself in
                UIView.animate(withDuration: 0.4, delay: 0.1) {
                    myself.center = oldCenter
                }
                registerForUndo()
            }
        }
        registerForUndo() // *
        self.center = c // *
        p.setTranslation(.zero, in: self.superview!)
    case .ended, .cancelled:
        self.undoer.endUndoGrouping()
    default: break
    }
}

Undo Interface

We must also decide how to let the user request undo and redo. While I was developing the code from the preceding section, I used an Undo button and a Redo button. 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 that 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, and if the user doesn’t turn it off in Settings, 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 UndoManager instance. If it finds one, it puts up an alert with an Undo button, a Redo button, or both; if the user taps a button, the runtime communicates directly with that UndoManager, calling its undo or redo method for us.

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 have MyView satisfy those requirements. We might call becomeFirstResponder at the end of dragging(_:), like this:

override var canBecomeFirstResponder : Bool {
    return true
}
@objc func dragging (_ p : UIPanGestureRecognizer) {
    switch p.state {
    // ... the rest as before ...
    case .ended, .cancelled:
        self.undoer.endUndoGrouping()
        self.becomeFirstResponder()
    default: break
    }
}

Then, to implement undo through shake-to-edit, we have only to provide a getter for the undoManager property that returns our undo manager, self.undoer:

let undoer = UndoManager()
override var undoManager : UndoManager? {
    return self.undoer
}

This works: shaking the device now brings up the undo/redo alert, 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 the undo manager with a string describing each undoable action. We do that by calling setActionName(_:); we can call it at the same time that we register our undo action:

self.undoer.setActionName("Move")

Now the undo/redo alert has more informative labels, as shown in Figure 26-1.

pios 3901
Figure 26-1. The shake-to-edit undo/redo alert

Built-In Gestures

Starting in iOS 13, three gestures are interpreted as asking for undo and redo:

  • Double tap with three fingers means undo.

  • Swipe left with three fingers means undo.

  • Swift right with three fingers means redo.

The idea is probably to make shake-to-edit obsolete, as it is rather violent and somewhat unreliable.

The response when the user makes one of these gestures is comparable to shake-to-edit: the runtime walks up the responder chain looking for a responder with an UndoManager in its undoManager property. Unlike shake-to-edit, when it finds this responder it doesn’t put up an alert; it simply sends the undo manager undo or redo directly. It also puts up a little caption at the top of the screen explaining what’s happening (Figure 26-2).

pios 3902b
Figure 26-2. The Undo caption

The caption is not a button, nor is the user offered a choice; the gesture is obeyed, and when the caption appears, the undo or redo has already been performed. The caption text does not consult the undo manager’s action name; it merely reads Undo or Redo. I regard this as unfortunate.

Undo Menu

Another possible undo/redo interface is through a menu (Figure 26-3). We can show the same menu used by a UITextField or UITextView for displaying menu items such as Select, Select All, Copy, and Paste (“Text Field Menu”). 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.

pios 3902
Figure 26-3. The shared menu as an undo/redo interface

Let’s cause the menu to appear in response to a long press on our MyView instance. We’ll attach another gesture recognizer to MyView. This will be a UILongPressGestureRecognizer, whose action method is called longPress(_:).

To configure the menu, we get the singleton global UIMenuController object and specify an array of custom UIMenuItems as its menuItems property. To make the menu appear, we send the UIMenuController the showMenu(from:rect:) message.

A particular menu item will actually appear in the menu only if we also return true from canPerformAction(_:withSender:) for that menu item’s action. Delightfully, the UndoManager’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 UndoManager itself, through its undoMenuItemTitle and redoMenuItemTitle properties:

@objc func longPress (_ g : UIGestureRecognizer) {
    if g.state == .began {
        let m = UIMenuController.shared
        let mi1 = UIMenuItem(title: self.undoer.undoMenuItemTitle,
            action: #selector(undo))
        let mi2 = UIMenuItem(title: self.undoer.redoMenuItemTitle,
            action: #selector(redo))
        m.menuItems = [mi1, mi2]
        m.showMenu(from: self, rect: self.bounds)
    }
}
override func canPerformAction(_ action: Selector,
    withSender sender: Any?) -> Bool {
        if action == #selector(undo) {
            return self.undoer.canUndo
        }
        if action == #selector(redo) {
            return self.undoer.canRedo
        }
        return super.canPerformAction(action, withSender: sender)
}
@objc func undo(_: Any?) {
    self.undoer.undo()
}
@objc func redo(_: Any?) {
    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
18.190.217.134