In the remainder of this series, we refactor an application that is built with the Model-View-Controller pattern and make it adopt the Model-View-ViewModel pattern instead. This will answer two important questions:
- What are the shortcomings of the MVC pattern?
- How can the MVVM pattern help resolve these shortcomings?
Cloudy is the application we refactor. It is a lightweight weather application that shows the user the weather for their current location or a saved location. It shows the current weather conditions and a forecast for the next few days. The weather data is retrieved from a weather API.
The user can add locations and switch between locations by bringing up the locations view.
Cloudy has a settings view that enables the user to customize the presentation of the weather data. The user can customize the time, wind speed, and temperature notation in the settings view.
Application Architecture
In this episode, I walk you through the source code of the project. You can follow along by opening the starter project of this episode.
Storyboard
The main storyboard is the best place to start. You can see that we have a container view controller with two child view controllers. The top child view controller shows the current weather conditions, the bottom child view controller displays the forecast for the next few days in a table view.
If the user taps the location button in the top child view controller (top left), the locations view is shown. The user can select a saved location or add a new location in the add location view.
If the user taps the settings button in the top child view controller (top right), the settings view is shown. This is another table view listing the options we discussed earlier.
View Controllers
If we open the View Controllers group in the Project Navigator, we can see the view controller classes that correspond with what I just showed you in the storyboard.
The RootViewController
class is the container view controller. The DayViewController
class is the top cild view controller and the WeekViewController
class is the bottom child view controller. The WeatherViewController
class is the superclass of the DayViewController
and the WeekViewController
classes.
Root View Controller
The root view controller is responsible for several tasks:
- it fetches the weather data
- it fetches the current location of the user's device
- it sends the weather data to its child view controllers
The root view controller delegates the fetching of the weather data to the DataManager
class. This class sends the request to the weather API and converts the JSON response to model objects.
In the completion handler of the weatherDataForLocation(latitude:longitude:completion:)
method of the RootViewController
class, the weather data is passed to the day and week view controllers.
RootViewController.swift
// Fetch Weather Data for Location
dataManager.weatherDataForLocation(latitude: latitude, longitude: longitude) { [weak self] (result) in
switch result {
case .success(let weatherData):
// Configure Day View Controller
self?.dayViewController.now = weatherData
// Configure Week View Controller
self?.weekViewController.week = weatherData.dailyData
case .failure:
// Notify User
self?.presentAlert(of: .noWeatherDataAvailable)
// Update Child View Controllers
self?.dayViewController.now = nil
self?.weekViewController.week = nil
}
}
Model Objects
The model objects we will be working with are Location
, WeatherData
and WeatherDayData
. You can find them in the Models group.
The Location
structure makes working with locations a bit easier. There is no magic involved. The WeatherData
and WeatherDayData
structures contain the weather data that is fetched from the weather API. Notice that a WeatherData
object contains an array of WeatherDayData
objects.
WeatherData.swift
import Foundation
struct WeatherData: Decodable {
...
// MARK: - Properties
let time: Date
// MARK: -
let latitude: Double
let longitude: Double
let windSpeed: Double
let temperature: Double
// MARK: -
let icon: String
let summary: String
// MARK: -
let dailyData: [WeatherDayData]
...
}
The current weather conditions are stored in the WeatherData
object and the forecast for the next few days is stored in an array of WeatherDayData
objects.
The root view controller passes the week view controller the array of WeatherDayData
objects, which it displays in a table view.
WeekViewController.swift
// Configure Week View Controller
self?.weekViewController.week = weatherData.dailyData
The day view controller receives the WeatherData
object from the root view controller.
DayViewController.swift
// Configure Day View Controller
self?.dayViewController.now = weatherData
Day View Controller
The now
property of the DayViewController
class stores the WeatherData
object. Every time this property is set, the user interface is updated with new weather data by invoking updateView()
.
DayViewController.swift
var now: WeatherData? {
didSet {
updateView()
}
}
In updateView()
, we hide the activity indicator view and update the weather data container view. The weather data container view is nothing more than a view that contains the views that present the weather data to the user.
DayViewController.swift
private func updateView() {
activityIndicatorView.stopAnimating()
if let now = now {
updateWeatherDataContainerView(with: now)
} else {
messageLabel.isHidden = false
messageLabel.text = "Cloudy was unable to fetch weather data."
}
}
The implementation of updateWeatherDataContainerView(with:)
is a classic example of the Model-View-Controller pattern. The model object is dissected and the raw values are transformed, formatted, and presented to the user.
DayViewController.swift
private func updateWeatherDataContainerView(with weatherData: WeatherData) {
weatherDataContainerView.isHidden = false
var windSpeed = weatherData.windSpeed
var temperature = weatherData.temperature
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "EEE, MMMM d"
dateLabel.text = dateFormatter.string(from: weatherData.time)
let timeFormatter = DateFormatter()
if UserDefaults.timeNotation == .twelveHour {
timeFormatter.dateFormat = "hh:mm a"
} else {
timeFormatter.dateFormat = "HH:mm"
}
timeLabel.text = timeFormatter.string(from: weatherData.time)
descriptionLabel.text = weatherData.summary
if UserDefaults.temperatureNotation != .fahrenheit {
temperature = temperature.toCelcius
temperatureLabel.text = String(format: "%.1f °C", temperature)
} else {
temperatureLabel.text = String(format: "%.1f °F", temperature)
}
if UserDefaults.unitsNotation != .imperial {
windSpeed = windSpeed.toKPH
windSpeedLabel.text = String(format: "%.f KPH", windSpeed)
} else {
windSpeedLabel.text = String(format: "%.f MPH", windSpeed)
}
iconImageView.image = imageForIcon(withName: weatherData.icon)
}
Week View Controller
The week view controller looks similar in several ways. The week
property stores the weather data and every time the property is set, the view controller's view is updated with the new weather data by invoking updateView()
.
WeekViewController.swift
var week: [WeatherDayData]? {
didSet {
updateView()
}
}
In updateView()
, we stop the activity indicator view, stop refreshing the refresh control, and invoke updateWeatherDataContainerView(with:)
if there is weather data available.
WeekViewController.swift
private func updateView() {
activityIndicatorView.stopAnimating()
tableView.refreshControl?.endRefreshing()
if let week = week {
updateWeatherDataContainerView(with: week)
} else {
messageLabel.isHidden = false
messageLabel.text = "Cloudy was unable to fetch weather data."
}
}
In updateWeatherDataContainerView(with:)
, we show the weather data container, which contains the table view, and reload the table view.
WeekViewController.swift
private func updateWeatherDataContainerView(with weatherData: [WeatherDayData]) {
// Show Weather Data Container View
weatherDataContainerView.isHidden = false
// Update Table View
tableView.reloadData()
}
The most interesting aspect of the week view controller is the configuration of table view cells in tableView(_:cellForRowAt:)
. In this method, we dequeue a table view cell, fetch the weather data for the day that corresponds with the index path, and configure the table view cell.
WeekViewController.swift
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: WeatherDayTableViewCell.reuseIdentifier, for: indexPath) as? WeatherDayTableViewCell else {
fatalError("Unable to Dequeue Weather Day Table View Cell")
}
if let week = week {
// Fetch Weather Data
let weatherData = week[indexPath.row]
var windSpeed = weatherData.windSpeed
var temperatureMin = weatherData.temperatureMin
var temperatureMax = weatherData.temperatureMax
if UserDefaults.temperatureNotation != .fahrenheit {
temperatureMin = temperatureMin.toCelcius
temperatureMax = temperatureMax.toCelcius
}
// Configure Cell
cell.dayLabel.text = dayFormatter.string(from: weatherData.time)
cell.dateLabel.text = dateFormatter.string(from: weatherData.time)
let min = String(format: "%.0f°", temperatureMin)
let max = String(format: "%.0f°", temperatureMax)
cell.temperatureLabel.text = "\(min) - \(max)"
if UserDefaults.unitsNotation != .imperial {
windSpeed = windSpeed.toKPH
cell.windSpeedLabel.text = String(format: "%.f KPH", windSpeed)
} else {
cell.windSpeedLabel.text = String(format: "%.f MPH", windSpeed)
}
cell.iconImageView.image = imageForIcon(withName: weatherData.icon)
}
return cell
}
As in the day view controller, we take the raw values of the model objects and format them before displaying the weather data to the user. Notice that we use several if
statements to make sure the weather data is formatted according to the user's preferences.
Locations View Controller
The locations view controller manages a list of locations and it displays the coordinates of the current location of the device. If the user selects a location from the list, Cloudy asks the weather API for that location's weather data and displays it in the weather view controllers.
The user can add a new location by tapping the plus button in the top left. This summons the add location view controller. The user is asked to enter the name of a city. Under the hood, the add location view controller uses the Core Location framework to perform a forward geocoding request. Cloudy is only interested in the coordinates of any matches the Core Location framework returns.
Settings View Controller
Despite the simplicity of the settings view, the SettingsViewController
class is almost 200 lines long. Later in this series, we leverage the Model-View-ViewModel pattern to make its implementation shorter and more transparent.
The SettingsViewController
class has a delegate, which it notifies whenever a setting changed.
SettingsViewController.swift
protocol SettingsViewControllerDelegate {
func controllerDidChangeTimeNotation(controller: SettingsViewController)
func controllerDidChangeUnitsNotation(controller: SettingsViewController)
func controllerDidChangeTemperatureNotation(controller: SettingsViewController)
}
The root view controller is the delegate of the settings view controller and it tells its child view controllers to reload their user interface whenever a setting changed.
RootViewController.swift
extension RootViewController: SettingsViewControllerDelegate {
func controllerDidChangeTimeNotation(controller: SettingsViewController) {
dayViewController.reloadData()
weekViewController.reloadData()
}
func controllerDidChangeUnitsNotation(controller: SettingsViewController) {
dayViewController.reloadData()
weekViewController.reloadData()
}
func controllerDidChangeTemperatureNotation(controller: SettingsViewController) {
dayViewController.reloadData()
weekViewController.reloadData()
}
}
Time to Write Some Code
That is all you need to know about Cloudy for now. In the next episode, we focus on several aspects in more detail and discuss which bits we plan to refactor with the help of the Model-View-ViewModel pattern.