Valuable Models, Part IV: Sharing is Caring
In Part I of this series, I discussed how my Apple TV app, Scoreboard, uses a model comprised entirely of value types, allowing me to centralize all view updates in a single property observer. In Part II, I discussed using an array diffing algorithm to figure out how to update the view. In Part III, I showed how you can give value types identities so you can tell an addition from an edit.
We've made tremendous progress on supporting the list of Player
s in a given Scoreboard
, but there's something else that needs doing. Scoreboard also has a list of Scoreboard
s that the user can switch between. Worse, the view controller that implements this list uses a table view, where the view controller for an individual scoreboard uses a collection view, so we can't use a common superclass. How can we avoid duplicating our diffing logic?
Refactor, Then Ruminate
We don't yet know how we're going to package this code so it can be reused, but we might be able to get a hint of how to do it by modifying it in place. Let's start by moving the logic out of the property observer into its own method:
class ScoreboardViewController: UICollectionViewController {
var scoreboard: Scoreboard {
didSet {
collectionView?.performBatchUpdates({
self.updateView(from: oldValue.players, to: self.scoreboard.players)
}, completion: nil)
}
}
func updateView(from from: [Player], to: [Player]) {
guard let collectionView = collectionView else {
return
}
var oldIndex = 0
var newIndex = 0
for change in to.diff(from: from, match: ===) {
switch change {
case .Append (_):
collectionView.insertItemsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
newIndex++
case .Remove (_):
collectionView.deleteItemsAtIndexPaths([ NSIndexPath(forItem: oldIndex, inSection: 0) ])
oldIndex++
case let .Update (old, new):
if old != new {
collectionView.reloadItemsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
}
newIndex++
oldIndex++
}
}
}
Okay, now let's abstract out the exact operations used to do the updating:
for change in to.diff(from: from, match: ===) {
switch change {
case let .Append (new):
insert(new, atIndex: newIndex)
newIndex++
case let .Remove (old):
delete(old, atIndex: oldIndex)
oldIndex++
case let .Update (old, new):
if old != new {
update(old, atIndex: oldIndex, to: new, atIndex: newIndex)
}
newIndex++
oldIndex++
}
}
}
func insert(newElement: Player, atIndex newIndex: Int) {
collectionView?.insertItemsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
}
func delete(oldElement: Player, atIndex oldIndex: Int) {
collectionView?.deleteItemsAtIndexPaths([ NSIndexPath(forItem: oldIndex, inSection: 0) ])
}
func update(oldElement: Player, atIndex oldIndex: Int, to newElement: Player, atIndex newIndex: Int) {
collectionView?.reloadItemsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
}
Having done that, we begin to see the outline of our new abstraction:
- It requires you to implement several different operations—
insert(_:atIndex:)
,delete(_:atIndex:)
, andupdate(_:atIndex:to:atIndex:)
. - If you implement those operations, you get one new operation for free.
- The abstraction does not have any persistent state of its own.
- This operation doesn't intrinsically have anything to do with views; it's much more generic than that.
This leads us to several observations:
- The lack of a need for persistent state (#3) and the fact that you only get new operation (#2) means that there's no particular reason to put this operation into its own instantiable type. In other words, there's no reason to make a
ViewUpdater
class. - The need to provide multiple operations (#1) means we'll probably have to create a new protocol containing those operations. The alternative—passing three closures—would be fairly clunky.
- The fact that this is a fairly generic operation (#4) means we probably shouldn't call it
updateView
orViewUpdater
or anything like that. Let's call ittransform
instead.
From this I conclude that there are two sensible implementations:
- A
transform(from:to:delegate:)
function which takes a delegate that implementsinsert(_:atIndex:)
,delete(_:atIndex:)
, andupdate(_:atIndex:to:atIndex:)
methods. - A protocol requiring
insert(_:atIndex:)
,delete(_:atIndex:)
, andupdate(_:atIndex:to:atIndex:)
, with an extension that adds atransform(from:to:)
method.
Which one is better? It's a matter of taste. I chose the protocol extension.
protocol Transformable {
func insert(newElement: Player, atIndex newIndex: Int)
func delete(oldElement: Player, atIndex oldIndex: Int)
func update(oldElement: Player, atIndex oldIndex: Int, to newElement: Player, atIndex newIndex: Int)
}
extension Transformable {
func transform(from from: [Player], to: [Player]) {
var oldIndex = 0
var newIndex = 0
for change in to.diff(from: from, match: ===) {
switch change {
case let .Append (new):
insert(new, atIndex: newIndex)
newIndex++
case let .Remove (old):
delete(old, atIndex: oldIndex)
oldIndex++
case let .Update (old, new):
if old != new {
update(old, atIndex: oldIndex, to: new, atIndex: newIndex)
}
newIndex++
oldIndex++
}
}
}
}
Oh, one more thing: let's make it not be a Player
, but any type that's both Identifiable
and Equatable
, the two operations that transform(from:to:)
demands:
protocol Transformable {
typealias TransformableElement: Identifiable, Equatable
func insert(newElement: TransformableElement, atIndex newIndex: Int)
func delete(oldElement: TransformableElement, atIndex oldIndex: Int)
func update(oldElement: TransformableElement, atIndex oldIndex: Int, to newElement: TransformableElement, atIndex newIndex: Int)
}
extension Transformable {
func transform(from from: [TransformableElement], to: [TransformableElement]) {
Now back in our ScoreboardViewController
, all we have to do is make it Transformable
, delete updateView(from:to:)
, and change the property observer to call transform(from:to:)
instead:
class ScoreboardViewController: UICollectionViewController, Transformable {
var scoreboard: Scoreboard {
didSet {
collectionView?.performBatchUpdates({
self.transform(from: oldValue.players, to: self.scoreboard.players)
}, completion: nil)
}
}
func insert(newElement: Player, atIndex newIndex: Int) {
collectionView?.insertItemsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
}
func delete(oldElement: Player, atIndex oldIndex: Int) {
collectionView?.deleteItemsAtIndexPaths([ NSIndexPath(forItem: oldIndex, inSection: 0) ])
}
func update(oldElement: Player, atIndex oldIndex: Int, to newElement: Player, atIndex newIndex: Int) {
collectionView?.reloadItemsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
}
And now we can go to ScoreboardListViewController
and say:
class ScoreboardListViewController: UITableViewController, Transformable {
var scoreboards: [Scoreboard] {
didSet {
tableView.beginUpdates()
transform(from: oldValue, to: scoreboards)
tableView.endUpdates()
}
}
func insert(newElement: Player, atIndex newIndex: Int) {
tableView.insertRowsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
}
func delete(oldElement: Player, atIndex oldIndex: Int) {
tableView.deleteRowsAtIndexPaths([ NSIndexPath(forItem: oldIndex, inSection: 0) ])
}
func update(oldElement: Player, atIndex oldIndex: Int, to newElement: Player, atIndex newIndex: Int) {
tableView.reloadRowsAtIndexPaths([ NSIndexPath(forItem: newIndex, inSection: 0) ])
}
Two unrelated controllers, two different view classes, but one core piece of code driving both of them. That's clean programming.
There's one little issue left with this setup, though: ScoreboardViewController
and ScoreboardListViewController
both have separate copies of the selected Scoreboard
, and keeping them in sync is a bit tricky. I'll examine this issue in Part V.