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 3 of 4 of this article. Click here to view the first page.

Making Your Struct Equatable

Make your way over to Person.swift and make Person conform to Equatable by adding the following extension:

// MARK: - Equatable

extension Person: Equatable {
  static func ==(_ firstPerson: Person, _ secondPerson: Person) -> Bool {
    return firstPerson.name == secondPerson.name &&
      firstPerson.face == secondPerson.face &&
      firstPerson.likes == secondPerson.likes &&
      firstPerson.dislikes == secondPerson.dislikes
  }
}

Now, if two Person objects share the same name, face, likes and dislikes, they are “equal”; otherwise, they’re not.

Note: You can compare the Face and Topic custom objects within ==(_:_:) without making Face and Topic Equatable since each object is composed solely of Strings, which are inherently equatable objects in Swift.

Navigate back to PersonDetailViewController.swift. Build and run. The if fromPerson == self.person error should have disappeared. Now that you’ve finally gotten that line to work, you’ll soon delete it entirely. Using a diff instead will improve your local reasoning.

Creating Diffs

In programming, a “diff” compares two objects to determine how or whether they differ. By creating a diff value type, (1) the original object, (2) the updated object and (3) their comparison can all live within a single, “local” place.

Within the end of the Person struct in Person.swift, add:

// 1
struct Diff {
  let from: Person
  let to: Person
    
  fileprivate init(from: Person, to: Person) {
    self.from = from
    self.to = to
  }
// 2
  var hasChanges: Bool {
    return from != to
  }
}
// 3
func diffed(with other: Person) -> Diff {
  return Diff(from: self, to: other)
}

This code does the following:

  1. struct Diff holds both the original (from) and new (to) person values.
  2. If from and to are different, hasChanges is true; otherwise it’s false.
  3. diffed(with:) returns a Diff containing self’s Person (from) and the new person (to).

In PersonDetailViewController, replace the line private func personDidChange(from fromPerson: Person) { with:

private func personDidChange(diff: Person.Diff) {

It now takes the entire Diff and not just the “from” object as a parameter.

Then, replace if fromPerson == self.person { return } with:

guard diff.hasChanges else { return }

to use diff‘s hasChanges property.

Also remove the two print statements you added earlier.

Improving Code Proximity

Before replacing the now invalid calls to personDidChange(from:) with calls to personDidChange(diff:), take a look at collectionView(_:didSelectItemAt:) and collectionView(_:didDeselectItemAt:).

In each method, notice that the variable to hold the original person object is initialized at the top of the class, but not used until the bottom. You can improve local reasoning by moving the object creation and use closer together.

Above personDidChange(diff:), add a new method within its same extension:

// 1
private func modifyPerson(_ mutatePerson: (inout Person) -> Void) {
  // 2
  var person: Person = self.person
  // 3
  let oldPerson = person
  // 4
  mutatePerson(&person)
  // 5
  let personDiff = oldPerson.diffed(with: person)
  personDidChange(diff: personDiff)
}

Here’s what’s happening step by step:

  1. modifyPerson(_:) takes in a closure that receives a pointer to a Person object.
  2. var person holds a mutable copy of the class’s current Person.
  3. oldPerson holds a constant reference to the original person object.
  4. Execute the (inout Person) -> Void closure you created at modifyPerson(_:)‘s call site. The code in the closure will mutate the person variable.
  5. Then personDidChange(diff:) updates the UI and registers an undo operation capable of reverting to the fromPerson data model.

To invoke modifyPerson(_:), in collectionView(_:didSelectItemAt:), collectionView(_:didDeselectItemAt:), and textFieldDidEndEditing(_:) replace let fromPerson: Person = person with:

modifyPerson { person in

Replace personDidChange(from: fromPerson) with:

}

in order to condense the code using the modifyPerson(_:) closure.

Similarly, within undoManager‘s registerUndo closure, replace let currentFromPerson: Person = self.person with:

target.modifyPerson { person in

Replace self.personDidChange(from: fromPerson) with:

}

to simplify the code with a closure. This design approach centralizes our update code and thus preserves “locality of reasoning” for our UI.

Select all the code in the class, then navigate to Editor > Structure > Re-Indent to properly realign the new closures.

Then, in personDidChange(diff:), after guard diff.hasChanges else { return } and before collectionView?.reloadData() add:

person = diff.to

This sets the class’ person to the updated person.

Likewise, inside the target.modifyPerson { person in ... } closure replace self.person = fromPerson with:

person = diff.from

This restores the previous person when undoing.

Build and run. Check a person’s detail view and everything should work as expected. Your PersonDetailViewController code is complete!

Celebrating Undo and Redo

Now, tap the < PeopleKeeper back button. Uh-oh… Where did those changes go? You’ll have to pass those updates back to PeopleListViewController somehow.

Updating the People List

Within the top of the PersonDetailViewController class, add:

var personDidChange: ((Person) -> Void)?

Unlike the personDidChange(diff:) method, the personDidChange variable will hold a closure that receives the updated person.

At the end of viewWillDisappear(_:), add:

personDidChange?(person)

When the view disappears upon returning to the main screen, the updated person will return to the closure.

Now you’ll need to initialize that closure.

Back in PeopleListViewController, scroll to prepare(for:sender:). When transitioning to a selected person’s detail view, prepare(for:sender:) currently sends a person object to the destination controller. Similarly, you can add a closure to that same function to retrieve a person object from the destination controller.

At the end of prepare(for:sender:), add:

detailViewController?.personDidChange = { updatedPerson in
  // Placeholder: Update the Data Model and UI
}

This initializes detailViewController‘s personDidChange closure. You will eventually replace the placeholder comment with code to update the data model and UI; before that, there’s some setup to do.

Open PeopleModel.swift. At the end of class PeopleModel, but inside the class, add:

struct Diff {
// 1
  enum PeopleChange {
    case inserted(Person)
    case removed(Person)
    case updated(Person)
    case none
  }
// 2 
  let peopleChange: PeopleChange
  let from: PeopleModel
  let to: PeopleModel
    
  fileprivate init(peopleChange: PeopleChange, from: PeopleModel, to: PeopleModel) {
    self.peopleChange = peopleChange
    self.from = from
    self.to = to
  }
}

Here’s what this code does:

  1. Diff defines a PeopleChange enum, which indicates 1. Whether the change between from and to is an insertion, removal, update or nothing and 2. Which Person was inserted, deleted, or updated.
  2. Diff holds both the original and updated PeopleModels and the diff’s PeopleChange.

To help figure out which person was inserted, deleted or updated, add this function after the Diff struct:

// 1
func changedPerson(in other: PeopleModel) -> Person? {
// 2
  if people.count != other.people.count {
    let largerArray = other.people.count > people.count ? other.people : people
    let smallerArray = other.people == largerArray ? people : other.people
    return largerArray.first(where: { firstPerson -> Bool in
      !smallerArray.contains(where: { secondPerson -> Bool in
        firstPerson.tag == secondPerson.tag
      })
    })
// 3
  } else {
    return other.people.enumerated().compactMap({ index, person in
      if person != people[index] {
        return person
      }
      return nil
    }).first
  }
}

Here’s a breakdown of this code:

  1. changedPerson(in:) compares self’s current PeopleModel with the people model passed in as a parameter, then returns the inserted/deleted/updated Person if one exists.
  2. If one array is smaller/larger than the other, find the larger of the two arrays, then find the first element in the array not contained within the smaller array.
  3. If the arrays are the same size, then the change was an update as opposed to an insertion or deletion; in this case, you iterate through the enumerated new people array and find the person in the new array who doesn’t match the old one at the same index.

Below changedPerson(in:), add:

// 1
func diffed(with other: PeopleModel) -> Diff {
  var peopleChange: Diff.PeopleChange = .none
// 2
  if let changedPerson = changedPerson(in: other) {
    if other.people.count > people.count {
      peopleChange = .inserted(changedPerson)
    } else if other.people.count < people.count {
      peopleChange = .removed(changedPerson)
    } else {
      peopleChange = .updated(changedPerson)
    }
  }
//3
  return Diff(peopleChange: peopleChange, from: self, to: other)
}

Reviewing the above code:

  1. You initialize peopleChange to none to indicate no change. You will eventually return peopleChange from this method.
  2. If the new array is larger than the old array, changedPerson was inserted; if the new array is smaller, changedPerson was removed; if the new array is the same size as the old array, changedPerson was updated. In each case, use the person returned from changedPerson(in:) as changedPerson's parameter.
  3. You return the Diff with peopleChange, the original PeopleModel and the updated PeopleModel.

Now, at the bottom of PeopleListViewController.swift, add:

// MARK: - Model & State Types

extension PeopleListViewController {
// 1
  private func peopleModelDidChange(diff: PeopleModel.Diff) {
// 2
    switch diff.peopleChange {
    case .inserted(let person):
      if let index = diff.to.people.index(of: person) {
        tableView.insertRows(at: [IndexPath(item: index, section: 0)], with: .automatic)
      }
    case .removed(let person):
      if let index = diff.from.people.index(of: person) {
        tableView.deleteRows(at: [IndexPath(item: index, section: 0)], with: .automatic)
      }
    case .updated(let person):
      if let index = diff.to.people.index(of: person) {
        tableView.reloadRows(at: [IndexPath(item: index, section: 0)], with: .automatic)
      }
    default:
      return
    }
// 3
    peopleModel = diff.to
  }
}

Like personDidChange(diff:) in PersonDetailViewController, peopleModelDidChange(diff:) does the following:

  1. peopleModelDidChange(diff:) takes PeopleModel.Diff as a parameter, then it updates the UI based on the changes in the data model.
  2. If diff's peopleChange is an insertion, insert a table view row at the index of that insertion. If peopleChange is a deletion, delete the table view row at the index of that deletion. If peopleChange is an update, reload the updated row. Otherwise, if there was no change, exit the method without updating the model or UI.
  3. Set the class's peopleModel to the updated model.

Next, just as you added modifyPerson(_:) in PersonDetailViewController, add: modifyModel(_:) above peopleModelDidChange(diff:):

// 1
private func modifyModel(_ mutations: (inout PeopleModel) -> Void) {
// 2
  var peopleModel = self.peopleModel
// 3   
  let oldModel = peopleModel
// 4  
  mutations(&peopleModel)
// 5
  tableView.beginUpdates()
// 6
  let modelDiff = oldModel.diffed(with: peopleModel)
  peopleModelDidChange(diff: modelDiff)
// 7    
  tableView.endUpdates()
}

Here's what this code does step by step:

  1. modifyModel(_:) takes in a closure that accepts a pointer to a variable PeopleModel.
  2. var peopleModel holds a mutable copy of the class' peopleModel.
  3. oldModel holds a constant reference to the original model.
  4. Perform the mutations on the old model to produce the new model.
  5. Begin the series of tableView changes.
  6. peopleModelDidChange(diff:) executes the tableView insertion, deletion, or reload as determined by modelDiff peopleChange.
  7. End the table view updates.

Back in prepare(for:sender:), replace the placeholder comment with:

self.modifyModel { model in
  model.people[selectedIndex] = updatedPerson
}

to swap the person at the selected index with his or her updated version.

One final step. Replace class PeopleModel { with:

struct PeopleModel {

Build and run. Select a person's detail view, make some changes and then return to the people list. The changes now propagate:

Propagating changes

Next, you'll add the ability to delete and add people to your people table.

To process deletions, replace the placeholder comment in tableView(_:editActionsForRowAt:) with:

self.modifyModel { model in
  model.people.remove(at: indexPath.row)
}

to remove the person at the deleted index from both the data model and UI.

To handle insertions, add the following to addPersonTapped():

// 1
tagNumber += 1
// 2
let person = Person(name: "", face: (hairColor: .black, hairLength: .bald, eyeColor: .black, facialHair: [], glasses: false), likes: [], dislikes: [], tag: tagNumber)
// 3
modifyModel { model in
  model.people += [person]
}
// 4
tableView.selectRow(at: IndexPath(item: peopleModel.people.count - 1, section: 0), 
                    animated: true, scrollPosition: .bottom)
showPersonDetails(at: IndexPath(item: peopleModel.people.count - 1, section: 0))

Here, you do the following:

  1. The class variable tagNumber keeps track of the highest tag in the people model. As you add each new person, increment tagNumber by 1.
  2. A new person originally has no name, no likes nor dislikes, and a default face configuration. His or her tag value equals the current tagNumber.
  3. Add the new person to the end of the data model and update the UI.
  4. Select the row of the new item — i.e. the final row — and transition to that person's detail view so the user can set the details.

Build and run. Add people, update, etc. You should now be able to add and delete users from the people list and updates should propagate back and forth between controllers:

Add and delete users

You're not done yet — PeopleListViewController's undo and redo aren't functional. Time to code one last bit of counter-sabotage to protect your contact list!