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
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.
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 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:
- Using a
switch
statement, you execute the case that matches the enumeration value corresponding to the current section. - If the user selects a hair color, set
person
‘s hair color to thePerson.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. - When the user taps the glasses option,
person
‘sglasses
Boolean becomestrue
. -
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. - If the user selects a topic in the
likes
ordislikes
section, add it to thelikes
ordislikes
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. - 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:
- 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.
- When the user deselects a facial hair style, likes or dislikes, you remove that deselected item from its respective set.
- When the user deselects the glasses feature, you set the
glasses
Boolean tofalse
.
Build and run the app. You should now see the desired selection behaviors:
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…
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 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:
- What does the model look like after you pop the first undo operation off the stack?
[spoiler title=”Answer #1″]Bob, Sam[/spoiler]
- And the second?
[spoiler title=”Answer #2″]Bob, Kathy[/spoiler]
- 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?
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:
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.
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:
-
personDidChange(from:)
takes the previous version ofperson
as a parameter. - Reloading the collection updates the preview and cell selections.
-
undoManager
registers an undo operation which, when invoked, setsself.person
to the previousPerson
then callspersonDidChange(from:)
recursively.personDidChange(from:)
updates the UI and registers the undo’s undo, i.e., it registers a redo path for the undone operation. - 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 theDispatchQueue
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:
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:
- If Person is a class:
var person = Person() person.face.hairColor = .blonde var anotherPerson = person anotherPerson.face.hairColor = .black person.face.hairColor == ??
[spoiler title=”person.face.hairColor == ??”]
person.face.hairColor == .black
[/spoiler]
- If Person is a struct:
var person = Person() person.face.hairColor = .blonde var anotherPerson = person anotherPerson.face.hairColor = .black person.face.hairColor == ??
[spoiler title=”person.face.hairColor == ??”]
person.face.hairColor == .blonde
[/spoiler]
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:
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:
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.
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!
Undoing People List Changes
At the end of peopleModelDidChange(diff:)
, add:
// 1
undoManager.registerUndo(withTarget: self) { target in
// 2
target.modifyModel { model in
model = diff.from
}
}
// 3
DispatchQueue.main.async {
self.undoButton.isEnabled = self.undoManager.canUndo
self.redoButton.isEnabled = self.undoManager.canRedo
}
Here, you:
- Register an undo operation capable of undoing data model and UI changes.
- Modify the people model by replacing the current model with the previous one.
- Enable/disable
undoButton
andredoButton
appropriately.
In undoTapped()
, add:
undoManager.undo()
and in redoTapped()
, add:
undoManager.redo()
to trigger undo and redo respectively.
Last but not least, add shake to undo/redo to this controller. At the end of viewDidAppear(_:)
, add:
becomeFirstResponder()
At the end of viewWillDisappear(_:)
, add:
resignFirstResponder()
And beneath viewWillDisappear(_:)
, add:
override var canBecomeFirstResponder: Bool {
return true
}
so that the controller can undo/redo in response to the shake gesture.
That's it! Build and run. You can edit, add, undo, redo, shake, etc.
Where to Go From Here?
Download the final project using the Download Materials link at the bottom or top of this tutorial to see how it compares to your version.
To further explore the UndoManager
API, try grouping undo features, naming undo actions, making undo operations discardable and using the various built-in notifications.
To further explore value types, try adding properties to Person
and PeopleModel
to make your app more robust.
And if you want to make your PeopleKeeper really work for you, add data persistence between app launches. See our "Updated Course: Saving Data in iOS" for more information.
Have any questions, comments or suggestions? Join in the forum discussion below!
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more