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 3 of 4 of this article. Click here to view the first page.

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:

  1. Your first bind! $viewModel.city establishes a connection between the values you’re typing in the TextField and the WeeklyWeatherViewModel‘s city property. Using $ allows you to turn the city property into a Binding<String>. This is only possible because WeeklyWeatherViewModel conforms to ObservableObject and is declared with the @ObservedObject property wrapper.
  2. Initialize the daily weather forecast rows with their own ViewModels. Open DailyWeatherRow.swift to see how it works.
  3. You can still use and access the WeeklyWeatherViewModel properties without any fancy binds. This just displays the city name in a Text.

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.

  1. Add a scheduler parameter, so you can specify which queue the HTTP request will use.
  2. The city property uses the @Published property delegate so it acts like any other Publisher. This means it can be observed and can also make use of any other method that is available to Publisher.
  3. 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.
  4. Use debounce(for:scheduler:) to provide a better user experience. Without it the fetchWeather 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 pass scheduler 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.
  5. You observe these events via sink(receiveValue:) and handle them with fetchWeather(forCity:) that you previously implemented.
  6. 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:

  1. Make CurrentWeatherViewModel conform to ObservableObject and Identifiable.
  2. Expose an optional CurrentWeatherRowViewModel as the data source.
  3. Transform new values to a CurrentWeatherRowViewModel as they come in the form of a CurrentWeatherForecastResponse.

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! :]