- Original Link: Undo History in Swift
- Original Author: chriseidhof
- Translated from: 掘金翻译计划
- Translator: Zheaoli
- Proofreader: xcc3641, Jaeger
In the past few months, there have been many blog posts about the dynamic features people would like to add to Swift. In fact, Swift has become quite a dynamic language: it has generics, protocols, first-class functions, and a standard library with many functions that operate on functions, such as map and filter (which means we can use safer and more flexible functions instead of KVC with strings). For most people, especially those who want to introduce reflection, this means that they can observe and modify their programs at runtime.
In Swift, reflection is limited, but you can still generate and insert things dynamically at runtime. For example, here's how you can dynamically generate dictionaries for NSCoding or JSON in Swift.
Today, we'll take a look at how to implement undo functionality in Swift. One way to do this is by using NSUndoManager, which is based on Objective-C's reflection capabilities. By using structs, we can implement undo in our apps in different ways. Before we start the tutorial, make sure you understand how structs work in Swift (most importantly, that they are value types).
First of all, I want to make it clear that this article is not about saying that we don't need to work with the runtime or that we provide an alternative to NSUndoManager. It's just a different way of thinking.
We start by creating a struct called UndoHistory. Usually, there's a warning when creating an UndoHistory, telling us that it only works when A is a struct. To store all the states, we need to put them in an array. When we make a change, we just push it onto the array, and when we want to undo, we pop it off. Usually, we want an initial state, so we need to create an initializer:
struct UndoHistory<A> {
private let initialValue: A
private var history: [A] = []
init(initialValue: A) {
self.initialValue = initialValue
}
}
For example, if we want to provide undo functionality in a tableViewController using an array, we can create a struct like this:
var history = UndoHistory(initialValue: [1, 2, 3])
For different scenarios, we can create different structs:
struct Person {
var name: String
var age: Int
}
var personHistory = UndoHistory(initialValue: Person(name: "Chris", age: 31))
Of course, we want to get and set the current state (in other words, we want to manipulate our history in real time). We can get our state from the last item in the history array, and if the array is empty, we return our initial value. We can change our current state by appending it to the history array.
extension UndoHistory {
var currentItem: A {
get {
return history.last ?? initialValue
}
set {
history.append(newValue)
}
}
}
For example, if we want to modify the age of a person, we can easily do it by reassigning the property:
personHistory.currentItem.age += 1
personHistory.currentItem.age // Prints 32
Of course, the undo method is not complete yet. It's very simple to remove the last item from the array. Depending on your preference, you can throw an exception when the array is empty, but I didn't choose to do that.
extension UndoHistory {
mutating func undo() {
guard !history.isEmpty else { return }
history.removeLast()
}
}
It's easy to use:
personHistory.undo()
personHistory.currentItem.age // Prints 31 again
So far, our UndoHistory only works with a simple Person class. For example, if we want to provide undo functionality in a table view controller using an array, we can use properties to get elements from the array:
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 cool feature of structs is that we can freely use the observer pattern. For example, we can modify the value of data:
var data: UndoHistory<[item]> {
didSet {
tableView.reloadData()
}
}
Even if we modify a deeply nested value (e.g. data.currentItem[17].name = "John"), we can easily locate the change using didSet. Of course, we might want to do something more convenient, like reloadData. For example, we can use the Changeset library to calculate changes and animate insertions/deletions/moves based on different operations.
Obviously, this approach has its drawbacks. For example, it saves the entire history of states, not just the differences between states. This approach only uses structs to implement undo (or more accurately, some features of structs). This means that you don't need to read the runtime programming guide, you just need to have a good understanding of structs and generics.
- Providing a computed property items for data.currentItem to get and set the value is a good idea. It makes implementing methods like data-source and delegate easier.
- If you want to go further, there are some interesting ideas: adding redo functionality or editing functionality. You can implement them in the tableView. If you're naive enough to do that, you'll find that there are duplicate records in your undo history.