Undo History in Swift
Use Value Semantics for Great Good
Over the last weeks, there have been a number of blog posts that want to add dynamic behavior to Swift. Swift is already a very dynamic language: it has generics, protocols, first-class functions, and the standard library is filled with functions like map
and filter
, which dynamically get their operation (not using a string like with KVC, but using a function, which is safer and more flexible). Most of the people that say they want dynamic behavior mean that they want reflection specifically: they want to analyze and modify the program at runtime.
In Swift, there is only a very limited reflection mechanism, although you can already inspect and generate code at runtime. For example, here’s how to generate a dictionary ready for NSCoding
or JSON serialization: Swift mirrors and JSON.
Today, we’ll have a look at implementing undo functionality in Swift. One of the examples people keep bringing up to make the case for reflection (the way Objective-C) supports it is NSUndoManager
. With struct semantics, we can add undo support to our apps in a different way. Before we get started, make sure that you understand how structs work in Swift (most importantly, how they are all unique copies). Clearly, this post will not remove the need for runtime programming in Swift, nor is it a replacement for NSUndoManager
. It’s just a simple example of how to think different.
We’ll build a struct called UndoHistory
. It’s generic, with the caveat that it only works when A
is a struct. To keep a history of all the states, we can store every value in an array. Whenever we want to change something, we just push onto the array, and whenever we want to undo, we pop from the array. We always want to start with an initial state, so we create an initializer for that:
struct UndoHistory<A> {
private let initialValue: A
private var history: [A] = []
init(initialValue: A) {
self.initialValue = initialValue
}
}
For example, if we want to add undo support to a table view controller that’s backed by an array, we can create a value of this struct:
var history = UndoHistory(initialValue: [1, 2, 3])
To support undo for a different struct, we just start with a different initial value:
struct Person {
var name: String
var age: Int
}
var personHistory = UndoHistory(initialValue: Person(name: "Chris", age: 31))
Of course, we want to have a way of getting the current state, and setting the current state (in other words: adding an item to our history). To get the current state, we simply return the last item in our history
array, and if the array is empty, we return the initial value. To set the current state, we simply append to our history
array.
extension UndoHistory {
var currentItem: A {
get {
return history.last ?? initialValue
}
set {
history.append(newValue)
}
}
}
For example, if we want to change the person’s age, we can easily do that through our new computed property:
personHistory.currentItem.age += 1
personHistory.currentItem.age // Prints 32
Of course, the code isn’t complete without an undo
method. This is as simple as removing the last item from the array. Depending on your preference, you could also make it throw
when the undo stack is empty, but I’ve chosen not to do that.
extension UndoHistory {
mutating func undo() {
guard !history.isEmpty else { return }
history.removeLast()
}
}
Using it is easy:
personHistory.undo()
personHistory.currentItem.age // Prints 31 again
Of course, our UndoHistory
works on more than just simple Person
structs. For example, if we want to create a table view controller that’s backed by an Array
, we can use the currentItem
property to get the array out [^1]:
final class MyTableViewController<Item>: UITableViewController {
var data: UndoHistory<[Item]>
init(value: [Item]) {
data = UndoHistory(initialValue: value)
super.init(style: .Plain)
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.currentItem.count
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Identifier", forIndexPath: indexPath)
let item = data.currentItem[indexPath.row]
// configure `cell` with `item`
return cell
}
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
guard editingStyle == .Delete else { return }
data.currentItem.removeAtIndex(indexPath.row)
}
}
Another thing that is really cool with struct semantics: we get observation for free. For example, we could change the definition of data
:
var data: UndoHistory<[Item]> {
didSet {
tableView.reloadData()
}
}
Even if we change something deep inside the array (e.g. data.currentItem[17].name = "John"
) our didSet
will get triggered. Of course, we probably want to do something a little bit smarter than reloadData
. For example, we could use the Changeset library to compute a diff and have insert/delete/move animations
1
.
Obviously, this approach has its drawbacks too. For example, it keeps a full history of the state, rather than a diff. It only works with structs (to be precise: only with structs that have value semantics). That said, you do not have to read the runtime programming guide, you only need to have a good grasp of structs and generics to come up with this solution 2 .
-
If you want to take this further, there are a couple of fun exercises: try adding redo support, or labeled actions. You can implement reordering in the table view, and you will see that if you do it naively, you’ll end up with two entries in your undo history. ↩
-
In Advanced Swift we go into way much more detail on these and many other topics. ↩