Combine + Diffable Data Source

Abe Mangona

Combine and diffable data sources are two powerful yet often-overlooked features that came out of WWDC 2019. Combine provides a declarative and reactive approach to processing events, while diffable data sources make it easier to display those events. Subscribing and reacting to events simplifies handling state changes from data sources by reducing the code required and making it easy to reason about. Using both of these frameworks in your app gives you a certain level of superpowers.

In this article, we’ll discover our powers by seeing how we could use both through a game of blackjack. To make things more realistic, we are going to be making a call to the Deck of Cards API. The app has a button which deals a card from a deck. This triggers a download of a random card through the Deck of Cards API.

Animated gif demoing our blackjack app

Download the reference project on GitHub.

Diffable Data Source

Datasources provide data to collectionviews and tableviews, which are responsible for rendering each item. When data is changed the UI is updated through reloadData() or preformBatchUpdates(_:completion). Both of these methods are a common source for bugs. reloadData() updates the entire UI, providing bad user experience. preformBatchUpdates(_:completion) is useful but easily gets complex. Diffable data sources now take care of a lot of these issues through automatic diffing with apply(_:animatingDifferences:). The data source creates a snapshot of the current UI state which is compared to the newly presented snapshot. It then diffs and updates the UI with a new snapshot state.

Learn more about diffable data sources at WWDC19 Advances in UI Data Sources.

In our app, we will be setting up a diffable data source that works with a Card object. Diffable data sources take generic types that must conform to Hashable. Automatic diffing needs unique identifiers. If you’re not familiar with Hashable, this article explains it well.

Card.swift

struct Card: Codable, Hashable {
    let identifier = UUID()
    let image: String
    let value: String
    let suit: String
    var points: Int {
        Int(value) ?? 10
    }

    private enum CodingKeys : String, CodingKey {
        case image, value, suit
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(identifier)
    }

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

Typealiases are not required but can make code related to data sources and snapshots more concise and readable. The Section parameter is an enum that has default Hashable conformance. A snapshot of the data source is also required to be given to the data source for diffing to occur through the DataSource.apply( _ snapshot:) method.

ViewController.swift

typealias DataSource = UICollectionViewDiffableDataSource<Section, Card>
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Card>

Setting up the data source requires only two custom methods.

ViewController.swift

extension ViewController {

    func setUpDataSource() {
        dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { (collectionView, indexPath, card) -> UICollectionViewCell? in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CardCell", for: indexPath) as! CardCell
            cell.imageView.sd_setImage(with: URL(string: card.image))
            return cell
        })
    }

    func updateHand(cards: [Card]) {
        var snapshot = Snapshot()
        snapshot.appendSections([.main])
        snapshot.appendItems(cards)
        dataSource.apply(snapshot, animatingDifferences: true)
    }

}

setUpDataSource() is a setup method that configures individual cells. If this looks familiar, that’s because it’s something similar to what you would normally do in the cellForItemAt and cellForIRowAt methods. A difference is that you get the actual object in a closure instead of having to use something like ObjectsArray[indexPath.row] to get a specific object. This makes things less error-prone.

updateHand(cards:) is an update method that creates a snapshot instance and appends sections and items. The snapshot is given to the data source to be applied and that’s where the diffing magic happens. If desired, the data source can also determine the best animation to apply.

That’s it! That’s all that needs to be done to implement a CollectionView diffable data source. TableView is almost identical with just different naming.

Combine

Combine provides a declarative API to process values over time asynchronously. The Combine framework has 3 main parts: publishers, subscribers, and operators.

  • A Publisher declares a type that can deliver a sequence of values over time.
  • A Subscriber acts on the received elements from the publisher. The publisher only begins to emit elements when it has a least one subscriber.
  • An Operator manipulates values emitted from upstream publishers.

Learn more on Combine at WWDC19 Introducing Combine.

Now that we have our UI and model set up, we need to add in the data and make API calls. Traditionally we would use regular URL session code, but we want a more declarative approach. Fortunately, Combine provides a few APIs to preform network calls.

  • URLSession
  • JSON encoding and decoding of models that conform to Codable

Though we could write a Blackjack app that doesn’t rely on an API, to illustrate these concepts, we will rely on an API for dealing cards in our app. We will make requests to the API to get the deck ID of a newly shuffled deck and then deals with each new card from the deck.

DealerService.swift

class DealerService {

    static func dealFromNewDeck() -> AnyPublisher<Deal, Error>{
        let url = URL(string: "https://deckofcardsapi.com/api/deck/new/shuffle/?deck_count=1")!
        return URLSession.shared
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Deal.self, decoder: JSONDecoder())
            .flatMap({
                return self.dealFromDeck(with: $0.deck_id)
            }).eraseToAnyPublisher()
    }

    static func dealFromDeck(with id: String) -> AnyPublisher<Deal, Error> {
        let url = URL(string: "https://deckofcardsapi.com/api/deck/\(id)/draw/?count=1")!
        return URLSession.shared.dataTaskPublisher(for: url)
            .mapError { $0 as Error }
            .map(\.data)
            .decode(type: Deal.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

Both of these methods return an AnyPublisher, which is used to wrap a publisher whose details are private to subscribers or other publishers.

dealFromNewDeck() is a dataTaskPublisher that maps and decodes data into an intermediate Deal object. That object’s deck_id property is then used in a flatMap to return the dealFromDeck(with:) which returns a publisher. The returned publisher exposes an instance of AnyPublisher to any downstream subscriber instead of the publisher’s actual type.

dealFromDeck is called when given adeck_id and directly returns a publisher with a Deal intermediate value.

Combine + Diffable Data Source

Now it’s time to bring both together and witness the power. Like most things these days you’re gonna need a subscription:

ViewController.swift

var subscription: AnyCancellable!

This subscription will subscribe to our publisher and react to its downstream emissions.

ViewController.swift

func deal() {

    let publisher = (hand.cards.isEmpty) ? DealerService.dealFromNewDeck() : DealerService.dealFromDeck(with: hand.deck_id!)

    subscription = publisher
        .sink(receiveCompletion: { completion in
            if case .failure(let err) = completion {
                print("Retrieving data failed with error \(err)")
            }
        }, receiveValue: { object in
            self.hand.deck_id = object.deck_id
            self.hand.cards.append(object.cards!.first!)
            self.updateHand(cards: self.hand.cards)
            self.updateTitles(cardsRemaining: object.remaining)
        })
}

The publisher is initialized base on whether any cards are in a hand. If there are cards, a publisher is made from a deck with a given deck_id. If there are no cards in a hand, a new publisher is made from a newly shuffled deck with a new deck_id.

A subscription is made to the publisher and the sink subscriber is used to respond to incoming events. Inside the received value closure, our hand is assigned the current deck_id then is appended a card to its cards property. With the new list of cards, the list is passed on to the updateHand(with:) method to update the current snapshot and applied to the data source. That’s it! Your data source is now responsive to your publisher’s emissions.

updateTitles(cardsRemaining:) is a method that runs on the main thread to update the UI.

Now, all that’s left is calling deal() from an IBAction for the powers to come to life. Make sure to empty the hand once you hit 21 to receive a new deck.

 @IBAction func deal(_ sender: Any) {
        if hand.score >= 21 {
            hand.cards.removeAll()
        }
        deal()
}

The reactive nature of combine and ease of the diffable data source makes for a simple and powerful combination. You should consider adopting these two frameworks in your current UIKit projects so you could potentially solve more complex problems.