UndoManager Tutorial: How to Implement With Swift Value Types

In this tutorial you’ll learn how to build an undo manager, using Swift and value types, leveraging the Foundation’s UndoManager class By Lyndsey Scott.

Leave a rating/review
Download materials
Save for later
Share
Note: This tutorial was built for Xcode 10 and iOS 12.

Nobody’s perfect. And once you implement UndoManager, your users don’t have to be either.

UndoManager provides a simple way to add undo/redo functionality to your apps. You may also be able to prevent the occasional flaw in your reasoning by keeping things more “local”.

In this tutorial, you’ll build an app called People Keeper to improve your local reasoning with Swift value types and learn how to use that improved local reasoning to achieve flawless undo/redo implementations.

Note: This tutorial assumes that you have an intermediate knowledge of iOS and Swift development. If you’re new to iOS development and/or Swift, check out our “Learn to Code iOS Apps with Swift Tutorial” series first.

Getting Started

Download the materials for this tutorial using the Download Materials link found at the top and bottom of this page. Build and run the starter app:

The starter app

The app is pre-populated with some folks you’ve supposedly encountered and wanted to remember. Click on Bob, Joan and/or Sam, and you’ll see that their physical features, likes and dislikes are specified in the cells below the preview.

Tapping Bob in PeopleListViewController (left) opens PersonDetailViewController (right). The screenshot series on the right shows PersonDetailViewController‘s scrolled page contents.

To understand the starter code, click through the project files and read the comments throughout. It’ll be your job in this tutorial to program the ability to add and edit your contacts.

Making Changes

What if Sam shaves his mustache or Joan starts wearing glasses? Or, during a particularly harsh winter, Bob decides that he dislikes everything including the weather? It’s useful to be able to update the people in People Keeper in real time.

Implementing selection behaviors

To start, if you choose a new feature or topic in PersonDetailViewController, the preview should update. To do this, at the bottom of the extension marked by UICollectionViewDelegate and UICollectionViewDataSource in PersonDetailViewController.swift, add:

override func collectionView(_ collectionView: UICollectionView,
                             didSelectItemAt indexPath: IndexPath) {
// 1
  switch Section(at: indexPath) {
// 2
  case .hairColor:
    person.face.hairColor = Person.HairColor.allCases[indexPath.row]
  case .hairLength:
    person.face.hairLength = Person.HairLength.allCases[indexPath.row]
  case .eyeColor:
    person.face.eyeColor = Person.EyeColor.allCases[indexPath.row]
// 3
  case .glasses:
    person.face.glasses = true
// 4
  case .facialHair:
    person.face.facialHair.insert(Person.FacialHair.allCases[indexPath.row])
// 5
  case .likes:
    person.likes.insert(Person.Topic.allCases[indexPath.row])
    person.dislikes.remove(Person.Topic.allCases[indexPath.row])
  case .dislikes:
    person.dislikes.insert(Person.Topic.allCases[indexPath.row])
    person.likes.remove(Person.Topic.allCases[indexPath.row])
  default:
    break
  }
// 6
  collectionView.reloadData()
}

Upon cell selection, the following happens:

  1. Using a switch statement, you execute the case that matches the enumeration value corresponding to the current section.
  2. If the user selects a hair color, set person‘s hair color to the Person.HairColor value at the selected row of the index path. If the user selects a hair length or eye color, set the hair length or eye color as well.
  3. When the user taps the glasses option, person‘s glasses Boolean becomes true.
  4. facialHair is a set since it can contain multiple items. When the user selects a facial hair style, insert it into the facial hair set.
  5. If the user selects a topic in the likes or dislikes section, add it to the likes or dislikes set respectively. Furthermore, a topic can’t be both liked and disliked, so if the user likes an item, deselect its cell in the dislike section and remove it from the dislike set and vice versa.
  6. Update the preview and selection UI by reloading the collection view.

Implementing deselection behaviors

Next, implement the deselection behaviors. Below the collectionView(_:didSelectItemAt:), add:

// 1
override func collectionView(_ collectionView: UICollectionView,
                             shouldDeselectItemAt indexPath: IndexPath) -> Bool {
  switch Section(at: indexPath) {
  case .facialHair, .glasses, .likes, .dislikes:
    return true
  default:
    return false
  }
}

override func collectionView(_ collectionView: UICollectionView,
                             didDeselectItemAt indexPath: IndexPath) {
    
  switch Section(at: indexPath) {
  // 2
  case .facialHair:
    person.face.facialHair.subtract([Person.FacialHair.allCases[indexPath.row]])
  case .likes:
    person.likes.subtract([Person.Topic.allCases[indexPath.row]])
  case .dislikes:
    person.dislikes.subtract([Person.Topic.allCases[indexPath.row]])
  case .glasses: // 3
    person.face.glasses = false
  default:
    break
  }
  collectionView.reloadData()
}

Here’s what you’re doing in each delegate method:

  1. Here, you specify that only the selected facial hair, glasses, likes and dislikes should be deselectable upon repeated tap. Deselection in any other section should only happen when the user selects another item in that same category.
  2. When the user deselects a facial hair style, likes or dislikes, you remove that deselected item from its respective set.
  3. When the user deselects the glasses feature, you set the glasses Boolean to false.

Build and run the app. You should now see the desired selection behaviors:

Previewing selections

You’ve now proven yourself a worthy commander of this powerful People Keeper technology. You’ve earned the right to wield your new weapon. On that day when a rival developer catches a glimpse of your People Keeper app, one powerful class will rise from the foundation to guard your states and protect against your evil competition…

SuperUndoManager

Introducing UndoManager

UndoManager is a general-purpose undo stack that is capable of simplifying your app’s state management. It can store whatever object or UI states that you’d like to track along with a closure, method or invocation capable of traversing back and forth through those states. Although it simplifies undo/redo when implemented properly, a lesser rival developer will likely implement UndoManager in a way that leads to fatal bugs. The two undo stacks that follow demonstrate a flawed example and a more successful example.

Undo stack example #1

Undo Stack #1

People model and UndoManager stack

Undo Stack #1

Undo Stack #1 is a sequence of small steps that are each responsible for modifying the model and then the view to match. Though this strategy could work in theory, as the list of operations grows, errors become more likely because precisely matching each change in the model to each change in the view becomes increasingly difficult.

To understand why, here’s an exercise:

[spoiler title=”Answer #1″]Bob, Sam[/spoiler]

[spoiler title=”Answer #2″]Bob, Kathy[/spoiler]

[spoiler title=”Answer #3″]Bob, Kathy, Mike[/spoiler]

  1. What does the model look like after you pop the first undo operation off the stack?

    [spoiler title=”Answer #1″]Bob, Sam[/spoiler]

  2. And the second?

    [spoiler title=”Answer #2″]Bob, Kathy[/spoiler]

  3. And the third?

    [spoiler title=”Answer #3″]Bob, Kathy, Mike[/spoiler]

Whether or not you got those answers right, perhaps you can imagine how multiple insertions and deletions can complicate the index calculation of following insertions, deletions or updates. This undo stack is order-dependent and mistakes can cause inconsistencies between your data model and view. Does this error sound familiar?

Model-view inconsistencies cause NSInternalInconsistencyExceptions.

NSInternalInconsistencyException

Model-view inconsistencies cause NSInternalInconsistencyExceptions.

Undo stack example #2

To avoid the error in Undo stack example #1, instead of recording data model and UI changes separately, record entire models:

Undo Stack #2

Updated People model and UndoManager stack

Undo Stack #2

To undo an operation, you can replace the current model with a model on the undo stack. Undo Stacks #1 and #2 do the same thing, but #2 is order-independent and thus less error prone.