Copied to clipboard
series
Series
Insights

Creating dynamic screens with protocol-oriented MVVM in Swift

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

17/7/2023
9
min read

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:

  1. Number of sections
  2. Number of cells within a section
  3. Height for each section or cell
  4. A reuse identifier to dequeue the view for the respective section or cell
  5. 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:


{% c-block language="js" %}
protocol TableViewItemViewModel {
var reuseIdentifier: String { get }
var height: Double { get }
var action: Any? { get
}
{% c-block-end %}

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:


{% c-block language="js" %}
struct TableViewSectionMap {
  let section: TableViewItemViewModel?
  let items: [TableViewItemViewModel]
  let footer: TableViewItemViewModel?
}
{% c-block-end %}

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:


{% c-block language="js" %}
var datasource: [TableViewSectionMap]
{% c-block-end %}

TableViewDataSource

{% c-block language="js" %}
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
    }
}
{% c-block-end %}

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.


{% c-block language="js" %}
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)
  }
}
{% c-block-end %}

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:


{% c-block language="js" %}
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 { 
        }
    }
}
{% c-block-end %}

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:


{% c-block language="js" %}
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
    }
  }
}
{% c-block-end %}

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:


{% c-block language="js" %}
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?
}
{% c-block-end %}

And the UITableViewCell’s implementation:


{% c-block language="js" %}
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
    }
}
{% c-block-end %}

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)



{% c-block language="js" %}
let datasource = [
            TableViewSectionMap(section: nil, items: [LoadingCell()], footer: nil)
        ]
{% c-block-end %}

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


{% c-block language="js" %}
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)
        ]
{% c-block-end %}

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).


{% c-block language="js" %}
protocol TableViewItemViewModel {
  var reuseIdentifier: String { get }
  var height: Double { get }
  var routeUri: RouteURI? { get }
}
{% c-block-end %}

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


{% c-block language="js" %}
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)
        }
    }
}
{% c-block-end %}

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.

Jens Reynders

Engineer

Jens Reynders

Engineer

17/7/2023
9
min read

We help companies
succeed in the digital age

Stay up-to-date with November Five

Follow us on LinkedIn for insights, learnings, use cases and more.

Looking for a partner that thinks beyond delivery?

We help companies
succeed in the digital age

Let’s get to know each other better and explore how we can help your business embark on a journey towards digitally enabled success.

CONTACT US
Series
DISCOVER MORE INSIGHTS
Chevron
About Fast Company’s ‘Best Workplace for Innovators’

November Five was named one of Fast Company’s global 100 Best Workplaces for Innovators in both 2020 and 2021. This annual list, developed in collaboration with Accenture, recognises and honors the top 100 businesses from different industries that inspire, support and promote innovation at all levels. For the consecutive year, November Five was the single Belgian workplace listed.

Fast Company is the world's leading progressive business media brand, with a unique editorial focus on innovation in technology, ethical economics, leadership, and design. Written for, by, and about the most progressive business leaders, Fast Company and FastCompany.com inspire readers and users to think beyond traditional boundaries, lead conversations and create the future of business.

Jeroen Van Winckel

Product Strategy Designer

Ralph Van Tongelen

Finance Director

Office

Office

Dario Prskalo

Associate to the executive team

Brecht Spileers

Chief of Staff & Director Corporate Strategy

Emily Stewart

Senior Content Writer

Rindert Dalstra

Brand & Marketing Director

Robin Van den Bergh

Managing Director at Appmiral

Maarten Raemdonck

Co-founder & Managing Director at Spencer

Phillip Vandervoort

Executive advisor - Strategy

Vincent Bruyneel

CFO & COO

David Du Pré

Executive advisor

Marc Wojciechowski

Assistant Director

Muriel Mwema

Director Product Management & Delivery

Nick Verbaendert

Co-Founder & Director Business Operations

David De Bels

Product Owner at Appmiral

Tom Vroemans

Co-founder & CEO

Veronique Verhees

Talent Manager

Jens Reynders

Engineer

Michiel Van Nueten

UI Designer

Samuel De Pooter

Engineer

Bert Hofmans

UI Designer

Stijn Symons

Director Architecture

Vincent Pauwels

Co-founder & Director Experience Design

Thomas Van Sundert

Co-founder & Director Engineering

Justin Mol

Director Client Partnerships

Leslie De Cuyper

Client Partner

Ruben Van den Bossche

Chief Operating Officer

Nikki Jacobs

Managing Director at The Market