Model-View-Viewmodel And Swift

More Swift And Model-View-Viewmodel In Practice

Model-View-Viewmodel And Swift

In the previous tutorial, we laid the foundation for adopting the Model-View-ViewModel pattern in the profile view controller of Samsara. We already implemented the Time section of the table view. This tutorial continues with the Warm Up and Cool Down sections.

Warm Up

To implement the Warm Up section, we need to revisit the tableView(_:numberOfRowsInSection:) method of the UITableViewDataSource protocol. We could return a value of 2 for the Warm Up section, but that isn't what I have in mind.

If the warm-up segment of the profile is enabled, I want to show the user the duration of the segment. If the segment is disabled, then there's no need to show the second row.

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    guard let section = ProfileSection(rawValue: section) else { return 1 }

    switch section {
    case .WarmUp:
        return profileViewModel.numberOfRowsForSegmentOfType(.WarmUp)
    default: return 1
    }
}

The view controller doesn't need to know about the configuration of the warm-up segment. In fact, the view controller doesn't know about the segment and isn't in a position to tell the table view how many rows the Warm Up section should include.

Instead, we ask the view model how many rows the Warm Up section should include. This keeps the implementation of tableView(_:numberOfRowsInSection:) concise, moving the responsibility from the view controller to the view model. Remember that the view controller is unaware of the state of the model.

func numberOfRowsForSegmentOfType(type: SegmentType) -> Int {
    var result = 1

    guard let segment = profile.segmentOfType(type) else { return result }

    switch type {
    case .WarmUp:
        result = segment.enabled ? 2 : 1
    default:
        result = 1
    }

    return result
}

In the view model, we ask profile for the segment of type type. If the profile doesn't have a segment of that type, it returns a value of 1.

The switch statement is more interesting for our discussion. Based on the enabled property of the warm-up segment, we return 2 or 1 for the number of rows. The implementation of segmentOfType(_:) is not important for this discussion.

In tableView(_:cellForRowAtIndexPath:), we invoke another helper method for the Warm Up section. The implementation is what's of interest to us.

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    guard let section = ProfileSection(rawValue: indexPath.section) else { return UITableViewCell() }

    switch section {
    case .Time:
        return cellForTimeSectionForRowAtIndexPath(indexPath)
    case .WarmUp:
        return cellForWarmUpSectionForRowAtIndexPath(indexPath)
    default:
        return UITableViewCell()
    }
}
private func cellForWarmUpSectionForRowAtIndexPath(indexPath: NSIndexPath) -> UITableViewCell {
    var result = UITableViewCell()

    switch indexPath.row {
    case 0:
        if let cell = tableView.dequeueReusableCellWithIdentifier(profileSwitchTableViewCell, forIndexPath: indexPath) as? ProfileSwitchTableViewCell {
            cell.mainLabel.text = "Enabled"
            cell.switchControl.on = profileViewModel.segmentOfTypeEnabled(.WarmUp)

            cell.switchControlHandler = { (sender) in
                self.profileViewModel.setSegmentOfType(.WarmUp, enabled: sender.on)

                if sender.on {
                    // Insert Rows
                    self.tableView.insertRowsAtIndexPaths([NSIndexPath(forRow: 1, inSection: indexPath.section)], withRowAnimation: .Top)

                } else {
                    // Delete Rows
                    self.tableView.deleteRowsAtIndexPaths([NSIndexPath(forRow: 1, inSection: indexPath.section)], withRowAnimation: .Top)
                }
            }

            result = cell
        }
    default:
        if let cell = tableView.dequeueReusableCellWithIdentifier(profileDefaultTableViewCell, forIndexPath: indexPath) as? ProfileTableViewCell {
            cell.detailTextLabel?.text = ""
            cell.textLabel?.text = profileViewModel.timeForSegmentOfType(.WarmUp)

            result = cell
        }
    }

    return result
}

If the value of indexPath.row is equal to 0, we dequeue a table view cell of type ProfileSwitchTableViewCell. This table view cell contains a UISwitch instance. What is interesting is the switchControlHandler property of the ProfileSwitchTableViewCell class. Long story short, the closure is invoked every time the value of the switch changes.

A few details are worth pointing out. First, the value of the switch is provided by the view model. We ask the view model if the warm-up segment is enabled or disabled.

func segmentOfTypeEnabled(type: SegmentType) -> Bool {
    guard let segment = profile.segmentOfType(type) else { return false }
    return segment.enabled
}

Second, in the closure assigned to the switchControlHandler property, the warm-up segment is enabled or disabled, depending on the state of the switch. Based on the new value, we insert or delete the second row of the Warm-Up section.

func setSegmentOfType(type: SegmentType, enabled: Bool) {
    if let segment = profile.segmentOfType(type) {
        // Configure Segment
        segment.enabled = enabled

    } else {
        // Create Segment
        let segment = Segment(type: type)

        // Configure Segment
        segment.enabled = enabled

        // Add Segment to Profile
        profile.segments.append(segment)
    }
}

The default case of the switch statement applies to the second row of the section. We set the text label's text to the formatted time of the warm-up segment. The view model makes that very easy.

func timeForSegmentOfType(type: SegmentType) -> String {
    guard let segment = profile.segmentOfType(type) else { return "00:00" }
    return stringFromTimeInterval(segment.duration)
}

Cool Down

The Cool Down section is similar, but it gets a bit more complex. This section can be enabled or disabled, but the user can also assign a sound to the segment. This means that the section contains two switches. Updating tableView(_:numberOfRowsInSection:) isn't difficult.

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    guard let section = ProfileSection(rawValue: section) else { return 1 }

    switch section {
    case .WarmUp:
        return profileViewModel.numberOfRowsForSegmentOfType(.WarmUp)
    case .CoolDown:
        return profileViewModel.numberOfRowsForSegmentOfType(.CoolDown)
    default: return 1
    }
}

The implementation of numberOfRowsForSegmentOfType(_:) is slightly more complex because the sound of the cool-down segment can also be enabled or disabled.

func numberOfRowsForSegmentOfType(type: SegmentType) -> Int {
    var result = 1

    guard let segment = profile.segmentOfType(type) else { return result }

    switch type {
    case .WarmUp:
        result = segment.enabled ? 2 : 1
    case .CoolDown:
        if segment.enabled {
            result = segment.soundOfTypeEnabled(.Begin) ? 4 : 3
        }
    default:
        result = 1
    }

    return result
}

In tableView(_:cellForRowAtIndexPath:), we invoke yet another helper method, cellForCoolDownSectionForRowAtIndexPath(_:).

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    guard let section = ProfileSection(rawValue: indexPath.section) else { return UITableViewCell() }

    switch section {
    case .Time:
        return cellForTimeSectionForRowAtIndexPath(indexPath)
    case .WarmUp:
        return cellForWarmUpSectionForRowAtIndexPath(indexPath)
    case .CoolDown:
        return cellForCoolDownSectionForRowAtIndexPath(indexPath)
    default:
        return UITableViewCell()
    }
}

The implementation of cellForCoolDownSectionForRowAtIndexPath(_:) is pretty lengthy. I have broken down the implementation, discussing each switch case seperately.

private func cellForCoolDownSectionForRowAtIndexPath(indexPath: NSIndexPath) -> UITableViewCell {
    var result = UITableViewCell()

    switch indexPath.row {
    case 0:
        ...
    case 1:
        ...
    case 2:
        ...
    default:
        ...
    }

    return result
}

Case 0

The first row of the Cool Down section needs a ProfileSwitchTableViewCell instance. We ask the view model for the value of the UISwitch instance of the table view cell. What makes this switch case interesting is the closure we assign to the switchControlHandler property of the table view cell.

if let cell = tableView.dequeueReusableCellWithIdentifier(profileSwitchTableViewCell, forIndexPath: indexPath) as? ProfileSwitchTableViewCell {
    cell.mainLabel.text = "Enabled"
    cell.switchControl.on = profileViewModel.segmentOfTypeEnabled(.CoolDown)

    cell.switchControlHandler = { (sender) in
        // Helpers
        var indexPaths = [NSIndexPath]()

        // Fetch Current Number of Rows
        let currentNumberOfRows = self.profileViewModel.numberOfRowsForSegmentOfType(.CoolDown)

        // Update Profile
        self.profileViewModel.setSegmentOfType(.CoolDown, enabled: sender.on)

        // Fetch New Number of Rows
        let newNumberOfRows = self.profileViewModel.numberOfRowsForSegmentOfType(.CoolDown)

        if sender.on { // Insert Rows
            for row in (currentNumberOfRows)..<(newNumberOfRows) {
                indexPaths.append(NSIndexPath(forRow: row, inSection: indexPath.section))
            }

            self.tableView.insertRowsAtIndexPaths(indexPaths, withRowAnimation: .Top)

        } else { // Delete Rows
            for row in (newNumberOfRows)..<(currentNumberOfRows) {
                indexPaths.append(NSIndexPath(forRow: row, inSection: indexPath.section))
            }

            self.tableView.deleteRowsAtIndexPaths(indexPaths, withRowAnimation: .Top)
        }
    }

    result = cell
}

We ask the view model for the current number of rows for the cool-down segment, update the enabled property of the cool-down segment, and ask the view model for the new number of rows for the segment. This gives us the required information to update the table view.

Case 1

The second row is similar to that of the warm-up section. It displays the duration of the segment on the left and a disclosure indicator on the right.

if let cell = tableView.dequeueReusableCellWithIdentifier(profileDefaultTableViewCell, forIndexPath: indexPath) as? ProfileTableViewCell {
    cell.detailTextLabel?.text = ""
    cell.textLabel?.text = profileViewModel.timeForSegmentOfType(.CoolDown)

    result = cell
}

Case 2

The third row resembles the first one. The difference is that we enable or disable the .Begin sound of the cool-down segment. The rest of the implementation is identical.

if let cell = tableView.dequeueReusableCellWithIdentifier(profileSwitchTableViewCell, forIndexPath: indexPath) as? ProfileSwitchTableViewCell {
    cell.mainLabel.text = "Bells"
    cell.switchControl.on = profileViewModel.soundOfTypeEnabled(.Begin, forSegmentOfType: .CoolDown)

    cell.switchControlHandler = { (sender) in
        // Helpers
        var indexPaths = [NSIndexPath]()

        // Fetch Current Number of Rows
        let currentNumberOfRows = self.profileViewModel.numberOfRowsForSegmentOfType(.CoolDown)

        // Update Profile
        self.profileViewModel.setSoundOfType(.Begin, enabled: sender.on, forSegmentOfType: .CoolDown)

        // Fetch New Number of Rows
        let newNumberOfRows = self.profileViewModel.numberOfRowsForSegmentOfType(.CoolDown)

        if sender.on { // Insert Rows
            for row in (currentNumberOfRows)..<(newNumberOfRows) {
                indexPaths.append(NSIndexPath(forRow: row, inSection: indexPath.section))
            }

            self.tableView.insertRowsAtIndexPaths(indexPaths, withRowAnimation: .Top)

        } else { // Delete Rows
            for row in (newNumberOfRows)..<(currentNumberOfRows) {
                indexPaths.append(NSIndexPath(forRow: row, inSection: indexPath.section))
            }

            self.tableView.deleteRowsAtIndexPaths(indexPaths, withRowAnimation: .Top)
        }
    }

    result = cell
}

Case Default

The default case applies to the fourth row of the cool-down section. It displays the name of the begin sound of the cool-down segment. This row is only visible if the cool-down section is enabled and if the begin sound is enabled for the segment.

if let cell = tableView.dequeueReusableCellWithIdentifier(profileDefaultTableViewCell, forIndexPath: indexPath) as? ProfileTableViewCell {
    cell.textLabel?.text = "Name"
    cell.detailTextLabel?.text = profileViewModel.nameSoundOfType(.Begin, forSegmentOfType: .CoolDown)

    result = cell
}

What Goes Where

Even though the MVVM pattern defines a clear separation of responsibilities, you must have noticed that some of the logic of the UITableViewDataSource protocol implementation made its way into the view model. Is that a good idea? Maybe. Maybe not. It is the responsibility of the developer to make decisions like this.

If you decide to adopt the MVVM pattern in a project, then the primary, architectural goal should be to stick with MVVM's unambiguous separation of concerns. In summary:

  • the view is ignorant of the controller
  • the controller is ignorant of the model
  • the model is ignorant of the view model
  • the view model owns the model
  • the controller owns the view
  • the controller owns the view model

Model-View-ViewModel

This is clear, but, as you noticed in this tutorial, it leaves room for discussion. I believe that there always is room for discussion. Some developers, for example, argue that complex table view cells can or should act as controllers. In the light of MVC, that means such table view cells need to know about and even own the model.

I agree that it simplifies some tasks and avoids a lot of back-and-forth between the table view cell and the controller. But at what cost? Reusability? Coupling?

To be honest, I like design patterns that leave some room for discussion. If all a developer had to do was implementing logic based on proven design patterns, then a developer's job would be much less enjoyable.

What Is Stopping You?

As I mentioned, my goal for these tutorials wasn't to leave you with a functioning Cocoa application. After all, Samsara isn't open source. What I hoped to achieve with these tutorials about MVC and MVVM is to show you a viable, dare I say better, alternative to the Model-View-ViewModel pattern we have come to love and hate.

If you decide to trade MVC for MVVM, then that doesn't mean you can no longer benefit from the benefits MVC has to offer. For the most part, MVVM is an improvement of MVC. MVVM defines the responsibilities of model and controller more clearly and leaves less room for ambiguity. The view model takes on the responsibilities that don't belong to the model and the controller. That, really, is MVVM in a nutshell.

The view model takes on the responsibilities that don't belong to the model and the controller. That, really, is MVVM in a nutshell.

The advantages are clear. Reusability increases and, more importantly, testability becomes much easier. Testing view models is the focus of the next tutorial of this series.

What's Next?

If you are reading this, then I assume you are at least considering MVVM as an option. The next tutorial of this series focuses on testing and I hope that tutorial convinces you to give MVVM a try in your next project. Questions? Leave them in the comments below or reach out to me on Twitter.

Next Episode "Testing View Models With Model-View-Viewmodel"

Stop Writing Swift That Sucks

Download the Swift Patterns I Swear By