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.city
establishes a connection between the values you’re typing in theTextField
and theWeeklyWeatherViewModel
‘scity
property. Using$
allows you to turn thecity
property into aBinding<String>
. This is only possible becauseWeeklyWeatherViewModel
conforms toObservableObject
and is declared with the@ObservedObject
property 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
WeeklyWeatherViewModel
properties 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
scheduler
parameter, so you can specify which queue the HTTP request will use. - The
city
property uses the@Published
property 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,
$city
emits 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 thefetchWeather
would make a new HTTP request for every letter typed.debounce
works 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 passscheduler
as 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
CurrentWeatherViewModel
conform toObservableObject
andIdentifiable
. - Expose an optional
CurrentWeatherRowViewModel
as the data source. - Transform new values to a
CurrentWeatherRowViewModel
as 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! :]