iOS MVVM Tutorial: Refactoring from MVC

In this iOS tutorial, you’ll learn how to convert an MVC app into MVVM. In addition, you’ll learn about the components and advantages of using MVVM. By Chuck Krutsinger .

4.8 (70) · 1 Review

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

Creating WeatherViewModel

Now that you’ve set up a mechanism for doing data binding between the view and view model, you can start to build your actual view model. In MVVM, the view controller doesn’t call any services or manipulate any model types. That responsibility falls exclusively to the view model.

You’ll start your refactor by moving code related to the geocoder and Weatherbit service from WeatherViewController into the WeatherViewModel. Then, you’ll bind views to the view model properties in WeatherViewController.

First, under View Models, create a new Swift file named WeatherViewModel. Then, add the following code:

// 1
import UIKit.UIImage
// 2
public class WeatherViewModel {
}

Here’s the code breakdown:

  1. First, add an import for UIKit.UIImage. No other UIKit types need to be permitted in the view model. A general rule of thumb is to never import UIKit in your view models.
  2. Then, set WeatherViewModel‘s class modifier to public. You make it public in order for it to be accessible for testing.

Now, open WeatherViewController.swift. Add the following property:

private let viewModel = WeatherViewModel()

Here you initialize the view model inside the controller.

Next, you’ll move WeatherViewController‘s LocationGeocoder logic to WeatherViewModel. The app won’t compile again until you complete all the following steps:

  1. First cut defaultAddress out of WeatherViewController and paste it into WeatherViewModel. Then, add a static modifier to the property.
  2. Next, cut geocoder out of the WeatherViewController and paste it into the WeatherViewModel.

In WeatherViewModel, add a new property:

let locationName = Box("Loading...")

The code above will make the app display “Loading…” on launch till a location has been fetched.

Next, add the following method into WeatherViewModel:

func changeLocation(to newLocation: String) {
  locationName.value = "Loading..."
  geocoder.geocode(addressString: newLocation) { [weak self] locations in
    guard let self = self else { return }
    if let location = locations.first {
      self.locationName.value = location.name
      self.fetchWeatherForLocation(location)
      return
    }
  }
}

This code changes locationName.value to “Loading…” prior to fetching via geocoder. When geocoder completes the lookup, you’ll update the location name and fetch the weather information for the location.

Replace WeatherViewController.viewDidLoad() with the code below:

override func viewDidLoad() {
  viewModel.locationName.bind { [weak self] locationName in
    self?.cityLabel.text = locationName
  }
}

This code binds cityLabel.text to viewModel.locationName.

Next, inside WeatherViewController.swift delete fetchWeatherForLocation(_:).

Since you still need a way to fetch weather data for a location, add a refactored fetchWeatherForLocation(_:) in WeatherViewModel.swift:

private func fetchWeatherForLocation(_ location: Location) {
  WeatherbitService.weatherDataForLocation(
    latitude: location.latitude, 
    longitude: location.longitude) { [weak self] (weatherData, error) in
      guard 
        let self = self,
        let weatherData = weatherData 
        else { 
          return 
        }
  }
}

The callback does nothing for now, but you’ll complete this method in the next section.

Finally, add an initializer to WeatherViewModel:

init() {
  changeLocation(to: Self.defaultAddress)
}

The view model starts by setting the location to the default address.

Phew! That was a lot of refactoring. You’ve just moved all service and geocoder logic from the view controller to the view model. Notice how the view controller shrunk significantly while also becoming much simpler.

To see your changes in action, change the value of defaultAddress to your current location.

Build and run.

Green home screen displaying weather for current location on Nov. 13

See that the city name now displays your current location. But the weather and date are not correct. The app is displaying the example information from the storyboard.

You’ll fix that next.

Formatting Data in MVVM

In MVVM, the view controller is only responsible for views. The view model is always responsible for formatting data from service and model types to present in the views.

In your next refactor, you’ll move the data formatting out of WeatherViewController and into WeatherViewModel. While you’re at it, you’ll add all the remaining data bindings so the weather data updates upon a change in location.

Start by addressing the date formatting. First, cut dateFormatter from WeatherViewController. Paste the property into WeatherViewModel.

Next, in WeatherViewModel, add the following below locationName:

let date = Box(" ")

It’s initially a blank string and updates when the weather data arrives from the Weatherbit API.

Now, add the following inside WeatherViewModel.fetchWeatherForLocation(_:) right before the end of the API fetch closure:

self.date.value = self.dateFormatter.string(from: weatherData.date)

The code above updates date whenever the weather data arrives.

Finally, paste in the following code to the end of WeatherViewController.viewDidLoad():

viewModel.date.bind { [weak self] date in
  self?.dateLabel.text = date
}

Build and run.

Green home screen displaying weather for current location on current date

Now the date reflects today’s date rather than Nov 13 as in the storyboard. You’re making progress!

Time to finish the refactor. Follow these final steps to finish the data bindings needed for the remaining weather fields.

First, cut tempFormatter from WeatherViewController. Paste the property into WeatherViewModel.

Then, add the following code for the remaining bindable properties into WeatherViewModel:

let icon: Box<UIImage?> = Box(nil)  //no image initially
let summary = Box(" ") 
let forecastSummary = Box(" ")

Now, add the following code to the end of WeatherViewController.viewDidLoad():

viewModel.icon.bind { [weak self] image in
  self?.currentIcon.image = image
}
    
viewModel.summary.bind { [weak self] summary in
  self?.currentSummaryLabel.text = summary
}
    
viewModel.forecastSummary.bind { [weak self] forecast in
  self?.forecastSummary.text = forecast
}

Here you have created bindings for the icon image, the weather summary and forecast summary. Whenever the values inside the boxes change, the view controller will automatically be informed.

Next, it’s time to actually change the values inside these Box objects. In WeatherViewModel.swift, add the following code to the end of completion closure in fetchWeatherForLocation(_:):

self.icon.value = UIImage(named: weatherData.iconName)
let temp = self.tempFormatter
  .string(from: weatherData.currentTemp as NSNumber) ?? ""
self.summary.value = "\(weatherData.description) - \(temp)℉"
self.forecastSummary.value = "\nSummary: \(weatherData.description)"

This code formats the different weather items for the view to present them.

Finally, add the following code to the end of changeLocation(to:) and before the end of the API fetch closure:

self.locationName.value = "Not found"
self.date.value = ""
self.icon.value = nil
self.summary.value = ""
self.forecastSummary.value = ""

This code makes sure no weather data is shown if no location is returned from the geocode call.

Build and run.

Green home screen displaying weather conditions for default location and current date

All of the weather information now updates for your defaultAddress. If you’ve used your current location, then look out the window and confirm that the data is correct. :] Next, you’ll see how MVVM can extend an app’s functionality.