MVVM in iOS

MVVM ("model/view/view-model") is design pattern that helps promote a separation of concerns in software development. It is an extension of the well-known "model/view/controller" (MVC) pattern that is often used in user interface design.

In a traditional MVC application, a "controller" object is used to mediate interaction between two other objects known as the "model" and the "view". The model is an abstract representation of data managed by the application, and the view is a visual representation of the data contained in the model. The controller notifies the view of changes to the model, and updates the model in response to user input events received from the view.

MVVM expands on MVC by further decoupling the view from the controller. Instead of requiring the controller to explicitly manage the view's state, a view in an MVVM application uses data binding to be automatically updated in response to changes in a "view model" object exposed by the controller. This object adapts the data provided by the underlying model so that it can be easily consumed by the view. The additional level of indirection allows the view and controller to vary independently without the risk of breaking one or the other.

MVC Example

For example, consider a simple custom table view cell implemented using MVC:

The cell class might provide a set of outlets that the table view controller can use to update its state:

class CustomCell: UITableViewCell {
    @IBOutlet var headingLabel: UILabel!
    @IBOutlet var detailLabel: UILabel!

    ...
}

Row data might be represented by instances of the following class, which provides "heading" and "detail" values for each cell:

class Row: NSObject {
    var heading: String?
    var detail: String?
}

The controller would use the outlets to populate the cell's contents when a new cell is requested:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: CustomCell.description(), for: indexPath) as! CustomCell

    let row = rows[indexPath.row]

    cell.headingLabel.text = row.heading
    cell.detailLabel.text = row.heading

    return cell
}

However, this design creates a tight coupling between the controller and the custom cell view. Any time the view class changes, the controller must also be updated.

MVVM Example

MVVM solves this problem by decoupling the view from the controller. Rather than exposing its implementation details via outlets, the view registers itself as an observer on the properties of the view model. Using this approach, the view and controller both become dependent on the view model, but neither is dependent on the other. As long as the view model doesn't change, either one can be modified without impact.

For example, the following markup shows a custom table view cell implemented using MarkupKit, an open-source framework for building native iOS and tvOS applications using a simple, HTML-like markup language. The cell contains two labels arranged vertically in a column:

<root accessoryType="disclosureIndicator">
    <LMColumnView>
        <UILabel text="$row.heading"/>
        <UILabel text="$row.detail"/>
    </LMColumnView>
</root>

Instead of outlets, the cell class exposes a row property representing the view model. The labels' text properties are bound to the properties of this object:

class CustomCell: LMTableViewCell {
    // View model
    dynamic var row: Row!

    ...
}

With the bindings established, the controller can be implemented as shown below. It simply dequeues a cell and sets its row property to the corresponding model value. Because they are bound to the properties of the view model, the cell's labels are automatically updated to reflect the new values. No direct manipulation of view elements is required:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: CustomCell.description(), for: indexPath) as! CustomCell

    cell.row = rows[indexPath.row]

    return cell
}

Key-Value Observing

Even if you're not using MarkupKit, you can still create bindings manually using key-value observing (KVO). For example:

class CustomCell: UITableViewCell {
    dynamic var row: Row!

    let headingKeyPath = "row.heading"
    let detailKeyPath = "row.detail"

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        // Add observers
        addObserver(self, forKeyPath: headingKeyPath, options: [.initial, .new], context: nil)
        addObserver(self, forKeyPath: detailKeyPath, options: [.initial, .new], context: nil)
    }

    required init?(coder decoder: NSCoder) {
        super.init(coder: decoder)
    }

    deinit {
        // Remove observers
        removeObserver(self, forKeyPath: headingKeyPath)
        removeObserver(self, forKeyPath: detailKeyPath)
    }

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        // Respond to changes
        let value = change?[.newKey] as? String

        switch keyPath! {
        case headingKeyPath:
            textLabel?.text = value

        case detailKeyPath:
            detailTextLabel?.text = value

        default:
            break
        }
    }
}

However, in general, this approach will be much more verbose than using markup.

Summary

This article introduced the MVVM design pattern and provided an example of how it can be used to simplify the implementation of a custom table view cell. A complete example can be found here.

For more information, see the MarkupKit README.

MarkupKit 3.0 Released

MarkupKit 3.0 is now available for download. The primary focus of this release was not the addition of new features but improvements to an existing feature.

In earlier versions, custom table and picker view data sources and delegates could call back into the source view to obtain information about static content. However, this approach relied on overrides of UIKit view methods such as numberOfSections, numberOfRowsInSection:, and cellForRowAtIndexPath:, which already have established and documented semantics. The redefined behavior did not always reflect the original intent, which could (and did) lead to bugs.

As of MarkupKit 3.0, a custom data source or delegate can call directly into the respective protocol implementations provided by LMTableView and LMPickerView. The view methods are no longer overridden, allowing them to retain their original behaviors.

However, while this approach provides a much cleaner and more deterministic delegation model, it is not backwards-compatible. All custom data source and delegate implementations must be updated to call the delegate methods instead of the view methods. Specifically, table view controllers that provide custom content should now extend the new LMTableViewController class and delegate to the base method as needed. For example:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    let n: Int
    if (tableView.name(forSection: section) == "dynamic") {
        n = ...
    } else {
        n = super.tableView(tableView, numberOfRowsInSection: section)
    }

    return n
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell: UITableViewCell
    if (tableView.name(forSection: indexPath.section) == "dynamic") {
        cell = ...
    } else {
        cell = super.tableView(tableView, cellForRowAt: indexPath)
    }

    return cell
}

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if (tableView.name(forSection: indexPath.section) == "dynamic") {
        ...
    } else {
        super.tableView(tableView, didSelectRowAt: indexPath)
    }
}

Additionally, controllers that provide custom content for LMPickerView instances should be updated to delegate to the picker view as shown below:

func numberOfComponents(in pickerView: UIPickerView) -> Int {
    return pickerView.numberOfComponents(in: pickerView)
}

func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
    let n: Int
    if (pickerView.name(forComponent: component) == "dynamic") {
        n = ...
    } else {
        n = pickerView.pickerView(pickerView, numberOfRowsInComponent: component)
    }

    return n
}

func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
    let title: String
    if (pickerView.name(forComponent: component) == "dynamic") {
        title = ...
    } else {
        title = pickerView.title(forRow: row, forComponent:component)!
    }

    return title
}

For more information, see the following examples or the project README: