Chapters

Hide chapters

SwiftUI Cookbook

Live Edition · iOS 16.4 · Swift 5.8.1 · Xcode 14.3.1

Using Combine With SwiftUI
Written by Team Kodeco

Combine is a powerful declarative Swift API for processing values over time. It provides a unified approach to handle many types of asynchronous and event-driven code. When coupled with SwiftUI, you can create highly responsive UIs that reflect the changing state of your underlying data. In this tutorial, you’ll create a real-time weather application that demonstrates the capabilities of Combine.

Defining a Weather Model

The Combine framework allows you to work with publishers, subscribers and operators. It can be utilized in various scenarios, such as network requests, user input handling and more.

In this example, you’ll create a weather model and a weather service to fetch data.

import Combine

struct Weather: Codable {
  let temperature: Double
  let description: Description

  enum Description: String, Codable, CaseIterable {
    case sunny, cloudy, rainy, snowing
  }
}

class WeatherService {
  func fetchWeather(for city: String) -> AnyPublisher<Weather, Error> {
    // Simulating a network call to get weather information
    let weather = Weather(
      temperature: Double.random(in: 0..<40),
      description: Weather.Description.allCases.randomElement()!
    )

    return Just(weather)
      .setFailureType(to: Error.self)
      .delay(for: 1.0, scheduler: RunLoop.main)
      .eraseToAnyPublisher()
  }
}

View Model with Combine

The view model is where Combine shines. Here, you will use a PassthroughSubject to receive user input and a Published property to notify the view of changes.

import SwiftUI
import Combine

class WeatherViewModel: ObservableObject {
  private var cancellables = Set<AnyCancellable>()
  private let weatherService = WeatherService()
  
  @Published var weather: Weather?
  let citySubject = PassthroughSubject<String, Never>()
  
  init() {
    citySubject
      .debounce(for: 0.5, scheduler: RunLoop.main)
      .flatMap { [unowned self] city in
        self.weatherService.fetchWeather(for: city)
      }
      .receive(on: RunLoop.main)
      .sink(receiveCompletion: { _ in }, receiveValue: { [unowned self] weather in
        self.weather = weather
      })
      .store(in: &cancellables)
  }
}

Let’s break down the code above, beginning with the definition of the class:

  • cancellables: A collection that stores references to the Combine subscriptions. This prevents the subscriptions from being deallocated, so they continue receiving updates.
  • weatherService: Your weather service to fetch weather data.
  • @Published var weather: A special property wrapper that automatically notifies the view of changes to the weather data.
  • citySubject: A subject that receives user input. A subject in Combine allows you to inject values into a Combine pipeline.

Next, in the init:

  • citySubject: This is where the chain begins. Whenever the user types a city, you send that string to this subject.
  • .debounce(for: 0.5, scheduler: RunLoop.main): This operator waits until the user stops typing for 0.5 seconds before sending the value along. This avoids making a service call for every keystroke.
  • flatMap: This operator takes the city string and calls your weather service’s method, which returns a publisher. If multiple values are sent quickly, flatMap can handle them without waiting for the previous ones to complete.
  • .receive(on: RunLoop.main): This ensures that the code in the subsequent sink operator runs on the main thread. This is essential when updating the UI.
  • sink: This terminal operator catches the value and allows us to use it. Here, you update the @Published weather property, which triggers a UI refresh.
  • .store(in: &cancellables): This stores the subscription in your cancellables set so that it continues to receive updates.

SwiftUI View

Now, let’s create a view that interacts with our view model.

struct ContentView: View {
  @ObservedObject var viewModel = WeatherViewModel()

  @State private var city: String = ""

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      TextField("Enter city", text: $city, onCommit: {
        viewModel.citySubject.send(city)
      })
      if let weather = viewModel.weather {
        HStack {
          Image(systemName: imageName(for: weather.description))
          VStack(alignment: .leading) {
            Text("Temperature: \(Int(weather.temperature.rounded()))°C")
              .font(.headline)
            Text(weather.description.rawValue.capitalized)
              .font(.subheadline)
          }
        }
      }
    }
    .padding()
  }

  func imageName(for description: Weather.Description) -> String {
    switch description {
    case .sunny: return "sun.max.fill"
    case .cloudy: return "cloud.fill"
    case .rainy: return "cloud.rain.fill"
    case .snowing: return "cloud.snow.fill"
    }
  }
}

Here’s what your preview should look like:

An app that displays the weather for a given city using the Combine framework.
An app that displays the weather for a given city using the Combine framework.

The view observes the @Published property of the ViewModel and updates the UI accordingly. The TextField is used to receive user input, and the onCommit closure is used to send the city name to the ViewModel.

Using the Combine framework with SwiftUI, you have created a live weather app that responds to user input in real time. The Combine framework provides a way to streamline asynchronous and event-driven code, making it easier to write, read, and maintain. This example can be further expanded with real network calls, error handling, and more advanced UI. Try running it in Xcode and have fun exploring Combine’s powerful features!

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.