MVVM with Combine Tutorial for iOS

In this MVVM with Combine Tutorial, you’ll learn how to get started using the Combine framework along with SwiftUI to build an app using the MVVM pattern By Rui Peres.

4.5 (120) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Building the App

Note: If you are new to something like SwiftUI or Combine, you may be confused by some of the snippets. Don’t worry if that’s the case! This is an advanced topic and it requires some time and practice. If something doesn’t make sense, run the app and set breakpoints to see how it behaves.

You’ll start with the model layer and move upwards to the UI.

Since you are dealing with JSON coming from the OpenWeatherMap API, you need a utility method to convert the data into a decoded object. Open Parsing.swift and add the following:

import Foundation
import Combine

func decode<T: Decodable>(_ data: Data) -> AnyPublisher<T, WeatherError> {
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .secondsSince1970

  return Just(data)
    .decode(type: T.self, decoder: decoder)
    .mapError { error in
      .parsing(description: error.localizedDescription)
    }
    .eraseToAnyPublisher()
}

This uses a standard JSONDecoder to decode the JSON from the OpenWeatherMap API. You’ll find out more about mapError(_:) and eraseToAnyPublisher() shortly.

Note: You can write the decode logic by hand or you can use a service like QuickType. As a rule of thumb, for services I own, I do it by hand. For third party services, I generate the boilerplate using QuickType. In this project, you’ll find the entities generated with this service in Responses.swift.

Now open WeatherFetcher.swift. This entity is responsible for fetching information from the OpenWeatherMap API, parsing the data and providing it to its consumer.

Like a good Swift citizen, you’ll start with a protocol. Add the following below the imports:

protocol WeatherFetchable {
  func weeklyWeatherForecast(
    forCity city: String
  ) -> AnyPublisher<WeeklyForecastResponse, WeatherError>

  func currentWeatherForecast(
    forCity city: String
  ) -> AnyPublisher<CurrentWeatherForecastResponse, WeatherError>
}

You’ll use the first method for the first screen to display the weather forecast for the next five days. You’ll use the second to view more detailed weather information.

You might be wondering what AnyPublisher is and why it has two type parameters. You can think of this as a computation to-be, or something that will execute once you subscribed to it. The first parameter (WeeklyForecastResponse) refers to the type it returns if the computation is successful and, as you might have guessed, the second refers to the type if it fails (WeatherError).

Implement those two methods by adding the following code below the class declaration:

// MARK: - WeatherFetchable
extension WeatherFetcher: WeatherFetchable {
  func weeklyWeatherForecast(
    forCity city: String
  ) -> AnyPublisher<WeeklyForecastResponse, WeatherError> {
    return forecast(with: makeWeeklyForecastComponents(withCity: city))
  }

  func currentWeatherForecast(
    forCity city: String
  ) -> AnyPublisher<CurrentWeatherForecastResponse, WeatherError> {
    return forecast(with: makeCurrentDayForecastComponents(withCity: city))
  }

  private func forecast<T>(
    with components: URLComponents
  ) -> AnyPublisher<T, WeatherError> where T: Decodable {
    // 1
    guard let url = components.url else {
      let error = WeatherError.network(description: "Couldn't create URL")
      return Fail(error: error).eraseToAnyPublisher()
    }

    // 2
    return session.dataTaskPublisher(for: URLRequest(url: url))
      // 3
      .mapError { error in
        .network(description: error.localizedDescription)
      }
      // 4
      .flatMap(maxPublishers: .max(1)) { pair in
        decode(pair.data)
      }
      // 5
      .eraseToAnyPublisher()
  }
}

Here’s what this does:

  1. Try to create an instance of URL from the URLComponents. If this fails, return an error wrapped in a Fail value. Then, erase its type to AnyPublisher, since that’s the method’s return type.
  2. Uses the new URLSession method dataTaskPublisher(for:) to fetch the data. This method takes an instance of URLRequest and returns either a tuple (Data, URLResponse) or a URLError.
  3. Because the method returns AnyPublisher<T, WeatherError>, you map the error from URLError to WeatherError.
  4. The uses of flatMap deserves a post of their own. Here, you use it to convert the data coming from the server as JSON to a fully-fledged object. You use decode(_:) as an auxiliary function to achieve this. Since you are only interested in the first value emitted by the network request, you set .max(1).
  5. If you don’t use eraseToAnyPublisher() you’ll have to carry over the full type returned by flatMap: Publishers.FlatMap<AnyPublisher<_, WeatherError>, Publishers.MapError<URLSession.DataTaskPublisher, WeatherError>>. As a consumer of the API, you don’t want to be burdened with these details. So, to improve the API ergonomics, you erase the type to AnyPublisher. This is also useful because adding any new transformation (e.g. filter) changes the returned type and, therefore, leaks implementation details.

At the model level, you should have everything you need. Build the app to make sure everything is working.

Diving Into the ViewModels

Next, you’ll work on the ViewModel that powers the weekly forecast screen:

Open WeeklyWeatherViewModel.swift and add:

import SwiftUI
import Combine

// 1
class WeeklyWeatherViewModel: ObservableObject, Identifiable {
  // 2
  @Published var city: String = ""

  // 3
  @Published var dataSource: [DailyWeatherRowViewModel] = []

  private let weatherFetcher: WeatherFetchable

  // 4
  private var disposables = Set<AnyCancellable>()

  init(weatherFetcher: WeatherFetchable) {
    self.weatherFetcher = weatherFetcher
  }
}

Here’s what that code does:

  1. Make WeeklyWeatherViewModel conform to ObservableObject and Identifiable. Conforming to these means that the WeeklyWeatherViewModel‘s properties can be used as bindings. You’ll see how to create them once you reach the View layer.]
  2. The properly delegate @Published modifier makes it possible to observe the city property. You’ll see in a moment how to leverage this.
  3. You’ll keep the View’s data source in the ViewModel. This is in contrast to what you might be used to doing in MVC. Because the property is marked @Published, the compiler automatically synthesizes a publisher for it. SwiftUI subscribes to that publisher and redraws the screen when you change the property.
  4. Think of disposables as a collection of references to requests. Without keeping these references, the network requests you’ll make won’t be kept alive, preventing you from getting responses from the server.

Now, use the WeatherFetcher by adding the following below the initializer:

func fetchWeather(forCity city: String) {
  // 1
  weatherFetcher.weeklyWeatherForecast(forCity: city)
    .map { response in
      // 2
      response.list.map(DailyWeatherRowViewModel.init)
    }

    // 3
    .map(Array.removeDuplicates)

    // 4
    .receive(on: DispatchQueue.main)

    // 5
    .sink(
      receiveCompletion: { [weak self] value in
        guard let self = self else { return }
        switch value {
        case .failure:
          // 6
          self.dataSource = []
        case .finished:
          break
        }
      },
      receiveValue: { [weak self] forecast in
        guard let self = self else { return }

        // 7
        self.dataSource = forecast
    })

    // 8
    .store(in: &disposables)
}

There’s quite a lot going on here, but I promise after this, everything will be easier!

  1. Start by making a new request to fetch the information from the OpenWeatherMap API. Pass the city name as the argument.
  2. Map the response (WeeklyForecastResponse object) to an array of DailyWeatherRowViewModel objects. This entity represents a single row in the list. You can check the implementation located in DailyWeatherRowViewModel.swift. With MVVM, it’s paramount for the ViewModel layer to expose to the View exactly the data it will need. It doesn’t make sense to expose directly to the View a WeeklyForecastResponse, since this forces the View layer to format the model in order to consume it. It’s a good idea to make the View as dumb as possible and concerned only with rendering.
  3. The OpenWeatherMap API returns multiple temperatures for the same day depending on the time of the day, so remove the duplicates. You can check Array+Filtering.swift to see how that’s done.
  4. Although fetching data from the server, or parsing a blob of JSON, happens on a background queue, updating the UI must happen on the main queue. With receive(on:), you ensure the update you do in steps 5, 6 and 7 occurs in the right place.
  5. Start the publisher via sink(receiveCompletion:receiveValue:). This is where you update dataSource accordingly. It’s important to notice that handling a completion — either a successful or failed one — happens separately from handling values.
  6. In the event of a failure, set dataSource as an empty array.
  7. Update dataSource when a new forecast arrives.
  8. Finally, add the cancellable reference to the disposables set. As previously mentioned, without keeping this reference alive, the network publisher will terminate immediately.

Build the app. Everything should compile! Right now, the app still doesn’t do much, because you don’t have a view so it’s time to take care of that!