Animating table row changes using changesets with MVVM

This is the seventh post in my series on [MVVM with ReactiveCocoa 3/4 in Swift][mvvm-reactivecocoa3-swift].

If you’ve ever written an iOS app that contained some kind of list, you are most certainly familiar with UITableView. This UIKit staple refreshes itself by pulling new data from a UITableViewDataSource when told so by its owner, which is usually a view controller.

In MVVM and similar architectures, the view controller is no longer in charge of retrieving and holding the data that populates our table view. This activity now occurs “deeper” in the hierarchy of architectural layers, for example in a view model. (I agree, by the way, that “view models” are [very poorly named][soroush-khanlou-mvvm-critique], but do concur with [Ash Furrow’s view][ash-furrow-mvvm-response] on the matter.) But how does the view get updated? Typically, we would expose an observable signal on the view model that sends new values, such as formatted strings, for the view to display. But as mentioned above, UITableView doesn’t want us to push content – it wants us to tell it when to pull.

The brute-force approach

When starting to write the [SwiftGoal][swiftgoal] showcase app, I opted for a very simple refresh mechanism: Every time there was new data, the view model would emit a Bool of value true (today I realize that I should have used Void instead, as the actual value was discarded anyway) on a signal called updatedContentSignal. The table view controller could then subscribe to the signal like so:

// MatchesViewController.swift

viewModel.updatedContentSignal
    |> observeOn(UIScheduler())
    |> observe(next: { [weak self] _ in
        self?.tableView.reloadData()
    })

This would cause the table view to ask its data source for new data and completely refresh itself, without visually indicating what rows actually changed.

Animating insertions and deletions

After the initial fetch, an application’s data rarely changes in its entirety. In a logbook-style app like SwiftGoal, a few entries might have been added since the last refresh, and some others deleted, but the majority remains unchanged. Wouldn’t it be nice if we could visualize the changes? And indeed, UITableView comes with some [handy methods][uitableview-batch-changes] to insert and delete cells in batches that must be surrounded with beginUpdates and endUpdates calls. (Whether such an API design is appropriate in the year 2015 is a topic for another post.) Doing so will also give us some neat animations for free. :muscle:

To build this, we have to adjust our view model’s API a little. The view must not only know when to refresh, but also what rows are affected. However, the actual content will still be loaded in a pull-driven fashion from the table view’s data source. So our exposed signal, now renamed to reflect the new behavior, looks like this:

// MatchesViewModel.swift

let contentChangesSignal: Signal<Changeset, NoError>

The Changeset is a [little data structure I wrote][swiftgoal-changeset-v1.0] that encapsulates two arrays of NSIndexPath instances, one for deletions and one for insertions. Its initializer takes two arrays, oldItems and newItems, and populates its index path arrays accordingly.

How then do we generate these changesets for the signal? After all, each changeset carries information that depends on our data’s current and previous state. But here’s the beauty of functional reactive programming: It turns out that ReactiveCocoa comes with an [operator][reactivecocoa-combineprevious] that effectively makes this a one-liner! Can you spot it?

// MatchesViewModel.swift
// (code adjusted for readability)

refreshSignal
    .flatMap(.Latest) { _ in
        return store.fetchMatches()
            .flatMapError { error in
                return SignalProducer(value: [])
            }
    }
    .combinePrevious([]) // Preserve history to calculate changeset
    .startWithNext({ [weak self] (oldMatches, newMatches) in
        self?.matches = newMatches
        if let observer = self?.contentChangesObserver {
            let changeset = Changeset(
                oldItems: oldMatches,
                newItems: newMatches
            )
            observer.sendNext(changeset)
        }
    })

Whenever a new array of matches arrives from the store and gets flattened onto the main signal chain, combinePrevious will combine it with the one sent right before it, returning a tuple of type ([Match], [Match]) that we can pass to the changeset initializer. (The empty array [] provided by us as a parameter is used for the initial value.) Finally, the resulting changeset is sent to an observer that feeds our contentChangesSignal. We also update the self.matches property with our new data to ensure that the table view can properly refresh its content:

// MatchesViewController.swift

viewModel.contentChangesSignal
    .observeOn(UIScheduler())
    .observeNext({ [weak self] changeset in
        guard let tableView = self?.tableView else { return }

        tableView.beginUpdates()
        tableView.deleteRowsAtIndexPaths(changeset.deletions, withRowAnimation: .Automatic)
        tableView.insertRowsAtIndexPaths(changeset.insertions, withRowAnimation: .Automatic)
        tableView.endUpdates()
    })

A word on equality

Equality is a powerful concept that has inspired [fantastic posts][nshipster-equality] also in the iOS community. Generating changesets means that we need to define what it means that an item is present in one array, but not in another. All of SwiftGoal’s models are structs, which, [unlike classes, have value semantics][swift-structs-classes]. We can make them conform to the Equatable protocol, overriding ==, to be able to use basic array methods like contains for calculating the changesets.1

Let’s take the example of a match. It has a unique identifier (String), two [Player] arrays with home and away players, respectively, and two Int properties for home and away goals. As the view currently only handles deletions and insertions, but not modifications, two matches that have the same identifier but e.g. different home goal counts must be treated as not equal, or else our table view would not refresh the data correctly.

// Match.swift

extension Match: Equatable {}

public func ==(lhs: Match, rhs: Match) -> Bool {
    return lhs.identifier == rhs.identifier
        && lhs.homePlayers == rhs.homePlayers
        && lhs.awayPlayers == rhs.awayPlayers
        && lhs.homeGoals == rhs.homeGoals
        && lhs.awayGoals == rhs.awayGoals
}

So this works, but there is a UX flaw: In the aforementioned scenario where some property within the same real-world match changes, the cell in question would animate out as if deleted, only to be replaced by a newly inserted cell with the updated content. This is acceptable, but not great, so let’s improve the code further!

Animating changes in content

The key point is that our model instances, being immutable, represent real-life entities at different moments in time. For example, two structs can both represent a Friday afternoon match between Anna and Bob, but with different scores – maybe the score was corrected after creation. We therefore need a way to distinguish between real-life entities, but also between content that might have changed over time. To choose between the two, it helps to think about the purpose of comparison. When should a list item be removed or inserted? When should it be updated in place?

In the case of SwiftGoal’s match list, the answer is clear: Adding and deleting match records on the server should result in their insertion or deletion from the table view. Any change to the content of the match, such as home or away goals, on the other hand, should cause the corresponding cell to be reloaded in place, e.g. with a cross-fade animation.

We can override the == operator to check whether two Match instances represent the same, “timeless” real-world entity.2 The way to tell is to look at their identifier. This means that our earlier comparison function can be simplified to

// Match.swift

extension Match: Equatable {}

public func ==(lhs: Match, rhs: Match) -> Bool {
    return lhs.identifier == rhs.identifier
}

For content equality, we need a different function that looks at other fields than just the identifier. Let’s call it contentMatches and define it like so:

// Match.swift

extension Match: Equatable {}

static func contentMatches(lhs: Match, _ rhs: Match) -> Bool {
    return lhs.identifier == rhs.identifier
        && Player.contentMatches(lhs.homePlayers, rhs.homePlayers)
        && Player.contentMatches(lhs.awayPlayers, rhs.awayPlayers)
        && lhs.homeGoals == rhs.homeGoals
        && lhs.awayGoals == rhs.awayGoals
}

Note how any nested models (in this case, the two Player arrays) must be tested for matching content, too. Suppose that a player’s name has changed and then gets shown as part of an otherwise unchanged match in the table view. Merely checking player identity would result in a visible bug, as the cell would not refresh its contents. Inside the Player model, comparing content happens the same way as for matches.3

Equipped with these comparison tools, we can now initialize changesets with two arrays (the old and new items) and our content matching function:

// Changeset.swift

typealias ContentMatches = (T, T) -> Bool

init(oldItems: [T], newItems: [T], contentMatches: ContentMatches) {

    deletions = oldItems.difference(newItems).map { item in
        return Changeset.indexPathForIndex(oldItems.indexOf(item)!)
    }

    modifications = oldItems.intersection(newItems)
        .filter({ item in
            let newItem = newItems[newItems.indexOf(item)!]
            return !contentMatches(item, newItem)
        })
        .map({ item in
            return Changeset.indexPathForIndex(oldItems.indexOf(item)!)
        })

    insertions = newItems.difference(oldItems).map { item in
        return NSIndexPath(forRow: newItems.indexOf(item)!, inSection: 0)
    }
}

For deletions and insertions, we grab the indices of all items that differ. For modifications, we filter the items common to both arrays based on whether their content matches, and store their indices. (The difference and intersection array extensions are defined in a separate file and do exactly what it says on the tin :relaxed:)

By the way, this kind of code should rank very high on your “make sure to test this” list, as hard-to-find bugs are likely to occur. Take a look at SwiftGoal’s Changeset [test file][swiftgoal-changeset-tests] for an example.

Finally, we must add one single line of code to our view controller to make sure it also reloads the index paths that are marked as modified in the changeset:

// MatchesViewController.swift

viewModel.contentChangesSignal
    .observeOn(UIScheduler())
    .observeNext({ [weak self] changeset in
        guard let tableView = self?.tableView else { return }

        tableView.beginUpdates()
        tableView.deleteRowsAtIndexPaths(changeset.deletions, withRowAnimation: .Automatic)
        tableView.reloadRowsAtIndexPaths(changeset.modifications, withRowAnimation: .Automatic)
        tableView.insertRowsAtIndexPaths(changeset.insertions, withRowAnimation: .Automatic)
        tableView.endUpdates()
    })

Summary

While animating table row changes isn’t strictly an MVVM topic, I consider it an excellent selling point, because the architecture introduces a nice separation of concerns: The view model converts subsequent item arrays into changesets using rules that live in the model layer, and all that remains for the view is to manage the visual row changes based on NSIndexPath arrays that it receives from its view model. And the best part is, you can apply this regardless of your exact setup. Prefer [RxSwift][rxswift] over ReactiveCocoa? Hate view models and want to write [presenters and interactors][viper] instead? With a few adjustments, the above will still work for you.



  1. Although structs are value types and don’t offer pointer equality the way classes do, overriding == and comparing an identifier property essentially introduces a similar concept of identity. The difference is that such identifiers go even further than the memory pointers of class instances, as they extend past the application to the server database. But instead of using structs, one could in principle one choose classes and create a factory that guarantees the uniqueness of instances, e.g. when fetching data from the backend. As someone eager to dig deeper into Swift, I simply made the choice to use structs instead.

  2. Overriding == for structs with identifier comparison comes with a few caveats. For example, storing such instances in a mutable collection like a Set or Array will require you to re-add them to update their content, because the collection uses == to check whether it already contains the instance. Many operators in FRP frameworks do the same and might thus filter out “too much” when applied to a signal that carries content changes. Fortunately, the ReactiveCocoa folks kept this in mind when designing the skipRepeats [operator][rac-skiprepeats], allowing us to pass a matcher function.

  3. For the Ranking [model][swiftgoal-ranking], however, it’s a bit more complicated. A ranking doesn’t have a real-life counterpart in the same way matches or players do, because it is generated on the fly to combine a player and their current rating. As we don’t want a deletion and insertion in the rating table when an existing player’s rating changes, we decide that ranking equality (==) simply means that it has the same real-world player, while any changes in player content or rating cause the content matcher to return false.