irskep, steveasleep.com, slam jamsen, diordna, etc

Three UIKit Protips

There are three patterns I use in most of my UIKit projects that I've never seen anyone else talk about. I think they help readability a lot, so I'm sharing them here:

  1. An addSubviews method to define your view hierarchy all at once
  2. An @AssignedOnce property wrapper
  3. A pattern for keeping view creation at the bottom of a file to keep the top clean

addSubviews

I've seen a lot of view and view controller code that looks like this:

override func loadView() {
  view = UIView()
  let scrollView = UIScrollView()
  view.addSubview(scrollView)
  let contentView = MyContentView()
  scrollView.addSubview(contentView)
  let topLabel = UILabel()
  let button = UIButton()
  contentView.addSubview(topLabel)
  contentView.addSubview(button)
}

This style of setup is straightforward, but I usually have a difficult time understanding the view hierarchy without spending more time than I'd like.

In most of my projects, I use this simple extension to help with this problem:

extension UIView {
  func addSubviews(_ subviews: UIView...) -> UIView {
    subviews.forEach { self.addSubview($0) }
    return self
  }
}

Now, it's possible for the calls to addSubviews() to visually resemble the view hierarchy!

override func loadView() {
  view = UIView()
  let scrollView = UIScrollView()
  let contentView = MyContentView()
  let topLabel = UILabel()
  let button = UIButton()
  
  view.addSubviews(
    scrollView.addSubviews(
      contentView.addSubviews(
        topLabel,
        bottomLabel)))
}

You can also use this pattern in UIView initializers.

@AssignedOnce

When using storyboards, you commonly need to use force-unwrapped optional vars to keep references to views and other things. fine, but there is no compile-time guarantee that the property can't be overwritten. Kotlin solves this problem with the lateinit keyword, but Swift has no equivalent.

You can at least prevent multiple writes to vars at runtime by using this simple property wrapper, which throws an assertion failure in debug builds if you write to the property more than once. It's not as good as a compile-time guarantee, but it does double as inline documentation.

@propertyWrapper
public struct AssignedOnce<T> {
  #if DEBUG
    private var hasBeenAssignedNotNil = false
  #endif

  public private(set) var value: T!

  public var wrappedValue: T! {
    get { value }
    
    // Normally you don't want to be running a bunch of extra code when storing values, but
    // since you should only be doing it one time, it's not so bad.
    set {
      #if DEBUG
        assert(!hasBeenAssignedNotNil)
        if newValue != nil {
          hasBeenAssignedNotNil = true
        }
      #endif

      value = newValue
    }
  }

  public init(wrappedValue initialValue: T?) {
    wrappedValue = initialValue
  }
}

In practice, you can just add @AssignedOnce in front of any properties you want to prevent multiple assignment to:

class MyViewController: UIViewController {
  @AssignedOnce var button: UIButton! // assigned by storyboard
  @AssignedOnce var label: UILabel! // assigned by storyboard
}

Looks pretty nice, right?

View Factories

The most critical part of any source code file is the first hundred lines. If you're browsing through code, it really helps to not have to scroll very much to see what's going on.

Unfortunately, it's very easy to gum up the top of a view view controller file by creating subviews over multiple lines, especially if (like me) you don't use storyboards at all. Here's what I mean:

class MyViewController: UIViewController: UITableViewDataSource, UITableViewDelegate {
  // I'm declaring all these as FUO `var`s instead of `let`s
  // so I can instantiate them in loadView().
  @AssignedOnce private var headerLabel: UILabel!
  @AssignedOnce private var tableView: UITableView!
  @AssignedOnce private var continueButton: UIButton!
  
  override func loadView() {
    view = UIView()
    
    headerLabel = UILabel()
    headerLabel.text = NSLocalizedString("List of things:", comment: "")
    headerLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle)
    headerLabel.textAlignment = .center
    headerLabel.textColor = UIColor.systemBlue
    
    continueButton = UIButton()
    continueButton.setTitle(NSLocalizedString("Continue", comment: ""), for: .normal)
    continueButton.addTarget(self, action: #selector(continueAction), for: .touchUpInside)
    
    tableView = UITableView()
    tableView.dataSource = self
    tableView.delegate = self
    // more semi-arbitrary table view configuration
    tableView.separatorStyle = .none
    tableView.showsVerticalScrollIndicator = false
    
    view.am_addSubviews(
      headerLabel,
      tableView,
      continueButton)
      
    // ... add constraints ...
  }
  
  // MARK: Actions
  
  @objc private func continueAction() {
    dismiss(animated: true, completion: nil)
  }
  
  // MARK: UITableViewDataSource
  
  /* ... */
  
  // MARK: UITableViewDelegate
  
  /* ... */
}

This is OK, but do you really need to know the implementation details of all the views so near the top of the file? In my experience, those parts of the code are written once and then never touched again.

Additionally, it's not great to use force-unwrapped optionals to store anything. But if we use let instead, then all views will be created at init time instead of in loadView().

View factories

We can solve a lot of problems by moving all view creation to the bottom of the file and using lazy var.

class MyViewController: UIViewController: UITableViewDataSource, UITableViewDelegate {
  private lazy var headerLabel = makeHeaderLabel()
  private lazy var tableView = makeTableView()
  private lazy var continueButton = makeContinueButton()
  
  override func loadView() {
    view = UIView()
    
    view.am_addSubviews(
      headerLabel,
      tableView,
      continueButton)
      
    // ... add constraints ...
  }
  
  // MARK: Actions
  
  @objc private func continueAction() {
    dismiss(animated: true, completion: nil)
  }
  
  // MARK: UITableViewDataSource
  
  /* ... */
  
  // MARK: UITableViewDelegate
  
  /* ... */
  
  // MARK: View factories
  
  private func makeHeaderLabel() -> UILabel {
    let headerLabel = UILabel()
    headerLabel.text = NSLocalizedString("List of things:", comment: "")
    headerLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle)
    headerLabel.textAlignment = .center
    headerLabel.textColor = UIColor.systemBlue
    return headerLabel
  }
  
  private func makeTableView() -> UITableView {
    let tableView = UITableView()
    tableView.dataSource = self
    tableView.delegate = self
    // more semi-arbitrary table view configuration
    tableView.separatorStyle = .none
    tableView.showsVerticalScrollIndicator = false
    return tableView
  }
  
  private func makeContinueButton() -> UIButton {
    let continueButton = UIButton()
    continueButton.setTitle(NSLocalizedString("Continue", comment: ""), for: .normal)
    // `self` is available inside `lazy var` method calls!
    continueButton.addTarget(self, action: #selector(continueAction), for: .touchUpInside)
    return continueBUtton
  }
}

The main advantage of this approach is that rarely-touched view creation code is both in a predictable place, and completely out of the way if you're browsing lots of files quickly. A bonus is that FUOs are not necessary due to the use of lazy var. And the factory method return types enable you to remove the explicit types from the property declarations.

That's all, thanks for reading!