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 .
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
iOS MVVM Tutorial: Refactoring from MVC
25 mins
- Getting Started
- Introducing MVVM Roles and Responsibilities
- Becoming Familiar With the Existing App Structure
- WeatherViewController
- Data Binding Using Box
- Creating WeatherViewModel
- Formatting Data in MVVM
- Adding Functionality in MVVM
- Unit Testing With MVVM
- Reviewing The Refactoring to MVVM
- Where to Go From Here?
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:
- First, add an import for
UIKit.UIImage
. No otherUIKit
types need to be permitted in the view model. A general rule of thumb is to never importUIKit
in your view models. - Then, set
WeatherViewModel
‘s class modifier topublic
. 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:
- First cut
defaultAddress
out ofWeatherViewController
and paste it intoWeatherViewModel
. Then, add a static modifier to the property. - Next, cut
geocoder
out of theWeatherViewController
and paste it into theWeatherViewModel
.
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.
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.
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.
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.