Skip to Content
Course content

126: Practice Exercise: Building a Weather App with Combine

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've spent a few lessons talking about the individual building blocks of Combine—publishers, subscribers, and operators. But honestly, Combine doesn't really "click" until you see it managing a real data flow from a network request all the way to the UI. So, let's build a miniature weather tracker. We aren't going to overcomplicate this with a massive API integration; we'll focus on the pipeline.

Modeling the Weather Response

First, I need a data structure that matches what a weather API typically returns. I'm keeping this lean. We only care about the temperature and the condition description. Since we're using JSONDecoder later in the pipeline, I'll make sure these conform to Codable.

struct Weather: Codable {
    let temperature: Double
    let condition: String
    let cityName: String
}

Wiring up the Data Stream

Now for the meat of the logic. I like to keep my network logic in a dedicated service. Instead of using the old completion handler pattern, I'm going to use dataTaskPublisher. It's a built-in Combine publisher that makes the network request and hands us a tuple of data and a response.

I'll chain a few operators here: map to grab just the data, and decode to turn that JSON into our Weather struct. I'm using eraseToAnyPublisher() at the end because I don't want the ViewModel to care about the complex nested type the chain produces.

class WeatherService {
    func fetchWeather(for city: String) -> AnyPublisher<Weather, Error> {
        let url = URL(string: "https://api.example.com/weather?q=\(city)")!
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Weather.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

Managing State in the ViewModel

This is where we actually "listen" to the stream. I'll create a WeatherViewModel that holds a @Published property for the weather. When the view asks for an update, the ViewModel will subscribe to the service.

I'm using a Set<AnyCancellable> to keep the subscription alive. If I don't store the cancellable, the request will be cancelled the moment the function finishes executing, and you'll wonder why your UI never updates.

class WeatherViewModel: ObservableObject {
    @Published var weather: Weather?
    @Published var errorMessage: String?
    
    private var cancellables = Set<AnyCancellable>()
    private let service = WeatherService()
    
    func updateWeather(city: String) {
        service.fetchWeather(for: city)
            .sink(receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    self.errorMessage = error.localizedDescription
                }
            }, receiveValue: { weather in
                self.weather = weather
            })
            .store(in: &cancellables)
    }
}

The "Main Thread" Gotcha

I actually ran this code just now, and it crashed. Why? Because dataTaskPublisher performs its work on a background thread. When the sink receives the value and tries to update self.weather, it's doing so on that background thread. Since @Published triggers a UI update, SwiftUI throws a fit because UI changes must happen on the main thread.

I forgot the most important operator for network calls: receive(on:). I need to tell Combine to hop back to the main queue before hitting the subscriber. Here is the corrected updateWeather method:

func updateWeather(city: String) {
    service.fetchWeather(for: city)
        .receive(on: RunLoop.main) // Added this to fix the threading crash
        .sink(receiveCompletion: { completion in
            if case .failure(let error) = completion {
                self.errorMessage = error.localizedDescription
            }
        }, receiveValue: { weather in
            self.weather = weather
        })
        .store(in: &cancellables)
}

Connecting to the View

With the pipeline finished, the SwiftUI side is trivial. The view just observes the ViewModel. When weather changes, the view re-renders. It's a clean, unidirectional flow: View triggers action → Service fetches data → Combine processes stream → ViewModel updates state → View reflects change.




📋 Practical Task

Exercise: Adding a Temperature Unit Toggle

Currently, our weather app only shows the temperature as it comes from the API. Your task is to implement a unit conversion feature using Combine.

  • Add a @Published var useFahrenheit: Bool = false property to the WeatherViewModel.
  • Create a new @Published var displayTemperature: String = "" property to hold the formatted string.
  • In the ViewModel's initializer, use the combineLatest operator to listen to both the $weather publisher and the $useFahrenheit publisher.
  • Whenever either changes, calculate the correct temperature (Celsius to Fahrenheit conversion: (C * 9/5) + 32) and update the displayTemperature string.
  • Ensure the final subscription is stored in the cancellables set and updates on the main thread.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.