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.
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.