read

UndoManager in part of Foundation, thus available for both iOS, macOS, etc.

Registering an undo action

Whenever you have an action that you want to undo, you will register a closure with your undo implementation, via an instance of undoManager (which UI/NSResponder holds one).

Let’s use an example whereby a user “add” an item, where the undo is to “remove”.

@IBAction func add(_ sender: Any) {
    let newModel = ...

    undoManager?.registerUndo(withTarget: self) { target in
        target.removeObject(newModel)
    }
    undoManager?.setActionName("Add")

    addObject(newModel)
}

addObject and removeObject are your custom functions to add/remove the model object.

registerUndo will tell undoManager how to perform the undo, by running the closure. Typically you can use the view controller, or any controller, as the target.

Note that undoManager does not hold a strong reference to target, so feel safe to use without specifying weak self.

You have to set the action name (a localized String), as it will be used as a display text to user, with the prefix “Undo”, eg “Undo Add”.

When user press Cmd+Z, or shake the iPhone, the undo operation will be performed.

Registering a redo action

Redo = undo an undo

To have a redo, you have to register an undo in the undo closure.

We can improve the code, and abstract with a new func addObjectWithUndo.

@IBAction func add(_ sender: Any) {
    let newModel = ...
    addObjectWithUndo(newModel)
}

// Register the undo operation and add the object model
private func addObjectWithUndo(_ object: Any) {
    undoManager?.registerUndo(withTarget: self) { target in
        // Call the corresponding `removeObjectWithUndo`, which also register another undo.
        // Remember: redo = undo an undo
        target.removeObjectWithUndo(object)
    }
    undoManager?.setActionName("Add")

    addObject(object)
}

private func removeObjectWithUndo(_ object: Any) {
    undoManager?.registerUndo(withTarget: self) { target in
        target.addObjectWithUndo(object)
    }
    undoManager?.setActionName("Remove")

    removeObject(newModel)
}

The pair of add and remove func will provide the redo capability.

Pitfall: Run loop undo all instead of popping one

In our example, there is a critical pitfall. If you were to add multiple times, then undo, you expect to pop the last undo operation off the stack.

However, the example is buggy (on purpose), and ALL will be undo because:

NSUndoManager normally creates undo groups automatically during a cycle of the run loop. The first time it is asked to record an undo operation in the cycle, it creates a new group. Then, at the end of the cycle, it closes the group. You can create additional, nested undo groups.

By default, groupsByEvent is true. This creates the group automatically in a run loop.

To “fix”, you should set groupsByEvent to false., and specify when you begin/end the groping manually.

private func addObjectWithUndo(_ object: Any) {
    undoManager?.groupsByEvent = false
    undoManager?.beginUndoGrouping()
    undoManager?.registerUndo(...)
    undoManager?.setActionName("Add")
    undoManager?.endUndoGrouping()
    ...
}

That 5 lines of code can be abstracted for every time you want to register an undo (:

Design Patterns

iOS has design patterns in regards to undo/redo. Usually, undo is unnecessary as edits are committed immediately.


Image

@samwize

¯\_(ツ)_/¯

Back to Home