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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Building the App
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.
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:
- Try to create an instance of
URL
from theURLComponents
. If this fails, return an error wrapped in aFail
value. Then, erase its type toAnyPublisher
, since that’s the method’s return type. - Uses the new
URLSession
methoddataTaskPublisher(for:)
to fetch the data. This method takes an instance ofURLRequest
and returns either a tuple(Data, URLResponse)
or aURLError
. - Because the method returns
AnyPublisher<T, WeatherError>
, you map the error fromURLError
toWeatherError
. - 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 usedecode(_:)
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)
. - If you don’t use
eraseToAnyPublisher()
you’ll have to carry over the full type returned byflatMap
: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 toAnyPublisher
. 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:
- Make
WeeklyWeatherViewModel
conform toObservableObject
andIdentifiable
. Conforming to these means that theWeeklyWeatherViewModel
‘s properties can be used as bindings. You’ll see how to create them once you reach the View layer.] - The properly delegate
@Published
modifier makes it possible to observe thecity
property. You’ll see in a moment how to leverage this. - 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. - 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!
- Start by making a new request to fetch the information from the OpenWeatherMap API. Pass the city name as the argument.
- Map the response (
WeeklyForecastResponse
object) to an array ofDailyWeatherRowViewModel
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 aWeeklyForecastResponse
, 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. - 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.
- 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. - Start the publisher via
sink(receiveCompletion:receiveValue:)
. This is where you updatedataSource
accordingly. It’s important to notice that handling a completion — either a successful or failed one — happens separately from handling values. - In the event of a failure, set
dataSource
as an empty array. - Update
dataSource
when a new forecast arrives. - 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!