Mastering MVVM With Swift

Meet Cloudy

Resources

In the remainder of this series, we're going to refactor an application that's built with MVC and make it adopt MVVM instead. This will give you two important insights:

  • What are the shortcomings of MVC?
  • How can MVVM help resolve these shortcomings?

The application we're going to refactor is Cloudy. Cloudy is a lightweight weather application that shows the user the weather of 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 the Dark Sky API, an easy-to-use weather service.

Meet Cloudy

The user can add locations and switch between locations by bringing up the locations view controller.

Managing Locations

Adding Locations

Cloudy has a settings view to change the time notation, the application's units system, and the user can switch between degrees Fahrenheit and degrees Celcius.

Managing the Application's Settings

Application Architecture

In this episode, I walk you through the source code of Cloudy. 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.

Cloudy's Main Storyboard

If the user taps the location button in the top child view controller (top left), the locations view controller is shown. The user can switch between locations and add new locations using the add location view controller.

Cloudy's Main Storyboard

If the user taps the settings button in the top child view controller (top right), the settings view controller is shown. This is another table view listing the options we discussed earlier.

Cloudy's Main Storyboard

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.

Cloudy's View Controllers

The RootViewController class is the container view controller. The DayViewController is the top cild view controller and the WeekViewController is the bottom child view controller. The WeatherViewController class is the superclass of the DayViewController and the WeekViewController.

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 Dark Sky API and converts the JSON response to model objects. I use a simple, lightweight JSON parser for this task. The implementation of the JSON parser and the DataManager class are unimportant for this discussion.

In the completion handler of the weatherDataForLocation(latitude:longitude:completion:) method of the RootViewController class, the weather data is sent to the day view controller and the week view controller.

RootViewController.swift

dataManager.weatherDataForLocation(latitude: latitude, longitude: longitude) { (response, error) in
    if let error = error {
        print(error)
    } else if let response = response {
        // Configure Day View Controller
        self.dayViewController.now = response

        // Configure Week View Controller
        self.weekViewController.week = response.dailyData
    }
}

Model Objects

The model objects we'll be working with are Location, WeatherData and WeatherDayData. You can find them in the Models group.

Model Objects

The Location structure makes working with locations a bit easier. There's no magic involved. The WeatherData and WeatherDayData structures contain the weather data that's fetched from the Dark Sky API. Notice that a WeatherData object contains an array of WeatherDayData instances.

WeatherData.swift

import Foundation

struct WeatherData {

    let time: Date

    let lat: Double
    let long: Double
    let windSpeed: Double
    let temperature: Double

    let icon: String
    let summary: String

    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 only hands the week view controller the array of WeatherDayData objects, which it displays in a table view.

WeekViewController.swift

var week: [WeatherDayData]?

The day view controller receives the WeatherData object from the root view controller.

DayViewController.swift

var 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, this is nothing more than a view that contains the views displaying the weather data.

DayViewController.swift

private func updateView() {
    activityIndicatorView.stopAnimating()

    if let now = now {
        updateWeatherDataContainer(withWeatherData: now)

    } else {
        messageLabel.isHidden = false
        messageLabel.text = "Cloudy was unable to fetch weather data."

    }
}

The implementation of updateWeatherDataContainer(withWeatherData:) is a classic example of the Model-View-Controller pattern. The model object is torn apart and the raw values are transformed and formatted for display to the user.

DayViewController.swift

private func updateWeatherDataContainer(withWeatherData weatherData: WeatherData) {
    weatherDataContainer.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 updateWeatherDataContainer(withWeatherData:) if there's weather data we need to show the user.

WeekViewController.swift

private func updateView() {
    activityIndicatorView.stopAnimating()
    tableView.refreshControl?.endRefreshing()

    if let week = week {
        updateWeatherDataContainer(withWeatherData: week)

    } else {
        messageLabel.isHidden = false
        messageLabel.text = "Cloudy was unable to fetch weather data."

    }
}

In updateWeatherDataContainer(withWeatherData:), we show the weather data container, which contains the table view, and reload the table view.

WeekViewController.swift

private func updateWeatherDataContainer(withWeatherData weatherData: [WeatherDayData]) {
    weatherDataContainer.isHidden = false

    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 populate 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("Unexpected 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 based on the user’s preferences in the settings view controller.

Locations View Controller

The locations view controller manages a list of locations and it displays the coordinates of the device's current location. If the user selects a location from the list, Cloudy asks the Dark Sky 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's 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.

If you want to run Cloudy, you need to add your Dark Sky API key to Configuration.swift. Signing up for a developer account is free and it only takes a minute.

Configuration.swift

struct API {

    static let APIKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    static let BaseURL = URL(string: "https://api.darksky.net/forecast/")!

    static var AuthenticatedBaseURL: URL {
        return BaseURL.appendingPathComponent(APIKey)
    }

}
Resources
Next Episode "What Is Wrong With Cloudy"

Stop Writing Swift That Sucks

Download the Swift Patterns I Swear By