Simplifying Table Views With Model-View-ViewModel

Simplifying Table Views With Model-View-ViewModel

In Mastering Model-View-ViewModel With Swift, we explore how you can use the Model-View-ViewModel pattern to simplify table views. The example we use in the course is Cloudy, a weather application powered by the Dark Sky API. The settings view of the application contains a handful of settings.

The Settings View of Cloudy

The implementation of the view controller that drives the settings view looks very typical for an application built with the Model-View-Controller pattern. This is what the implementation of the UITableViewDataSource protocol looks like in the SettingsViewController class.

// MARK: - Table View Data Source Methods

func numberOfSections(in tableView: UITableView) -> Int {
    return Section.count
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    guard let section = Section(rawValue: section) else { fatalError("Unexpected Section") }
    return section.numberOfRows
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let section = Section(rawValue: indexPath.section) else { fatalError("Unexpected Section") }
    guard let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.reuseIdentifier, for: indexPath) as? SettingsTableViewCell else { fatalError("Unexpected Table View Cell") }

    switch section {
    case .time:
        cell.mainLabel.text = (indexPath.row == 0) ? "12 Hour" : "24 Hour"

        let timeNotation = UserDefaults.timeNotation()
        if indexPath.row == timeNotation.rawValue {
            cell.accessoryType = .checkmark
        } else {
            cell.accessoryType = .none
        }
    case .units:
        cell.mainLabel.text = (indexPath.row == 0) ? "Imperial" : "Metric"

        let unitsNotation = UserDefaults.unitsNotation()
        if indexPath.row == unitsNotation.rawValue {
            cell.accessoryType = .checkmark
        } else {
            cell.accessoryType = .none
        }
    case .temperature:
        cell.mainLabel.text = (indexPath.row == 0) ? "Fahrenheit" : "Celcius"

        let temperatureNotation = UserDefaults.temperatureNotation()
        if indexPath.row == temperatureNotation.rawValue {
            cell.accessoryType = .checkmark
        } else {
            cell.accessoryType = .none
        }
    }

    return cell
}

Using the Model-View-ViewModel pattern, we can rid the settings view controller of the task to configure table view cells. That is not the responsibility of the view controller. This may seem unnecessary for a view controller as simple as this one, but imagine a view controller managing dozens of settings and configuration options. Samsara is a fine example of this.

This is the user interface to configure a profile in Samsara.

Again, it is possible to put the view controller in charge of figuring out what each table view cell needs to display. But why would you make your life more difficult if a pattern like MVVM can elegantly solve this problem. The result is a lightweight, focused view controller. The view models are simple to implement, lightweight, and very easy to test.

Let me show you how the settings view controller can benefit from the Model-View-ViewModel pattern.

Creating a View Model for Each Section

There are several solutions to solve this problem. One solution is to create a view model for each section. Let us start with the Time Notation section. We create a new file, SettingsViewTimeViewModel.swift, and define a struct named SettingsViewTimeViewModel.

What should the view model look like? Remember that the view model keeps a reference to a model.

The view model keeps a reference to a model.

We define a property, timeNotation, of type TimeNotation.

import UIKit

struct SettingsViewTimeViewModel {

    // MARK: - Properties

    let timeNotation: TimeNotation

}

TimeNotation is an enum with two members.

enum TimeNotation: Int {
    case twelveHour
    case twentyFourHour
}

The interface of the SettingsViewTimeViewModel struct is going to be short and simple. Remember that the view controller asks the view model for the values it needs to configure a table view cell in the Time Notation section. It needs a string for the mainLabel label and it needs to know the value of the table view cell's accessoryType property. This is what the interface of the view model should look like.

import UIKit

struct SettingsViewTimeViewModel: SettingsRepresentable {

    // MARK: - Properties

    let timeNotation: TimeNotation

    // MARK: - Public Interface

    var text: String {
        ...
    }

    var accessoryType: UITableViewCellAccessoryType {
        ...
    }

}

The implementation of the computed properties is trivial as you can see below.

import UIKit

struct SettingsViewTimeViewModel: SettingsRepresentable {

    // MARK: - Properties

    let timeNotation: TimeNotation

    // MARK: - Public Interface

    var text: String {
        switch timeNotation {
        case .twelveHour: return "12 Hour"
        case .twentyFourHour: return "24 Hour"
        }
    }

    var accessoryType: UITableViewCellAccessoryType {
        if UserDefaults.timeNotation() == timeNotation {
            return .checkmark
        } else {
            return .none
        }
    }

}

The implementations of the view models for the Units and Temperature sections are very similar.

import UIKit

struct SettingsViewUnitsViewModel: SettingsRepresentable {

    // MARK: - Properties

    let unitsNotation: UnitsNotation

    // MARK: - Public Interface

    var text: String {
        switch unitsNotation {
        case .imperial: return "Imperial"
        case .metric: return "Metric"
        }
    }

    var accessoryType: UITableViewCellAccessoryType {
        if UserDefaults.unitsNotation() == unitsNotation {
            return .checkmark
        } else {
            return .none
        }
    }

}
import UIKit

struct SettingsViewTemperatureViewModel: SettingsRepresentable {

    // MARK: - Properties

    let temperatureNotation: TemperatureNotation

    // MARK: - Public Interface

    var text: String {
        switch temperatureNotation {
        case .fahrenheit: return "Fahrenheit"
        case .celsius: return "Celsius"
        }
    }

    var accessoryType: UITableViewCellAccessoryType {
        if UserDefaults.temperatureNotation() == temperatureNotation {
            return .checkmark
        } else {
            return .none
        }
    }

}

Refactoring the Settings View Controller

With the view models ready to use, we can refactor the tableView(_:cellForRowAt:) method of the settings view controller.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let section = Section(rawValue: indexPath.section) else { fatalError("Unexpected Section") }
    guard let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.reuseIdentifier, for: indexPath) as? SettingsTableViewCell else { fatalError("Unexpected Table View Cell") }

    switch section {
    case .time:
        guard let timeNotation = TimeNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }

        // Initialize View Model
        let viewModel = SettingsViewTimeViewModel(timeNotation: timeNotation)

        // Configure Cell
        cell.mainLabel.text = viewModel.text
        cell.accessoryType = viewModel.accessoryType
    case .units:
        guard let unitsNotation = UnitsNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }

        // Initialize View Model
        let viewModel = SettingsViewUnitsViewModel(unitsNotation: unitsNotation)

        // Configure Cell
        cell.mainLabel.text = viewModel.text
        cell.accessoryType = viewModel.accessoryType
    case .temperature:
        guard let temperatureNotation = TemperatureNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }

        // Initialize View Model
        let viewModel = SettingsViewTemperatureViewModel(temperatureNotation: temperatureNotation)

        // Configure Cell
        cell.mainLabel.text = viewModel.text
        cell.accessoryType = viewModel.accessoryType
    }

    return cell
}

To understand what happens, we take the Time Notation section as an example. We create an instance of the TimeNotation enum using the value of the indexPath parameter.

case .time:
    guard let timeNotation = TimeNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }

    // Initialize View Model
    let viewModel = SettingsViewTimeViewModel(timeNotation: timeNotation)

    // Configure Cell
    cell.mainLabel.text = viewModel.text
    cell.accessoryType = viewModel.accessoryType

The TimeNotation instance is used to instantiate the view model, an instance of the SettingsViewTimeViewModel struct. The view controller uses the view model to configure the table view cell. That looks much better.

This implementation of the Model-View-ViewModel pattern slightly deviates from what we discussed yesterday. The view models used by the settings view controller are short-lived objects because the view controller doesn't keep a reference to the view models. But that isn't a problem. The view models are structures, value types that are inexpensive to make. They are instantiated, used to configure a table view cell, and discarded soon thereafter.

But We Can Do Better

But we can do better than this. Notice that we repeat ourselves in the tableView(_:cellForRowAt:) method. In the next tutorial, we use protocols to further simplify the settings view controller.

You can learn more about the Model-View-ViewModel pattern and Swift in Mastering Model-View-ViewModel With Swift. Check it out.