Chapters

Hide chapters

Combine: Asynchronous Programming with Swift

First Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

20. In Practice: Building a Complete App
Written by Scott Gardner

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

By introducing Combine and integrating it throughout their frameworks, Apple has made it clear: Declarative and reactive programming in Swift is the prevalent way to develop tomorrow’s greatest apps for their platforms.

In the last three sections, you acquired some awesome Combine skills. In this final chapter, you’ll utilize what you’ve learned to finish developing an app that lets you fetch Chuck Norris jokes and translate them into Spanish — or another language of your choice. And the learning’s not done yet! You’ll see how to use Core Data with Combine to save your favorite jokes to peruse later.

Getting started

Open the starter project found in projects/starter. Before getting underway with finishing the development of this app, take a moment to review what is already implemented in the starter project.

Note: This project uses SwiftUI. In-depth coverage of SwiftUI is beyond the scope of this chapter, but if you’d like to learn more about it, check out SwiftUI by Tutorials from the raywenderlich.com library.

Select the ChuckNorrisJokes project at the top of the Project navigator:

The project has three targets:

  1. ChuckNorrisJokes: The main target, which contains all your UI code.
  2. ChuckNorrisJokesModel: You’ll define your models and services here. Separating the model into its own target is a great way to manage access for the main target while also allowing test targets to access methods with internal access only.
  3. ChuckNorrisJokesTests: You’ll write several unit tests in this target.

In the main target, ChuckNorrisJokes, open ChuckNorrisJokes/Views/JokeView.swift. This is the main view of the app. There are two previews available for this view: an iPhone Xs Max in light mode and an iPhone SE in dark mode.

You can see previews by clicking the Adjust Editor Options button in the top-right corner of Xcode and checking Canvas.

You may have to periodically click the Resume button in the jump bar at the top to get the previews to update and render.

Click the Live Preview button next to each preview to get an interactive running version that’s similar to running the app in the simulator.

Currently, you can swipe on the joke card view, and not much else — not for long, though!

Note: At the time of this writing, SwiftUI previews are a mixed bag. They work for simple layouts but often have trouble with more complex ones. Apple will, no doubt, improve this in future releases of Xcode, so this chapter suggests viewing previews. However, if the preview rendering fails, of if you’re still running macOS Mojave, you can also build and run the app to a simulator to check out your progress.

Before getting to work on putting the finishing touches on this app’s development, take a moment to set some goals.

Setting goals

You’ve received several user stories that go like this: As a user, I want to:

Implementing JokesViewModel

This app will use a single view model to manage the state that drives several UI components, triggers fetching a joke and its translation and triggers saving a joke.

Implementing state

SwiftUI uses several pieces of state to determine how to render your views. Add this code below the line that creates the decoder:

@Published public var fetching: Bool = false
@Published public var joke: Joke = Joke.starter
@Published public var backgroundColor = Color("Gray")
@Published public var decisionState: DecisionState = .undecided
@Published public var showTranslation = false

Implementing services

Open Services/JokesService.swift. You’ll use JokesService to fetch a random joke from the chucknorris.io database. It will also provide a publisher of the data returned from a fetch.

public protocol JokeServiceDataPublisher {
  func publisher() -> AnyPublisher<Data, URLError>
}
public protocol TranslationServiceDataPublisher {
  func publisher(for joke: Joke, to languageCode: String)
    -> AnyPublisher<Data, URLError>
}
extension TranslationService: TranslationServiceDataPublisher {
  public func publisher(for joke: Joke, to languageCode: String)
    -> AnyPublisher<Data, URLError> {
    URLSession.shared.dataTaskPublisher(
      for: url(for: joke, languageCode: languageCode)
    )
    .map(\.data)
    .eraseToAnyPublisher()
  }
}
extension JokesService: JokeServiceDataPublisher {
  public func publisher() -> AnyPublisher<Data, URLError> {
    URLSession.shared
      .dataTaskPublisher(for: url)
      .map(\.data)
      .eraseToAnyPublisher()
  }
}
func publisher() -> AnyPublisher<Data, URLError> {
  // 1
  let publisher = CurrentValueSubject<Data, URLError>(data)
  
  // 2
  if let error = error {
    publisher.send(completion: .failure(error))
  }
  
  // 3
  return publisher.eraseToAnyPublisher()
}
func publisher(for joke: Joke, to languageCode: String) -> AnyPublisher<Data, URLError> {
  // 1
  let publisher = CurrentValueSubject<Data, URLError>(data)
  
  // 2
  if let error = error {
    DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
      publisher.send(completion: .failure(error))
    }
  }
  
  // 3
  return publisher.eraseToAnyPublisher()
}

Finish implementing JokesViewModel

With that handiwork done, return to View Models/JokesViewModel.swift and add the following two properties after the @Published ones:

private let jokesService: JokeServiceDataPublisher
private let translationService: TranslationServiceDataPublisher
public init(jokesService: JokeServiceDataPublisher = JokesService(),
            translationService: TranslationServiceDataPublisher = TranslationService()) {
  self.jokesService = jokesService
  self.translationService = translationService
}
$joke
  .map { _ in false }
  .assign(to: \.fetching, on: self)
  .store(in: &subscriptions)

Fetching jokes

Speaking of fetching, change the implementation of fetchJoke() to match this code:

public func fetchJoke() {
  // 1
  fetching = true
  // 2
  jokeSubscriptions = []
  
  // 3
  jokesService.publisher()
    // 4
    .retry(1)
    // 5
    .decode(type: Joke.self, decoder: Self.decoder)
     // 6
    .replaceError(with: Joke.error)
    // 7
    .receive(on: DispatchQueue.main)
    // 8
    .handleEvents(receiveOutput: { [unowned self] in
      self.joke = $0
    })
    // 9
    .filter { $0 != Joke.error }
     // 10
    .flatMap { [unowned self] joke in
      self.fetchTranslation(for: joke, to: "es")
    }
    // 11
    .receive(on: DispatchQueue.main)
     // 12
    .assign(to: \.joke, on: self)
    .store(in: &jokeSubscriptions)
}

Fetching translations

The fetchTranslation(for:to:) method will work similarly to fetchJoke(). Change the implementation of fetchTranslation(for:to:) to the following:

func fetchTranslation(for joke: Joke, to languageCode: String)
  -> AnyPublisher<Joke, Never> {
  // 1
  guard joke.languageCode != languageCode else {
    return Just(joke).eraseToAnyPublisher()
  }
  
  // 2
  return translationService.publisher(for: joke, to: languageCode)
    .retry(1)
    .decode(type: TranslationResponse.self, decoder: Self.decoder)
    // 3
    .compactMap { $0.translations.first }
    // 4
    .map {
      Joke(id: joke.id,
           value: joke.value,
           categories: joke.categories,
           languageCode: languageCode,
           translationLanguageCode: languageCode,
           translatedValue: $0)
    }
    // 5
    .replaceError(with: Joke.error)
    .eraseToAnyPublisher()
}

Changing the background color

The updateBackgroundColorForTranslation(_:) method should update backgroundColor based on the position of the joke card view — aka, its translation. Change its implementation to the following to make that work:

public func updateBackgroundColorForTranslation(_ translation: Double) {
  switch translation {
  case ...(-0.5):
    backgroundColor = Color("Red")
  case 0.5...:
    backgroundColor = Color("Green")
  default:
    backgroundColor = Color("Gray")
  }
}
public func updateDecisionStateForTranslation(
  _ translation: Double,
  andPredictedEndLocationX x: CGFloat,
  inBounds bounds: CGRect) {
  switch (translation, x) {
  case (...(-0.6), ..<0):
    decisionState = .disliked
  case (0.6..., bounds.width...):
    decisionState = .liked
  default:
    decisionState = .undecided
  }
}

Preparing for the next joke

You have one more method to go. Change reset() to:

public func reset() {
  backgroundColor = Color("Gray")
}

Making the view model observable

There’s one more thing you’ll do in this view model before moving on: Make it conform to ObservableObject so that it can be observed throughout the app. Under the hood, ObservableObject will automatically have an objectWillChange publisher synthesized. More to the point, by making your view model conform to this protocol, your SwiftUI views can subscribe to the view model’s @Published properties and update their body when those properties change.

public final class JokesViewModel: ObservableObject {

Wiring JokesViewModel up to the UI

There are two View components on the main screen of the app: a JokeView that’s essentially the background and a floating JokeCardView. Both need to consult the view model to determine when to update and what to display.

@ObservedObject var viewModel: JokesViewModel
struct JokeCardView_Previews: PreviewProvider {
  static var previews: some View {
    JokeCardView(viewModel: JokesViewModel())
      .previewLayout(.sizeThatFits)
  }
}
@ObservedObject private var viewModel = JokesViewModel()
JokeCardView(viewModel: viewModel)
Text(viewModel.showTranslation ? viewModel.joke.translatedValue
                               : viewModel.joke.value)

Displaying a required link

Yandex’s free translation service requires that your app prominently displays the “Powered by Yandex.Translate” link. So you’ll oblige, but you only want to show the link when you’re displaying a translation. To make that happen, locate the Button’s .opacity(0) modifier and change it to:

.opacity(viewModel.showTranslation ? 1 : 0)

Toggling the original and translated joke

The last thing to implement here is LargeInlineButton’s action. Locate action: { } and change it to:

action: { self.viewModel.showTranslation.toggle() }

Setting the joke card’s background color

Now, head back to JokeView.swift. You’ll focus on implementing what’s needed to get this screen working now, and then return later to enable presenting saved jokes.

.background(viewModel.backgroundColor)

Indicating if a joke was liked or disliked

Next, you’ll want to set a visual indication of whether the user liked or disliked a joke. Find the two uses of HUDView: One displays the .thumbDown image and the other displays the .rofl image. These image types are defined in HUDView.swift and correspond to images drawn using Core Graphics.

.opacity(viewModel.decisionState == .disliked ? hudOpacity : 0)
.opacity(viewModel.decisionState == .liked ? hudOpacity : 0)

Handling decision state changes

Now, find updateDecisionStateForChange(_:) and change it to:

private func updateDecisionStateForChange(_ change: DragGesture.Value) {
  viewModel.updateDecisionStateForTranslation(
    translation,
    andPredictedEndLocationX: change.predictedEndLocation.x,
    inBounds: bounds
  )
}
private func updateBackgroundColor() {
  viewModel.updateBackgroundColorForTranslation(translation)
}

Handling when the user lifts their finger

One more method to implement, then you can take the app for a spin.

private func handle(_ change: DragGesture.Value) {
  // 1
  let decisionState = viewModel.decisionState
  
  switch decisionState {
  // 2
  case .undecided:
    cardTranslation = .zero
    self.viewModel.reset()
  default:
    // 3
    let translation = change.translation
    let offset = (decisionState == .liked ? 2 : -2) * bounds.width
    cardTranslation = CGSize(width: translation.width + offset,
                             height: translation.height)
    showJokeView = false
    
    // 4
    reset()
  }
}
self.viewModel.reset()
self.viewModel.fetchJoke()

Trying out your app

To check out your progress thus far, show the preview, click Resume if necessary, and click the Live Preview play button.

Your progress so far

That takes care of the implementation side of these features:

Implementing Core Data with Combine

The Core Data team has been hard at work these past few years. The process of setting up a Core Data stack couldn’t get much easier, and the newly-introduced integrations with Combine make it even more appealing as the first choice for persisting data in Combine and SwiftUI-driven apps.

Review the data model

The data model has already been created for you. To review it, open Models/ChuckNorrisJokes.xcdatamodeld and select JokeManagedObject in the ENTITIES section. You’ll see the following attributes have been defined, along with a unique constraint on the id attribute:

Extending JokeManagedObject to save jokes

Right-click on the Models folder in the Project navigator for the main target and select New File…. Select Swift File, click Next, and save the file with name JokeManagedObject+.swift. Replace the entire body of this file with the following:

// 1
import Foundation
import SwiftUI
import CoreData
import ChuckNorrisJokesModel

// 2
extension JokeManagedObject {
  // 3
  static func save(joke: Joke, inViewContext viewContext: NSManagedObjectContext) {
    // 4
    guard joke.id != "error" else { return }
    // 5
    let fetchRequest = NSFetchRequest<NSFetchRequestResult>(
      entityName: String(describing: JokeManagedObject.self))
    // 6
    fetchRequest.predicate = NSPredicate(format: "id = %@", joke.id)
    
    // 7
    if let results = try? viewContext.fetch(fetchRequest),
       let existing = results.first as? JokeManagedObject {
      existing.value = joke.value
      existing.categories = joke.categories as NSArray
      existing.languageCode = joke.languageCode
      existing.translationLanguageCode = joke.translationLanguageCode
      existing.translatedValue = joke.translatedValue
    } else {
      // 8
      let newJoke = self.init(context: viewContext)
      newJoke.id = joke.id
      newJoke.value = joke.value
      newJoke.categories = joke.categories as NSArray
      newJoke.languageCode = joke.languageCode
      newJoke.translationLanguageCode = joke.translationLanguageCode
      newJoke.translatedValue = joke.translatedValue
    }
    
    // 9
    do {
      try viewContext.save()
    } catch {
      fatalError("\(#file), \(#function), \(error.localizedDescription)")
    }
  }
}

Extending collections of JokeManagedObject to delete jokes

To also make deleting easier, add this extension on Collections of JokeManagedObject:

extension Collection where Element == JokeManagedObject, Index == Int {
  // 1
  func delete(at indices: IndexSet, inViewContext viewContext: NSManagedObjectContext) {
    // 2
    indices.forEach { index in
      viewContext.delete(self[index])
    }
    
    // 3
    do {
      try viewContext.save()
    } catch {
      fatalError("\(#file), \(#function), \(error.localizedDescription)")
    }
  }
}

Create the Core Data stack

There are several ways to set up a Core Data stack. In this chapter, you’ll take advantage of access control to create a stack that only the SceneDelegate can access.

import Combine
import CoreData
// 1
private enum CoreDataStack {
  // 2
  static var viewContext: NSManagedObjectContext = {
    let container = NSPersistentContainer(name: "ChuckNorrisJokes")

    container.loadPersistentStores { _, error in
      guard error == nil else {
        fatalError("\(#file), \(#function), \(error!.localizedDescription)")
      }
    }

    return container.viewContext
  }()

  // 3
  static func save() {
    guard viewContext.hasChanges else { return }

    do {
      try viewContext.save()
    } catch {
      fatalError("\(#file), \(#function), \(error.localizedDescription)")
    }
  }
}
let contentView = JokeView()
  .environment(\.managedObjectContext, CoreDataStack.viewContext)
CoreDataStack.save()

Fetching jokes

Open Views/JokeView.swift and add this code right before the @ObservedObject private var viewModel property definition to get a handle to the viewContext from the environment:

@Environment(\.managedObjectContext) private var viewContext
if decisionState == .liked {
  JokeManagedObject.save(joke: viewModel.joke,
                         inViewContext: viewContext)
}

Showing saved jokes

Next, find the LargeInlineButton block of code in JokeView’s body and change it to:

LargeInlineButton(title: "Show Saved") {
  self.presentSavedJokes = true
}
.padding(20)
.sheet(isPresented: $presentSavedJokes) {
  SavedJokesView()
    .environment(\.managedObjectContext, self.viewContext)
}
NavigationView {
  VStack {
    Spacer()
    
    LargeInlineButton(title: "Show Saved") {
      self.presentSavedJokes = true
    }
    .padding(20)
  }
  .navigationBarTitle("Chuck Norris Jokes")
}
.sheet(isPresented: $presentSavedJokes) {
  SavedJokesView()
    .environment(\.managedObjectContext, self.viewContext)
}

Finishing the saved jokes view

Now, you need to finish implementing the saved jokes view, so open Views/SavedJokesView.swift. The model has already been imported for you.

@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
  sortDescriptors: [NSSortDescriptor(
                        keyPath: \JokeManagedObject.value,
                        ascending: true
                   )],
  animation: .default
) private var jokes: FetchedResults<JokeManagedObject>

Deleting jokes

Locate the ForEach(jokes, id: \.self) block of code, including the .onDelete block of code, and changing it to the following:

ForEach(jokes, id: \.self) { joke in
  // 1
  Text(self.showTranslation ? joke.translatedValue ?? "N/A"
                            : joke.value ?? "N/A")
    .lineLimit(nil)
}
.onDelete { indices in
  // 2
  self.jokes.delete(at: indices,
                    inViewContext: self.viewContext)
}

Challenge

This is the final challenge of the book. Take it on and finish strong!

Challenge: Write unit tests against JokesViewModel

In the ChuckNorrisJokesTests target, open Tests/JokesViewModelTests.swift. You’ll see the following:

private func viewModel(
  withJokeError jokeError: Bool = false,
  withTranslationError translationError: Bool = false)
  -> JokesViewModel {
  JokesViewModel(
    jokesService: mockJokesService(
      withError: jokeError
    ),
    translationService: mockTranslationService(
      withError: translationError
    )
  )
}

Key points

Here are some of the main things you learned in this chapter:

Where to go from here?

Bravo! Finishing a book of this magnitude is no small accomplishment. We hope you feel extremely proud of yourself and are excited to put your newly-acquired skills into action!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now