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 Players in a given Scoreboard, but there's something else that needs doing. Scoreboard also has a list of Scoreboards 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:

  1. It requires you to implement several different operations—insert(_:atIndex:), delete(_:atIndex:), and update(_:atIndex:to:atIndex:).
  2. If you implement those operations, you get one new operation for free.
  3. The abstraction does not have any persistent state of its own.
  4. 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 or ViewUpdater or anything like that. Let's call it transform instead.

From this I conclude that there are two sensible implementations:

  • A transform(from:to:delegate:) function which takes a delegate that implements insert(_:atIndex:), delete(_:atIndex:), and update(_:atIndex:to:atIndex:) methods.
  • A protocol requiring insert(_:atIndex:), delete(_:atIndex:), and update(_:atIndex:to:atIndex:), with an extension that adds a transform(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.