NSUndo​Manager

We all make mistakes. Thankfully, Foundation comes to our rescue for more than just our misspellings. Cocoa includes a simple yet robust API for undoing or redoing actions through NSUndoManager.

By default, each application window has an undo manager, and any object in the responder chain can manage a custom undo manager for performing undo and redo operations local to their respective view. UITextField and UITextView use this functionality to automatically provide support for undoing text edits while first responder. However, indicating whether other actions can be undone is an exercise left for the app developer.

Creating an undoable action requires three steps: performing a change, registering an “undo operation” which can reverse the change, and responding to a request to undo the change.

Undo Operations

To show an action can be undone, register an “undo operation” while performing the action. The Undo Architecture documentation defines an “undo operation” as:

A method for reverting a change to an object, along with the arguments needed to revert the change.

The operation specifies:

  • The object to receive a message if an undo is requested
  • The message to send and
  • The arguments to pass with the message

If the method invoked by the undo operation also registers an undo operation, the undo manager provides redo support without extra work, as it is “undoing the undo”.

There are two types of undo operations, “simple” selector-based undo and complex “NSInvocation-based undo”.

Registering a Simple Undo Operation

To register a simple undo operation, invoke NSUndoManger -registerUndoWithTarget:selector:object: on a target which can undo the action. The target is not necessarily the modified object, and is often a utility or container which manages the object’s state. Specify the name of the undo action at the same time, using NSUndoManager -setActionName:. The undo dialog shows the name of the action, so it should be localized.

func updateScore(score: NSNumber) {
    undoManager.registerUndoWithTarget(self, selector:Selector("updateScore:"), object:myMovie.score)
    undoManager.setActionName(NSLocalizedString("actions.update", comment: "Update Score"))
    myMovie.score = score
}

Registering a Complex Undo Operation with NSInvocation

Simple undo operations may be too rigid for some uses, as undoing an action may require more than one argument. In these cases, we can leverage NSInvocation to record the selector and arguments required. Calling prepareWithInvocationTarget: records which object will receive the message which will make the change.

func movePiece(piece: ChessPiece, row:UInt, column:UInt) {
    let undoController : ViewController = undoManager?.prepareWithInvocationTarget(self) as ViewController
    undoController.movePiece(piece, row:piece.row, column:piece.column)
    undoManager?.setActionName(NSLocalizedString("actions.move-piece", "Move Piece"))

    piece.row = row
    piece.column = column
    updateChessboard()
}

The magic here is that NSUndoManager implements forwardInvocation:. When the undo manager receives the message to undo -movePiece:row:column:, it forwards the message to the target since NSUndoManager does not implement this method.

Performing an Undo

Once undo operations are registered, actions can be undone and redone as needed, using NSUndoManager -undo and NSUndoManager -redo.

Responding to the Shake Gesture on iOS

By default, users trigger an undo operation by shaking the device. If a view controller should handle an undo request, the view controller must:

  1. Be able to become first responder
  2. Become first responder once its view appears,
  3. Resign first responder when its view disappears

When the view controller then receives the motion event, the operating system presents a dialog to the user when undo or redo actions are available. The undoManager property of the view controller will handle the user’s choice without further involvement.

class ViewController: UIViewController {
    override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated)
        becomeFirstResponder()
    }

    override func viewWillDisappear(animated: Bool) {
        super.viewWillDisappear(animated)
        resignFirstResponder()
    }

    override func canBecomeFirstResponder() -> Bool {
        return true
    }

    
}

Customizing the Undo Stack

Grouping Actions Together

All undo operations registered during a single run loop will be undone together, unless “undo groups” are otherwise specified. Grouping allows undoing or redoing many actions at once. Although each action can be performed and undone individually, if the user performs two at once, undoing both at once preserves a consistent user experience.

func readAndArchiveEmail(email: Email) {
    undoManager?.beginUndoGrouping()
    markEmail(email, read: true)
    archiveEmail(email)
    undoManager?.setActionName(NSLocalizedString("actions.read-archive", comment:"Mark as Read and Archive"))
    undoManager?.endUndoGrouping()
}

func markEmail(email: Email, read:Bool) {
    let undoController: ViewController = undoManager?.prepareWithInvocationTarget(self) as ViewController
    undoController.markEmail(email, read:email.read)
    undoManager?.setActionName(NSLocalizedString("actions.read", comment:"Mark as Read"))
    email.read = read
}

func archiveEmail(email: Email) {
    let undoController: ViewController = undoManager?.prepareWithInvocationTarget(self) as ViewController
    undoController.moveEmail(email, toFolder:"Inbox")
    undoManager?.setActionName(NSLocalizedString("actions.archive", comment:"Archive"))
    moveEmail(email, toFolder:"All Mail")
}

Clearing the Stack

Sometimes the undo manager’s list of actions should be cleared to avoid confusing the user with unexpected results. The most common cases are when the context changes dramatically, like changing the visible view controller on iOS or externally made changes occurring on an open document. When that time comes, the undo manager’s stack can be cleared using NSUndoManager -removeAllActions or NSUndoManager -removeAllActionsWithTarget: if finer granularity is needed.

Caveats

If an action has different names for undo versus redo, check whether an undo operation is occurring before setting the action name to ensure the title of the undo dialog reflects which action will be undone. An example would be a pair of opposing operations, like adding and removing an object:

func addItem(item: NSObject) {
    undoManager?.registerUndoWithTarget(self, selector: Selector("removeItem:"), object:item)
    if undoManager?.undoing == false {
        undoManager?.setActionName(NSLocalizedString("action.add-item", comment: "Add Item"))
    }
    myArray.append(item)
}

func removeItem(item: NSObject) {
    if let index = find(myArray, item) {
        undoManager?.registerUndoWithTarget(self, selector: Selector("addItem:"), object:item)
        if undoManager?.undoing == false {
            undoManager?.setActionName(NSLocalizedString("action.remove-item", comment: "Remove Item"))
        }
        myArray.removeAtIndex(index)
    }
}

If your test framework runs many tests as a part of one run loop (like Kiwi), clear the undo stack between tests in teardown. Otherwise tests will share undo state and invoking NSUndoManager -undo during a test may lead to unexpected results.


There are even more ways to refine behavior with NSUndoManager, particularly for grouping actions and managing scope. Apple also provides usability guidelines for making undo and redo accessible in an expected and delightful way.

We all may wish to live without mistakes, but Cocoa gives us a way to let our users live with fewer regrets as it makes some actions easily changeable.

NSMutableHipster

Questions? Corrections? Issues and pull requests are always welcome.

This article uses Swift version 1.0. Find status information for all articles on the status page.

Written by Delisa Mason
Delisa Mason

Delisa Mason (@kattrali) is the creator of the Xcode Package Manager, Alcatraz, as well as a CocoaPods Core Team member.

Next Article

What if, instead of lowering source code down for the purpose of execution, we raised it for the purpose of understanding?