Chapters

Hide chapters

Combine: Asynchronous Programming With Swift

Fourth Edition · iOS 16 · Swift 5.8 · Xcode 14

20. In Practice: Building a Complete App
Written by Shai Mishali

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In the last three sections, you acquired some awesome Combine skills. In this final chapter, you’ll use everything that you’ve learned to finish developing an app that lets you fetch Chuck Norris jokes. But wait, there’s more! You’ll also see how to use Core Data with Combine to persist and retrieve your favorite jokes.

Getting Started

Open the starter project for this chapter. Before you start adding code to the project, take a moment to review what is already implemented in the starter project.

Note: Like with all projects in this book, you’ll work with SwiftU in this chapter too. If you’d like to learn more about it, check out SwiftUI by Tutorials from the Kodeco 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 a relatively strict internal access level.
  3. ChuckNorrisJokesTests: You’ll write some 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 11 Pro Max in light mode and an iPhone SE (2nd generation) in dark mode.

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

If Xcode fails to render some of your in-progress code, it will stop updating the Canvas. You may have to periodically click the Resume button in the jump bar at the top to re-start the previews.

Click the Live Preview button for 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: If the preview rendering fails, you can also build and run the app in a simulator to check out your progress.

Before getting to work on putting the finishing touches on this app’s development, you should 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, and triggers fetching and 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 = false
@Published public var joke = Joke.starter
@Published public var backgroundColor = Color("Gray")
@Published public var decisionState = DecisionState.undecided

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>
}
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
  DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
    if let error = error {
      publisher.send(completion: .failure(error))
    } else {
      publisher.send(data)
    }
  }

  // 3
  return publisher.eraseToAnyPublisher()
}

Finish Implementing JokesViewModel

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

private let jokesService: JokeServiceDataPublisher
public init(jokesService: JokeServiceDataPublisher = JokesService()) {
  self.jokesService = jokesService
}
$joke
  .map { _ in false }
  .assign(to: &$fetching)

Fetching Jokes

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

public func fetchJoke() {
  // 1
  fetching = true

  // 2
  jokesService.publisher()
    // 3
    .retry(1)
    // 4
    .decode(type: Joke.self, decoder: Self.decoder)
     // 5
    .replaceError(with: Joke.error)
    // 6
    .receive(on: DispatchQueue.main)
     // 7
    .assign(to: &$joke)
}

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 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.joke.value)

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. Locate the private var jokeCardView property and change its .background(Color.white) modifier to:

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

// 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
    } else {
      // 8
      let newJoke = self.init(context: viewContext)
      newJoke.id = joke.id
      newJoke.value = joke.value
      newJoke.categories = joke.categories as NSArray
    }

    // 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(joke.value ?? "N/A")
}
.onDelete { indices in
  // 2
  self.jokes.delete(
    at: indices,
    inViewContext: self.viewContext
  )
}

Challenges

You now have a great app, but your exceptional work ethic — and your manager — won’t allow you to check in your work without accompanying unit tests. So this chapter’s challenge section asks you to write unit tests to ensure your logic is sound, and to help prevent regressions down the road.

Challenge 1: 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) -> JokesViewModel {
  JokesViewModel(jokesService: mockJokesService(withError: jokeError))
}

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now