Yesterday, you learned how to use view models in a view controller that isn't driven by data. We refactored the settings view controller of Cloudy.
But there is room for improvement. We are repeating ourselves in the tableView(_:cellForRowAt:)
method of the UITableViewDataSource
protocol. We can improve this using protocol-oriented programming.
Creating a Protocol
The first improvement I want to make is removing the duplicate code in the tableView(_:cellForRowAt:)
method of the UITableViewDataSource
protocol.
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
}
We can optimize the implementation of the tableView(_:cellForRowAt:)
method with a protocol. Create a new file and name it SettingsRepresentable.swift. Add an import statement for the UIKit framework at the top and define the SettingsRepresentable
protocol. The interface of the protocol should reflect that of the view models we created in the previous tutorial. This is what the protocol definition should look like.
import UIKit
protocol SettingsRepresentable {
var text: String { get }
var accessoryType: UITableViewCellAccessoryType { get }
}
Before we can use the protocol in the settings view controller, the view models we created earlier need to conform to the protocol. That is easy, though. We only need to add the protocol to the type definition of each view model.
import UIKit
struct SettingsViewTimeViewModel: SettingsRepresentable {
...
}
import UIKit
struct SettingsViewUnitsViewModel: SettingsRepresentable {
...
}
import UIKit
struct SettingsViewTemperatureViewModel: SettingsRepresentable {
...
}
That's it. Each of the view models automatically conforms to the SettingsRepresentable
protocol.
Refactoring the Settings View Controller
We can now update the tableView(_:cellForRowAt:)
method of the SettingsViewController
class. We declare a variable, viewModel
, of type SettingsRepresentable?
and assign the view model to that variable. If viewModel
has a value, the view controller uses the view model to configure the table view cell.
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") }
var viewModel: SettingsRepresentable?
switch section {
case .time:
guard let timeNotation = TimeNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }
viewModel = SettingsViewTimeViewModel(timeNotation: timeNotation)
case .units:
guard let unitsNotation = UnitsNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }
viewModel = SettingsViewUnitsViewModel(unitsNotation: unitsNotation)
case .temperature:
guard let temperatureNotation = TemperatureNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }
viewModel = SettingsViewTemperatureViewModel(temperatureNotation: temperatureNotation)
}
if let viewModel = viewModel {
cell.mainLabel.text = viewModel.text
cell.accessoryType = viewModel.accessoryType
}
return cell
}
Better but Not Perfect
While this is an improvement, I would like to further reduce the involvement of the settings view controller. The SettingsTableViewCell
is perfectly capable of configuring itself if we hand it a view model. All the table view cell needs to do is ask the view model for the values it needs to configure itself.
Note that this is very different from passing a model to a view, which is certainly not what we intend to do. The table view cell won’t know about the model, it will use the interface of the view model we give it. Because the view model conforms to the SettingsRepresentable
protocol, the SettingsTableViewCell
doesn't even need to know about any of the view models. That is the flexibility of protocol-oriented programming.
To make this work, we need to update the SettingsTableViewCell
class. Open SettingsTableViewCell.swift and define a new method, configure(withViewModel:)
, which accepts one parameter of type SettingsRepresentable
.
// MARK: - Configuration
func configure(withViewModel viewModel: SettingsRepresentable) {
mainLabel.text = viewModel.text
accessoryType = viewModel.accessoryType
}
The implementation is straightforward. The table view cell asks the view model for the values it needs to populate and configure itself.
The final change we need to make is update the tableView(_:cellForRowAt:)
of the SettingsViewController
class. As you can see, the role of the view controller is very limited. It instantiates a view model for each table view cell and passes the view model to the configure(withViewModel:)
method of the table view cell. The table view cell takes care of the rest.
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") }
var viewModel: SettingsRepresentable?
switch section {
case .time:
guard let timeNotation = TimeNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }
viewModel = SettingsViewTimeViewModel(timeNotation: timeNotation)
case .units:
guard let unitsNotation = UnitsNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }
viewModel = SettingsViewUnitsViewModel(unitsNotation: unitsNotation)
case .temperature:
guard let temperatureNotation = TemperatureNotation(rawValue: indexPath.row) else { fatalError("Unexpected Index Path") }
viewModel = SettingsViewTemperatureViewModel(temperatureNotation: temperatureNotation)
}
if let viewModel = viewModel {
cell.configure(withViewModel: viewModel)
}
return cell
}
What's Next?
The settings view controller is a simple view controller, but I hope you can see the potential of the Model-View-ViewModel pattern in terms of simplifying view controllers. The settings view controller is very focused. All it does is manage its view and subviews and handle user interaction. That is the primary role of every view controller. And this applies to Model-View-ViewModel as well as Model-View-Controller.
You can learn more about the Model-View-ViewModel pattern and Swift in Mastering Model-View-ViewModel With Swift. Check it out.