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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
UndoManager Tutorial: How to Implement With Swift Value Types
30 mins
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.
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:
-
struct Diffholds both the original (from) and new (to) person values. - If
fromandtoare different,hasChangesis true; otherwise it’s false. -
diffed(with:)returns aDiffcontaining self’sPerson(from) and the newperson(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:
-
modifyPerson(_:)takes in a closure that receives a pointer to aPersonobject. -
var personholds a mutable copy of the class’s currentPerson. -
oldPersonholds a constant reference to the originalpersonobject. - Execute the
(inout Person) -> Voidclosure you created atmodifyPerson(_:)‘s call site. The code in the closure will mutate thepersonvariable. - Then
personDidChange(diff:)updates the UI and registers an undo operation capable of reverting to thefromPersondata 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!

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:
-
Diffdefines aPeopleChangeenum, which indicates 1. Whether the change betweenfromandtois an insertion, removal, update or nothing and 2. WhichPersonwas inserted, deleted, or updated. -
Diffholds both the original and updatedPeopleModels and the diff’sPeopleChange.
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:
-
changedPerson(in:)compares self’s currentPeopleModelwith the people model passed in as a parameter, then returns the inserted/deleted/updatedPersonif one exists. - 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.
- 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:
- You initialize
peopleChangetononeto indicate no change. You will eventually returnpeopleChangefrom this method. - If the new array is larger than the old array,
changedPersonwas inserted; if the new array is smaller,changedPersonwas removed; if the new array is the same size as the old array,changedPersonwas updated. In each case, use the person returned fromchangedPerson(in:)aschangedPerson's parameter. - You return the
DiffwithpeopleChange, the originalPeopleModeland the updatedPeopleModel.
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:
-
peopleModelDidChange(diff:)takesPeopleModel.Diffas a parameter, then it updates the UI based on the changes in the data model. - If
diff'speopleChangeis an insertion, insert a table view row at the index of that insertion. IfpeopleChangeis a deletion, delete the table view row at the index of that deletion. IfpeopleChangeis an update, reload the updated row. Otherwise, if there was no change, exit the method without updating the model or UI. - Set the class's
peopleModelto 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:
-
modifyModel(_:)takes in a closure that accepts a pointer to a variablePeopleModel. -
var peopleModelholds a mutable copy of the class'peopleModel. -
oldModelholds a constant reference to the original model. - Perform the mutations on the old model to produce the new model.
- Begin the series of
tableViewchanges. -
peopleModelDidChange(diff:)executes thetableViewinsertion, deletion, or reload as determined bymodelDiffpeopleChange. - 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:
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:
- The class variable
tagNumberkeeps track of the highesttagin the people model. As you add each new person, incrementtagNumberby 1. - 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. - Add the new person to the end of the data model and update the UI.
- 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:
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!

