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 String
s, 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 Diff
holds both the original (from
) and new (to
) person values. - If
from
andto
are different,hasChanges
is true; otherwise it’s false. -
diffed(with:)
returns aDiff
containing 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 aPerson
object. -
var person
holds a mutable copy of the class’s currentPerson
. -
oldPerson
holds a constant reference to the originalperson
object. - Execute the
(inout Person) -> Void
closure you created atmodifyPerson(_:)
‘s call site. The code in the closure will mutate theperson
variable. - Then
personDidChange(diff:)
updates the UI and registers an undo operation capable of reverting to thefromPerson
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!
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:
-
Diff
defines aPeopleChange
enum, which indicates 1. Whether the change betweenfrom
andto
is an insertion, removal, update or nothing and 2. WhichPerson
was inserted, deleted, or updated. -
Diff
holds both the original and updatedPeopleModel
s 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 currentPeopleModel
with the people model passed in as a parameter, then returns the inserted/deleted/updatedPerson
if 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
peopleChange
tonone
to indicate no change. You will eventually returnpeopleChange
from this method. - 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 fromchangedPerson(in:)
aschangedPerson
's parameter. - You return the
Diff
withpeopleChange
, the originalPeopleModel
and 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.Diff
as a parameter, then it updates the UI based on the changes in the data model. - If
diff
'speopleChange
is an insertion, insert a table view row at the index of that insertion. IfpeopleChange
is a deletion, delete the table view row at the index of that deletion. IfpeopleChange
is 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
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:
-
modifyModel(_:)
takes in a closure that accepts a pointer to a variablePeopleModel
. -
var peopleModel
holds a mutable copy of the class'peopleModel
. -
oldModel
holds a constant reference to the original model. - Perform the mutations on the old model to produce the new model.
- Begin the series of
tableView
changes. -
peopleModelDidChange(diff:)
executes thetableView
insertion, deletion, or reload as determined bymodelDiff
peopleChange
. - 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
tagNumber
keeps track of the highesttag
in the people model. As you add each new person, incrementtagNumber
by 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!