One of the most common problems I see in iOS codebases is the creation of view hierarchies inside view controllers.

When using MVC we often complain about the massive view controllers, most of the time the reason we have these massive view controllers is because of the default patterns we use to code them. Then as a patch solution, people tend to get creative with architecture acronyms to fix this, but we can do better with MVC by having better default coding patterns.

Within the SATS iOS app we have transitioned to the approach I will share in this post: creating UIView classes that contain view hierarchies. We support iOS 12, so we use UIKit a lot still, but the concepts apply really to any framework. Before explaining what I mean about this approach, let’s see the common issues present in most view controllers.

Massive View Controllers

The development of UIs using UIKit has been divided historically by either: creating views in code or using storyboards (or .xibs).

Personally, I think storyboards/xibs are a bad idea because:

  • changes are very difficult to understand when looking at a pull request in GitHub
  • merge conflicts happen often and are a pain to deal with
  • they present a half reality as the view may have one configuration that the view controller changes
  • etc

Still, there are also problems when writing the UI in code.

Typically a UIViewController subclass has a lot of different concerns: creating a view hierarchy, networking, tracking, navigation to other parts of the application, etc. As iOS developers we tend to create hierarchies within the view controller as commonly seen in Apple’s Sample Code.

class ProfileViewController: UIViewController {
  let nameLabel = UILabel()
  let profileImageView = UIImageView()
  let followerCountLabel = UILabel()
  let activityIndicator = UIActivityIndicatorView()
  let errorMessageLabel = UILabel()
  // ... a bunch of other subviews

  override func viewDidLoad() {
    nameLabel.textColor = .black
    profileImageView.aspectRatio = .scaleAspectFill
    //.. and a lot more configurations

    view.addSubview(nameLabel)
    view.addSubview(profileImageView)
    view.addSubview(errorMessageLabel)
    view.addSubview(activityIndicator)
    // setting the hierachy...

    NSLayoutConstraint.activate([
    // ... a lot constraint definitions
    ])
  }

  // Somewhere in this file we performed a web request and call
  // this method as callback
  func onDataLoaded() {
    activityIndicator.stopAnimating()
    activityIndicator.isHidden = true

    if dataSuccessfullyLoaed {
      nameLabel.text = data.name
      followerCountLabel.text = "\(data.followerCount)"

      if data.isPremiumUser {
        nameLabel.updateStyleForPremiumUser()
      }

      errorMessageLabel.isHidden = true
    } else if let error = error {
      errorMessageLabel.isHidden = false
      nameLabel.isHidden = true

      errorMessageLabel.text = error.localizedDescription
    }
  }
}

Then again, this is only counting with view hierarchy boiler plate. There are always more code associated with doing the web request to populate the data in those views. Things get even more complicated when we think about the possible states and combinations the views can be in. In the case of a profile, you can have “normal” users and “premium” ones, so the resulting UI may be quite different for both cases.

One of the biggest problems I see with a code like the example above is: it’s very difficult to understand by reading the code the different states of the UI. When making changes to the code, we may not notice that we break one of the multiple combinations on how this screen is used.

It’s quite difficult to iterate in this UI as the view controller has multiple concerns like:

  • making requests and handle responses
  • handling loading/errors
  • tracking
  • navigation to other view controllers
  • use system capabilities
  • decide which views to show and hide depending on the state
  • gesture logic
  • etc

These concerns make it more difficult to instantiate the view controller/view combination on isolation as there is a bunch of dependencies that would be needed to be controlled with mocks and techniques like that.

A view as a “function” of state

The way we build UI at SATS consists of the following rules:

  1. Views define ViewData structs that contains all the info the view needs. We call them ViewData to differentiate to the commonly used ViewModel name as this name can mean a lot of different things to different people.
  2. Views don’t hold state, they just get passed a ViewData object and render accordingly.
  3. Views use the delegate pattern to communicate events to a controller or a view model to handle.

One point I want to clarify a bit more: views don’t hold state, this applies for data loading state if the view is designed for a “loaded” state. The same applies regarding business logic, is the user “premium” or not? the view doesn’t take the decision, it just gets informed of it. The main exception to this rule is related to “view state”, that would be relevant for gestures, where the view locally holds some kind of state just for the purposes of rendering the gesture.

A given view we create would look like:

import UIKit

// NOTE: A
struct ProfileViewData {
  let name: String
  let bio: String
  let isPremiumUser: Bool
}

class ProfileView: UIView {

  // MARK: Subviews

  lazy var profileImageView: UIImageView = { ... }()

  lazy var nameLabel: UILabel = { ... }()

  lazy var bioLabel: UILabel = { ... }()

  lazy var premiumUserBadge: BadgeView = { ... }()

  // MARK: Initializers

  override init(frame: CGRect) {
      super.init(frame: frame)
      setup()
  }

  // MARK: Internal methods

  // NOTE: B
  func configure(with viewData: ProfileViewData) {
    nameLabel.text = viewData.name
    bioLabel.text = viewData.bio
    premiumUserBadge.isHidden = !viewData.isPremiumUser
  }

  // NOTE: C
  func set(profileImage: UIImage?) {
    profileImageView.image = profileImage
  }

  // MARK: Private methods

  // NOTE: D
  private func setup() {
    addSubview(profileImageView)
    addSubview(nameLabel)
    addSubview(bioLabel)
    addSubview(premiumUserBadge)

    NSLayoutConstraint.activate([
      // ...
    ])
  }
}

That’s basically it. I will explain some of the NOTE comments in the code:

  • NOTE A: It doesn’t matter how the data is structured in the backend, how nested/complex it may be, to render this view we just need the elements present in ProfileViewData, nothing more. Certain types like Date, can be passed as just String into the ViewData structs already formatted, then these structs are quite simple and easy to create for tests.

    This also means the process of mapping Data Transfer Objects, model objects is like a pure function process, then it’s highly testable. We are also decoupling our internal model data representation with the UI.

  • NOTE B: We are using the view data objects to populate the view and just discarding them. The configure(with: data) method is the main entry-point to configure our view, ideally the only one.

  • NOTE C: Usually to get the profile data we may do a web request, which contains a profileImageUrl that will trigger a second web request. Instead of passing a URL to the view, we can expose an specialized entry point in set(profileImage:) to do this, in this way the images are state-less for what the view cares about. This also makes using test images quite easy.

  • NOTE D: This view in particular doesn’t have a notion of loading state, and it does not need to, then the internal hierarchy is simpler and it’s easier to reason about the end result and states of the view. We also don’t have a mix of subviews related to loading or error states, which are usually entangled in typical UIViewController implementations.

Testability

This approach of writing UI doesn’t require dependencies or a fancy architecture. The main point is to be able to independently instantiate and configure the views, which helps speeding up development with Xcode Previews.

One of the benefits of this separation of concerns is the fact that you can more easily test a UI and in different setups. We recently started using Pointfree.co’s SnapshotTesting library to create snapshot test of a given piece of UI, then we are aware if we unintentionally change how this UI renders.

A sample of this would look like:

import XCTest
import SnapshotTesting
@testable import SATS

class VoucherViewTests: XCTestCase {
  func testCompactBarcodeVoucher() {
    let voucherView = createVoucherView(for: .compactBarcode)

    assertSnapshot(matching: voucherView, as: .image, named: "Light")
    assertSnapshot(matching: voucherView, as: .image(precision: 0.5, traits: .darkMode), named: "Dark")
  }

  func testCompactNormalVoucher() {
    let voucherView = createVoucherView(for: .compactText)

    assertSnapshot(matching: voucherView, as: .image, named: "Light")
    assertSnapshot(matching: voucherView, as: .image(precision: 0.5, traits: .darkMode), named: "Dark")
  }

  func testRegularBarcodeVoucher() {
    let voucherView = createVoucherView(for: .regularBarcode, size: CGSize(width: 350, height: 350))

    assertSnapshot(matching: voucherView, as: .image, named: "Light")
    assertSnapshot(matching: voucherView, as: .image(precision: 0.5, traits: .darkMode), named: "Dark")
  }

  func testRegularNormalVoucher() {
    let voucherView = createVoucherView(for: .regularText, size: CGSize(width: 350, height: 350))

    assertSnapshot(matching: voucherView, as: .image, named: "Light")
    assertSnapshot(matching: voucherView, as: .image(precision: 0.5, traits: .darkMode), named: "Dark")
  }
}

This code will test the view with different types of data in different rendering configurations, both in light and dark mode, so we can ensure the UI keeps working if we have any changes.

The reference images look like:

Sample reference images for snapshot test
Sample reference images for snapshot test as collage, the code doesn't work for a discount

These images live in the repo along the code, if later the UI changes the test will fail so we are aware of the change.

Summary

I think UI should be built in isolation and without state, so it’s easier to iterate building it, specific to the business needs. Then you get the advantage of not mixing subtrees of views for unrelated states.

UIViewControllers tend to have so many different concerns, so it’s nice to abstract the views from them, also, testing UI in different device configurations: iPad, iPhone 12 mini, iPhone 8; dark and light modes and different accessibility settings contribute to more robust UI overall.

Finally, this approach does not require a complete refactor of your app, nor extra dependencies and it can work with almost any architecture under the sun.