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
Weekly Weather View
Start by opening WeeklyWeatherView.swift. Then, add the viewModel property and an initializer inside the struct:
@ObservedObject var viewModel: WeeklyWeatherViewModel
init(viewModel: WeeklyWeatherViewModel) {
self.viewModel = viewModel
}
The @ObservedObject property delegate establishes a connection between the WeeklyWeatherView and the WeeklyWeatherViewModel. This means that, when the WeeklyWeatherView‘s property objectWillChange sends a value, the view is notified that the data source is about to change and consequently the view is re-rendered.
Now open SceneDelegate.swift and replace the old weeklyView property with the following:
let fetcher = WeatherFetcher()
let viewModel = WeeklyWeatherViewModel(weatherFetcher: fetcher)
let weeklyView = WeeklyWeatherView(viewModel: viewModel)
Build the project again to make sure that everything compiles.
Head back into WeeklyWeatherView.swift and replace body with the actual implementation for your app:
var body: some View {
NavigationView {
List {
searchField
if viewModel.dataSource.isEmpty {
emptySection
} else {
cityHourlyWeatherSection
forecastSection
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle("Weather ⛅️")
}
}
When the dataSource is empty, you’ll show an empty section. Otherwise, you’ll show the forecast section and the ability to see more detail about the particular city that you searched for. Add the following at the bottom of the file:
private extension WeeklyWeatherView {
var searchField: some View {
HStack(alignment: .center) {
// 1
TextField("e.g. Cupertino", text: $viewModel.city)
}
}
var forecastSection: some View {
Section {
// 2
ForEach(viewModel.dataSource, content: DailyWeatherRow.init(viewModel:))
}
}
var cityHourlyWeatherSection: some View {
Section {
NavigationLink(destination: CurrentWeatherView()) {
VStack(alignment: .leading) {
// 3
Text(viewModel.city)
Text("Weather today")
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
var emptySection: some View {
Section {
Text("No results")
.foregroundColor(.gray)
}
}
}
Although there is quite a bit of code here, there are only three main parts:
- Your first bind!
$viewModel.cityestablishes a connection between the values you’re typing in theTextFieldand theWeeklyWeatherViewModel‘scityproperty. Using$allows you to turn thecityproperty into aBinding<String>. This is only possible becauseWeeklyWeatherViewModelconforms toObservableObjectand is declared with the@ObservedObjectproperty wrapper. - Initialize the daily weather forecast rows with their own ViewModels. Open DailyWeatherRow.swift to see how it works.
- You can still use and access the
WeeklyWeatherViewModelproperties without any fancy binds. This just displays the city name in aText.
Build and run the app and you should see the following:

Surprisingly, or not, nothing happens. The reason for this is that you haven’t connected the city bind to an actual HTTP request yet. Time to fix that.
Open WeeklyWeatherViewModel.swift and replace your current initializer with the following:
// 1
init(
weatherFetcher: WeatherFetchable,
scheduler: DispatchQueue = DispatchQueue(label: "WeatherViewModel")
) {
self.weatherFetcher = weatherFetcher
// 2
$city
// 3
.dropFirst(1)
// 4
.debounce(for: .seconds(0.5), scheduler: scheduler)
// 5
.sink(receiveValue: fetchWeather(forCity:))
// 6
.store(in: &disposables)
}
This code is crucial because it bridges both worlds: SwiftUI and Combine.
- Add a
schedulerparameter, so you can specify which queue the HTTP request will use. - The
cityproperty uses the@Publishedproperty delegate so it acts like any otherPublisher. This means it can be observed and can also make use of any other method that is available toPublisher. - As soon as you create the observation,
$cityemits its first value. Since the first value is an empty string, you need to skip it to avoid an unintended network call. - Use
debounce(for:scheduler:)to provide a better user experience. Without it thefetchWeatherwould make a new HTTP request for every letter typed.debounceworks by waiting half a second (0.5) until the user stops typing and finally sending a value. You can find a great visualization of this behavior at RxMarbles. You also passscheduleras an argument, which means that any value emitted will be on that specific queue. Rule of thumb: You should process values on a background queue and deliver them on the main queue. - You observe these events via
sink(receiveValue:)and handle them withfetchWeather(forCity:)that you previously implemented. - Finally, you store the cancelable as you did before.
Build and run the project. You should finally see the main screen in action:

Navigation and Current Weather Screen
MVVM as an architectural pattern doesn’t get into the nitty-gritty details. Some decisions are left up to the developer’s discretion. One of those is how you navigate from one screen to another, and what entity owns that responsibility. SwiftUI hints on the usage of NavigationLink, and, as such, this is what you’ll use in this tutorial.
If you look at NavigationLink‘s most basic initializer: public init<V>(destination: V, label: () -> Label) where V : View, you can see that it expects a View as an argument. This, in essence ties your current View (origin) to another View (destination). This relationship might be okay in simpler apps but when you have complex flows that require different destinations based on external logic (like a server response) you might get into trouble.
Following the MVVM recipe, the View should ask the ViewModel what to do next, but this is tricky because the parameter expected is a View and a ViewModel should be agnostic about those concerns. This problem is solved via FlowControllers or Coordinators, which are represented by yet another entity that works alongside the ViewModel to manage routing across app. This approach scales well, but it would stop you from using something like NavigationLink.
All of that is beyond the scope of this tutorial so, for now, you’ll be pragmatic and use a hybrid approach.
Before diving into navigation, first update CurrentWeatherView and CurrentWeatherViewModel. Open CurrentWeatherViewModel.swift and add the following:
import SwiftUI
import Combine
// 1
class CurrentWeatherViewModel: ObservableObject, Identifiable {
// 2
@Published var dataSource: CurrentWeatherRowViewModel?
let city: String
private let weatherFetcher: WeatherFetchable
private var disposables = Set<AnyCancellable>()
init(city: String, weatherFetcher: WeatherFetchable) {
self.weatherFetcher = weatherFetcher
self.city = city
}
func refresh() {
weatherFetcher
.currentWeatherForecast(forCity: city)
// 3
.map(CurrentWeatherRowViewModel.init)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
self.dataSource = nil
case .finished:
break
}
}, receiveValue: { [weak self] weather in
guard let self = self else { return }
self.dataSource = weather
})
.store(in: &disposables)
}
}
CurrentWeatherViewModel mimics what you did previously in WeeklyWeatherViewModel:
- Make
CurrentWeatherViewModelconform toObservableObjectandIdentifiable. - Expose an optional
CurrentWeatherRowViewModelas the data source. - Transform new values to a
CurrentWeatherRowViewModelas they come in the form of aCurrentWeatherForecastResponse.
Now, take care of the UI. Open CurrentWeatherView.swift and add an initializer at the top of the struct:
@ObservedObject var viewModel: CurrentWeatherViewModel
init(viewModel: CurrentWeatherViewModel) {
self.viewModel = viewModel
}
This follows the same pattern you applied in WeeklyWeatherView and it’s most likely what you’ll be doing when using SwiftUI in your own projects: You inject a ViewModel in the View and access its public API.
Now, update the body computed property:
var body: some View {
List(content: content)
.onAppear(perform: viewModel.refresh)
.navigationBarTitle(viewModel.city)
.listStyle(GroupedListStyle())
}
You’ll notice the use of the onAppear(perform:) method. This takes a function of type () -> Void and executes it when the view appears. In this case, you call refresh() on the View Model so the dataSource can be refreshed.
Finally, add the following at the bottom of the file:
private extension CurrentWeatherView {
func content() -> some View {
if let viewModel = viewModel.dataSource {
return AnyView(details(for: viewModel))
} else {
return AnyView(loading)
}
}
func details(for viewModel: CurrentWeatherRowViewModel) -> some View {
CurrentWeatherRow(viewModel: viewModel)
}
var loading: some View {
Text("Loading \(viewModel.city)'s weather...")
.foregroundColor(.gray)
}
}
This adds the remaining UI bits.
The project doesn’t compile yet, because you’ve changed the CurrentWeatherView initializer.
Now that you have most pieces in place, it’s time to wrap up your navigation. Open WeeklyWeatherBuilder.swift and add the following:
import SwiftUI
enum WeeklyWeatherBuilder {
static func makeCurrentWeatherView(
withCity city: String,
weatherFetcher: WeatherFetchable
) -> some View {
let viewModel = CurrentWeatherViewModel(
city: city,
weatherFetcher: weatherFetcher)
return CurrentWeatherView(viewModel: viewModel)
}
}
This entity will act as a factory to create screens that are needed when navigating from the WeeklyWeatherView.
Open WeeklyWeatherViewModel.swift and start using the builder by adding the following at the bottom of the file:
extension WeeklyWeatherViewModel {
var currentWeatherView: some View {
return WeeklyWeatherBuilder.makeCurrentWeatherView(
withCity: city,
weatherFetcher: weatherFetcher
)
}
}
Finally, open WeeklyWeatherView.swift and change the cityHourlyWeatherSection property implementation to the following:
var cityHourlyWeatherSection: some View {
Section {
NavigationLink(destination: viewModel.currentWeatherView) {
VStack(alignment: .leading) {
Text(viewModel.city)
Text("Weather today")
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
The key piece here is viewModel.currentWeatherView. WeeklyWeatherView asks WeeklyWeatherViewModel which view it should navigate to next. WeeklyWeatherViewModel makes use of WeeklyWeatherBuilder to provide the necessary view. There is a nice separation between responsibilities while at the same time keeping the overall relationship between them easy to follow.
There are many other approaches to solve the navigation problem. Some developers will argue that the View layer shouldn’t be aware to where it’s navigating, or even how that navigation should happen (modally or pushed). If that’s the argument, then it no longer makes sense to use what Apple provides with NavigationLink. It’s important to strike a balance between pragmatism and scalability. This tutorial leans towards the former.
Build and run the project. Everything should work as expected! Congratulations on creating your weather app! :]
