Integrate Combine Into an App

Aug 5 2021 · Swift 5.4, macOS 11.3, Xcode 12.5

Part 1: Define a View Model

04. Use Publishers in the ViewModel

Episode complete

Play next episode

Next
About this episode
Leave a rating/review
See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 03. Create Data Publishers Next episode: 05. Use @ObservedObject to Monitor State

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Transcript: 04. Use Publishers in the ViewModel

In the last episode, we made our own custom publishers that, in the end, started with a publisher (in this case a dataTaskPublisher from URLSession), and we performed some operations on that publisher, the last of which was erasing the type signature to AnyPublisher. This takes advantage of the fact that Operators are both Publishers and Subscribers.

With those new publishers defined, we can now use them in a Combine pipeline just like any other native Publisher that Combine provides. Let’s go to a demo.

Go to the the View Models folder and select the JokesViewModel.swift file, and define two services - one for jokes, one for translations - that adopt the JokesServiceDataPublisher and TranslationServiceDataPublisher protocols respectively.

private let jokesService: JokeServiceDataPublisher
private let translationService: TranslationServiceDataPublisher

Then in the initializer, set those properties, using the default initializers as default arguments,

public init(jokesService: JokeServiceDataPublisher = JokesService(),
            translationService: TranslationServiceDataPublisher = TranslationService()) {
  self.jokesService = jokesService
  self.translationService = translationService
}

Continuing on in the initializer, subscribe to the $joke publisher - which if you recall is one of our @Published properties from an earlier episode.

$joke
  .map { _ in false }
  .assign(to: \.fetching, on: self)
  .store(in: &subscriptions)

This takes the published value, maps the value to false, and assigns that to the fetching property of the JokesViewModel and stores it in the subscriptions array. This block of code will help us indicate when the app is fetching or translating a joke.

Now to build our Combine pipeline. In the fetchJoke method, add the following code:

public func fetchJoke() {
  // 1
  fetching = true
  // 2
  jokeSubscriptions = []

Set fetching to true (since we are now in the act of fetching a joke) and initialize the jokeSubscriptions array (this will cancel all previously added subscriptions).

Now grab the publisher from the jokesService, and perform some operations:

  jokesService.publisher()

retry the fetch once if an error occurs, and pass the data from the publisher into a decode operator, and attempt to decode it into a Joke struct

    .retry(1)
    .decode(type: Joke.self, decoder: Self.decoder)

If any errors are encountered, use replaceError to replace that error with the Joke.error value, which will display an error message.

    .replaceError(with: Joke.error)

Receive the result on the main queue, and send the joke through the publisher via handleEvents so it can be displayed while it is being translated

    .receive(on: DispatchQueue.main)
    
    .handleEvents(receiveOutput: { [unowned self] in
      self.joke = $0
    })

In case an error occurred, filter out that joke so it won’t get translated, and then use flatMap to trigger a fetch of the translation. This ends up turning the original publisher of untranslated jokes to a new publisher of translated jokes. Don’t forget to receive that joke on the main queue as well.

    .filter { $0 != Joke.error }
    .flatMap { [unowned self] joke in
      self.fetchTranslation(for: joke, to: "es")
    }
    .receive(on: DispatchQueue.main)

Assign the final value to self’s joke property (which, unlike the original joke, now has a translation) and store the subscription in the jokeSubscriptions array.

    .assign(to: \.joke, on: self)
    .store(in: &jokeSubscriptions)
}

Now with fetchJoke implemented, we can move onto fetchTranslation, which we used at the end of fetchJoke.

First, make sure that that joke’s existing language code is not the same as the one you are attempting to translate with; if it is, just republish the joke.

func fetchTranslation(for joke: Joke, to languageCode: String)
  -> AnyPublisher<Joke, Never> {

  guard joke.languageCode != languageCode else {
    return Just(joke).eraseToAnyPublisher()
  }

Otherwise, get the translationService’s publisher and perform the following operations

  return translationService.publisher(for: joke, to: languageCode)

retry 1 time on a failure, and attempt to decode the publisher’s data to a TranslationResponse with the decode operator

    .retry(1)
    .decode(type: TranslationResponse.self, decoder: Self.decoder)

Use compactMap to get the first translation from the array of translations

    .compactMap { $0.translations.first }

Then use map to convert the translation into a new Joke struct, using the passed in joke’s attributes, but using the translatedValue from compactMap

    .map {
      Joke(id: joke.id,
           value: joke.value,
           categories: joke.categories,
           languageCode: languageCode,
           translationLanguageCode: languageCode,
           translatedValue: $0)
    }

Replace any errors with the Joke.error value again, and erase to AnyPublisher via eraseToAnyPublisher

    // 5
    .replaceError(with: Joke.error)
    .eraseToAnyPublisher()
}

Now with the functions in place that fetch the jokes and translations, there is one more thing we need to in order to have the views and view model communicate with each other, and we’ll look at that next.