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
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Undoing Detail View Changes

At the bottom of PersonDetailViewController.swift, insert:

// MARK: - Model & State Types

extension PersonDetailViewController {
// 1
  private func personDidChange(from fromPerson: Person) {
// 2
    collectionView?.reloadData() 
// 3
    undoManager.registerUndo(withTarget: self) { target in
      let currentFromPerson: Person = self.person
      self.person = fromPerson
      self.personDidChange(from: currentFromPerson)
    }
// 4
    // Update button UI 
    DispatchQueue.main.async {
      self.undoButton.isEnabled = self.undoManager.canUndo
      self.redoButton.isEnabled = self.undoManager.canRedo
    }
  }
}

Here’s what going on above:

  1. personDidChange(from:) takes the previous version of person as a parameter.
  2. Reloading the collection updates the preview and cell selections.
  3. undoManager registers an undo operation which, when invoked, sets self.person to the previous Person then calls personDidChange(from:) recursively. personDidChange(from:) updates the UI and registers the undo’s undo, i.e., it registers a redo path for the undone operation.
  4. If undoManager is capable of an undo — i.e., canUndo, enable the undo button — otherwise, disable it. It is the same for redo. While the code is running on the main thread, the undo manager doesn’t update its state until after this method returns. Using the DispatchQueue block allows the UI update to wait until this undo/redo operation completes.

Now, at the top of both collectionView(_:didSelectItemAt:) and collectionView(_:didDeselectItemAt:), add:

let fromPerson: Person = person

to retain an instance of the original person.

At the end of those same delegate methods, replace collectionView.reloadData() with:

personDidChange(from: fromPerson)

in order to register an undo that reverts to fromPerson. You removed collectionView?.reloadData() because that is already called in personDidChange(from:), so you don’t need to do it twice.

In undoTapped(), add:

undoManager.undo()

and in redoTapped(), add:

undoManager.redo()

to trigger undo and redo respectively.

Implementing shaking to undo/redo

Next, you’ll add the ability to shake the device running the app to initiate undo/redo. At the bottom of viewDidAppear(_:), add:

becomeFirstResponder()

at the bottom of viewWillDisappear(_:), add:

resignFirstResponder()

then below viewWillDisappear(_:), add:

override var canBecomeFirstResponder: Bool {
  return true
}

When the user shakes his or her device running the app to undo/redo, NSResponder goes up the responder chain looking for a next responder that returns an NSUndoManager object. When you set PersonDetailViewController as the first responder, its undoManager will respond to a shake gesture with the option to undo/redo.

Build and run your app. To test your changes, navigate to PersonDetailViewController, switch between a few different hair colors, and then tap or shake to undo/redo:

Adding Undo and Redo

Notice that tapping undo/redo doesn’t change the preview.

To debug, add the following within the top of the registerUndo(withTarget:handler:) closure:

print(fromPerson.face.hairColor)
print(self.person.face.hairColor)

Again, build and run your app. Try changing a person’s hair color a few times, undoing and redoing. Now, look at the debug console and you should see that, whenever you undo/redo, both print statements output only the final selected color. Is UndoManager dropping the ball already?

Not at all! The issue is elsewhere in the code.

Improving Local Reasoning

Local reasoning is the concept of being able to understand sections of code independent from context.

In this tutorial, for example, you’ve used closures, lazy initialization, protocol extensions and condensed code paths to make portions of your code understandable without venturing far outside their scopes – when viewing only “local” code, for example.

What does this have to do with the bug you’ve just encountered? You can fix the bug by improving your local reasoning. By understanding the difference between reference and value types, you’ll learn how to maintain better local control of your code.

Reference Types vs. Value Types

Reference and value are the two “type” categories in Swift. For types with reference semantics, such as a class, different references to the same instance share the same storage. Value types, however — such as structs, enums and tuples — each hold their own separate data.

To understand how this contributes to your current conundrum, answer the following questions using what you’ve just learned about reference vs. value type data storage:

[spoiler title=”person.face.hairColor == ??”]

[/spoiler]

[spoiler title=”person.face.hairColor == ??”]

[/spoiler]

  1. If Person is a class:
    var person = Person()
    person.face.hairColor = .blonde
    var anotherPerson = person
    anotherPerson.face.hairColor = .black
    
    person.face.hairColor == ??
    
    person.face.hairColor == .black
  2. If Person is a struct:
    var person = Person()
    person.face.hairColor = .blonde
    var anotherPerson = person
    anotherPerson.face.hairColor = .black
    
    person.face.hairColor == ??
    
    person.face.hairColor == .blonde
var person = Person()
person.face.hairColor = .blonde
var anotherPerson = person
anotherPerson.face.hairColor = .black

person.face.hairColor == ??
person.face.hairColor == .black
var person = Person()
person.face.hairColor = .blonde
var anotherPerson = person
anotherPerson.face.hairColor = .black

person.face.hairColor == ??
person.face.hairColor == .blonde

The reference semantics in question one hurts local reasoning because the value of the object can change underneath your control and no longer make sense without context.

So in Person.swift, change class Person { to:

struct Person {

so that Person now has value semantics with independent storage.

Build and run your app. Then, change a few features of a person, undoing and redoing to see what happens:

Fixed undo and redo

Undoing and redoing selections now works as expected.

Next, add the ability to undo/redo name updates. Return to PersonDetailViewController.swift and, within the UITextFieldDelegate extension, add:

func textFieldDidEndEditing(_ textField: UITextField) {
  if let text = textField.text {
    let fromPerson: Person = person
    person.name = text
    personDidChange(from: fromPerson)
  }
}

When the text field finishes editing, set person‘s new name to the field’s text and register an undo operation for that change.

Build and run. Now, change the name, change characteristics, undo, redo, etc. Mostly everything should work as planned but you may notice one small issue. If you select the name field then press return without making any edits, the undo button becomes active, indicating that an undo action was registered to undoManager even though nothing actually changed:

Undo with no actual changes

In order to fix this, you could compare the original and updated names, and register the undo only if those two values don’t match, but this is poor local reasoning — especially as person‘s property list grows, it’s easier to compare entire person objects instead of individual properties.

At top of personDidChange(from:), add:

if fromPerson == self.person { return }

Logically, it seems as if this line should compare the old and new person but there’s an error:

Binary operator '==' cannot be applied to operands of type 'Person' and 'Person!'

As it turns out, there’s no built-in way to compare Person objects since several of their properties are composed of custom types. You’ll have to define the comparison criteria on your own. Luckily, struct offers an easy way to do that.