Creating dynamic screens with protocol-oriented MVVM in Swift

profile picture

Jens Engineer

22 Mar 2018  ·  9 min read


We developed a more streamlined methodology to translate component-based designs into easily adjustable pages and views – with minimal duplicate code.

Previously in a MVC + Objective-C environment, our view controllers would dictate the UI representation. This could lead to a lot of repetitive code or one large parent view controller. When we started to develop the new MyProximus application, these issues became painfully obvious. We noticed that different pages looked more or less the same when filled with data. All of them had loading and error states, which also looked the same across all parts of the app. And because a solution using MVVM would come more naturally in Swift, this would immediately make the MyProximus app our largest Swift app to date.

Goals and opportunities

Let’s take a first look at the designs to see what we’re talking about.

As you can see, there are a lot of similarities here, with some slight variations.. Looking even closer, you can define similar blocks of UI across different screens. This conscious design methodology (which we’ve talked about in more detail in this post) of reusing visual elements to create a coherent look, also lends itself perfectly to reusing a lot of separate components in development – just displaying different data and positioned differently on each screen.

The main opportunity and goal here is to:

  • minimise duplicate code,
  • make the creation of pages and views easier
  • make them more adjustable, changing between loading, data, error states

We’ll be using Swift and incorporate a type of protocol-oriented MVVM (model, view, view model). This way, we’ll minimise the logic within the view controllers, and keep the control somewhere more suitable. A view controller doesn’t need to know about its content – the view models can dictate how they want to be presented.

In what follows, we’ll go into some more detail on the way we handled different components in this project.

TableViewController proposal

For this specific case, a tableview is best suited to implement the bigger part of the app. It’s a vertical repetition of different cells (components) and sections, which can vary in presented data and order. If we look at the bare minimum of what a tableview needs to present data, we come to the following:

  • Number of sections
  • Number of cells within a section
  • Height for each section or cell
  • A reuse identifier to dequeue the view for the respective section or cell
  • An action to be triggered when tapping a cell

For the time being we’ll focus on the first 4. The action isn’t relevant when building the UI and will be discussed later.

Now, it’s important to note that for the following solution to work, we’ll need to set some rules to make our lives simpler. Views presented by the tableview should have a configure function, so they can be configured with their specific view model. This will be in the form of a protocol that can be easily implemented by other view models. An example for this will be provided later on.

So, we have sections to display, and each section should have its own cells. Both have a height and a reuse identifier. So essentially, they are the same.

We could define a component within a tableview with the following protocol:

protocol TableViewItemViewModel {
  var reuseIdentifier: String { get }
  var height: Double { get }
  var action: Any? { get }
}

Now, we can compose the entire tableview’s datasource with items conform to this protocol. A universal datasource for a tableview would be a list (Array) of sections resulting in a sectioned datasource. One such section would look like this:

struct TableViewSectionMap {
  let section: TableViewItemViewModel?
  let items: [TableViewItemViewModel]
  let footer: TableViewItemViewModel?
}

Using all the capabilities a tableview provides us, we also added the footer. Both the section and footer are optional because a section within a tableview doesn’t necessarily needs to display a view for its header or footer.

ViewController implementation

As discussed, the datasource is a list of sections, defined by the following statement:

var datasource: [TableViewSectionMap]

TableViewDataSource

extension TableViewController : UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return datasource(for: tableView).count
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return datasource(for: tableView)[safe: section]?.items.count ?? 0
    }
    
    // MARK: Headers
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        guard let section = datasource(for: tableView)[safe: section]?.section, section.height > 0 else {
            return nil
        }
        
        let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: section.reuseIdentifier)
        
        return headerView
    }
    
    // MARK: Footers
    func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
        guard let footer = datasource(for: tableView)[safe: section]?.footer, footer.height > 0 else {
            return nil
        }
        
        let footerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: footer.reuseIdentifier)
        
        return footerView
    }
    
    // MARK: Cells
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let viewModel = datasource(for: tableView)[safe: indexPath.section]?.items[safe: indexPath.row] else {
            return UITableViewCell()
        }
        
        let cell = tableView.dequeueReusableCell(withIdentifier: viewModel.reuseIdentifier, for: indexPath)
        
        return cell
    }
}

Important to note is that header/footer views and cells should be pre-registered to the tableview. For the most common ones, this is done by calling an extension function on the UITableView.

extension UITableView {
  func registerAll() {
    // Cells
    register(UINib(nibName: String(describing: SubtitleTableViewCell.self), bundle: nil), forCellReuseIdentifier: SubtitleTableViewCell.reuseIdentifier)
    register(UINib(nibName: String(describing: LoadingIndicatorTableViewCell.self), bundle: nil), forCellReuseIdentifier: LoadingIndicatorTableViewCell.reuseIdentifier)
    register(UINib(nibName: String(describing: InfoMessageTableViewCell.self), bundle: nil), forCellReuseIdentifier: InfoMessageTableViewCell.reuseIdentifier)
    register(UINib(nibName: String(describing: ErrorTableViewCell.self), bundle: nil), forCellReuseIdentifier: ErrorTableViewCell.reuseIdentifier)
    register(UINib(nibName: String(describing: ButtonTableViewCell.self), bundle: nil), forCellReuseIdentifier: ButtonTableViewCell.reuseIdentifier)
    register(UINib(nibName: String(describing: ImageTableViewCell.self), bundle: nil), forCellReuseIdentifier: ImageTableViewCell.reuseIdentifier)
    
    // Headers
    register(UINib(nibName: String(describing: SubtitleTableViewHeaderView.self), bundle: nil), forHeaderFooterViewReuseIdentifier: SubtitleTableViewHeaderView.reuseIdentifier)
    register(UINib(nibName: String(describing: InfoTableViewHeaderView.self), bundle: nil), forHeaderFooterViewReuseIdentifier: InfoTableViewHeaderView.reuseIdentifier)
    
    // Footers
    register(UINib(nibName: String(describing: InfoTableViewHeaderFooterView.self), bundle: nil), forHeaderFooterViewReuseIdentifier: InfoTableViewHeaderFooterView.reuseIdentifier)
  }
}

Custom cells and header/footer views can additionally be registered within specific view controllers. Calling this function will ensure the most-used and common ones are already registered and handled accordingly.

This ensures the conformity to the tableview’s datasource protocol. Next up is the implementation of the delegate protocol.

TableViewDelegate

The implementation of the delegate protocol should provide the tableview with the height and configuration for each component. This is done as follows:

extension TableViewController : UITableViewDelegate {
    
    // MARK: Headers
    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        return CGFloat(datasource(for: tableView)[safe: section]?.section?.height ?? 0)
    }
    
    func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
        
        if let header = view as? UITableViewHeaderFooterView {
            do {
                try header.configure(with: datasource(for: tableView)[safe: section]?.section)
            } catch {
                
            }
        }
    }
    
    // MARK: Footers
    func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        return CGFloat(datasource(for: tableView)[safe: section]?.footer?.height ?? 0)
    }
    
    func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) {
        if let footer = view as? UITableViewHeaderFooterView {
            do {
                try footer.configure(with: datasource(for: tableView)[safe: section]?.footer)
            } catch {
                
            }
        }
    }
    
    // MARK: Cells
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        let height = datasource(for: tableView)[safe: indexPath.section]?.items[safe: indexPath.row]?.height ?? 0
        return CGFloat(height)
    }
    
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        do {
            try cell.configure(with: datasource(for: tableView)[safe: indexPath.section]?.items[safe: indexPath.row])
        } catch {
            
        }
    }
}

Again, for the most common header/footer views and cells, we can provide a general configure function as an extension on the respective view subclasses. These will be throwing functions: if the general configure methods can’t configure the view, it will throw an error and custom configuration can be done in the catch block. The generalised functions look as follows:

extension UITableViewCell {
  func configure(with viewModel: TableViewItemViewModel?) throws {
    switch self {
    case is SubtitleTableViewCell:
      (self as! SubtitleTableViewCell).configure(with: viewModel as? SubtitleTableViewCellViewModel)
    case is InfoMessageTableViewCell:
      (self as! InfoMessageTableViewCell).configure(with: viewModel as? InfoMessageTableViewCellViewModel)
    case is ErrorTableViewCell:
      (self as! ErrorTableViewCell).configure(with: viewModel as? ErrorTableViewCellViewModel)
    case is LoadingIndicatorTableViewCell:
      (self as! LoadingIndicatorTableViewCell).willBecomeVisible()
    case is ButtonTableViewCell:
      (self as! ButtonTableViewCell).configure(with: viewModel as? ButtonTableViewCellViewModel)
    case is ImageTableViewCell:
      (self as! ImageTableViewCell).configure(with: viewModel as? ImageTableViewCellViewModel)
    default:
      throw TableViewConfigureError.cellNotRegistered
    }
  }
}

extension UITableViewHeaderFooterView {
  func configure(with viewModel: TableViewItemViewModel?) throws {
    switch self {
    case is InfoTableViewHeaderFooterView:
      (self as! InfoTableViewHeaderFooterView).configure(with: viewModel as? InfoTableViewHeaderFooterViewViewModel)
    case is SubtitleTableViewHeaderView:
      (self as! SubtitleTableViewHeaderView).configure(with: viewModel as? SubtitleHeaderViewViewModel)
    case is InfoTableViewHeaderView:
      (self as! InfoTableViewHeaderView).configure(with: viewModel as? InfoTableViewHeaderViewViewModel)
    default:
      throw TableViewConfigureError.headerFooterNotRegistered
    }
  }
}

What actually happens is that we check off the class of the view in the switch case. If the type matches, we try to configure it with its specific view model protocol; otherwise we throw an error.

Datasource composition

Now that we’ve seen the backbone of this easy tableview composition, the question is: how do we actually compose the datasource needed to build the UI? In this case, we created a number of structs that implement specific view configuration protocols for which it can be displayed. On the other side, we created a number of different header/footer views and cells, that can be configured with their own configuration protocol. I’ll give one simple example of both:

struct InfoCell {
    let message: String
    
    init(message: String) {
        self.message = message
    }
}

extension InfoCell : InfoMessageTableViewCellViewModel {
    var infoMessage: String {
        return message
    }
}

extension InfoCell : TableViewItemViewModel {
    var height: Double {
        return InfoMessageTableViewCell.standardHeight
    }
    var reuseIdentifier: String {
        return InfoMessageTableViewCell.reuseIdentifier
    }
    var action: Any?
}

And the UITableViewCell’s implementation:

protocol InfoMessageTableViewCellViewModel {
    var infoMessage: String { get }
}

class InfoMessageTableViewCell: UITableViewCell {
    static var standardHeight: Double {
        return 90.0
    }
    
    static var reuseIdentifier: String {
        return "infoMessage.cell"
    }
    
    @IBOutlet weak var messageLabel: UILabel!
    
    func configure(with viewModel: InfoMessageTableViewCellViewModel?) {
        messageLabel?.text = viewModel?.infoMessage
    }
}

So, an instance of InfoCell will be displayed as an InfoMessageTableViewCell in the tableview. It is the InfoCell instance that will be present in the sectioned datasource for the tableview composition.

The next code examples describe a possible sectioned datasource in 2 stages.

At the start, when a page is still loading its data, this datasource should suffice:

(Initialisation parameters are deliberately left out for clarity)

let datasource = [
            TableViewSectionMap(section: nil, items: [LoadingCell()], footer: nil)
        ]

Then, after finishing the load, the datasource can be swapped out by actual data:

let datasource = [
            TableViewSectionMap(section: nil, items: [ImageCell(), InfoCell()], footer: nil),
            TableViewSectionMap(section: Header(), items: [ImageCell(), PaymentTransaction(), PaymentTransaction(), PaymentTransaction()], footer: nil),
            TableViewSectionMap(section: nil, items: [InfoCell()], footer: nil)
        ]

Tapping a cell

In some cases, a cell should trigger an action like, for instance, a navigation. For this specific app, we chose to use an internal route navigation framework we developed. It is as simple as asking the URLRouter to open a routeUri. A routeUri is represented as a string. When the URLRouter has a route matching the routeUri, it will trigger the handler block for that route.

With this in mind, we can further complete the TableViewItemViewModel and UITableViewDelegate implementation. The action provided with a TableViewItemViewModel will simply be an optional routeUri (String).

protocol TableViewItemViewModel {
  var reuseIdentifier: String { get }
  var height: Double { get }
  var routeUri: RouteURI? { get }
}

The missing function in the UITableViewDelegate implementation will be the following:

extension TableViewController : UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let route = datasource(for: tableView)[safe: indexPath.section]?.items[safe: indexPath.row]?.routeUri {
            URLRouter.open(route)
        }
    }
}

Conclusion

Using this way of programming and this design pattern, it is very easy to compose different screens by just creating the datasource for the tableview. It makes changing states more easy. And it is also much faster to implement new sections because the heavy lifting, in terms of creating visual representation, has already been done.

What are your experiences with MVVM in Swift? Or do you have suggestions or remarks to improve our approach? Let us know via Twitter @novemberfiveco!