Using Interface Builder at Lyft

Last week people realized that Xcode 8.3 by default uses storyboards in new projects without a checkbox to turn this off. This of course sparked the Interface Builder vs. programmatic UI discussion again, so I wanted to give some insight in our experience using Interface Builder in building the Lyft app. This is not intended as hard "you should also use Interface Builder" advice, but rather to show that IB can work at a larger scale.

First, some stats about the Lyft app:

With the rewrite of our app we moved to using IB for about 95% of our UI.

The #1 complaint about using Interface Builder for a project with more than 1 developer is that it's impossible to resolve merge conflicts. We never have this problem. Everybody on the team can attest that they have never run into major conflicts they couldn't reasonably resolve.

With that concern out of the way, what about some of the other common criticisms Interface Builder regularly gets?

Improving the workflow

Out of the box, IB has a number of shortcomings that could make working with it more painful than it needs to be. For example, referencing IB objects from code still can only be done with string identifiers. There is also no easy way to embed custom views (designed in IB) in other custom views.

Over time we have improved the workflow for our developers to mitigate some of these shortcomings, either by writing some tools or by writing a little bit of code that can be used project-wide.

storyboarder script

To solve the issue of stringly-typed view controller identifiers, we wrote a script that, just before compiling the app, generates a struct with static properties that exposes all view controllers from the app in a strongly-typed manner. This means that now we can instantiate a view controller in code like this:

1
let viewController = Onboarding.SignUp.instantiate()

Not only is viewController now guaranteed to be there at runtime (if something is wrong in the setup of IB the code won't even compile), but it's also recognized as a SignUpViewController and not a generic UIViewController.

Strongly-typed segues

All our view controllers have a base view controller named ViewController. This base controller implements prepare(for:sender:) like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
open override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard let identifier = segue.identifier else {
        return
    }

    let segueName = identifier.firstLetterUpperString()
    let selector = Selector("prepareFor\(segueName):sender:")
    if self.responds(to: selector) {
        self.perform(selector, with: segue.destination, with: sender)
    }
}

This means that a view controller that has a segue to TermsOfServiceViewController can now do this:

1
2
3
4
@objc
private func prepareForTermsOfService(_ viewController: TermsOfServiceViewController, sender: Any?) {
    viewController.onTermsAccepted = { [weak self] self?.proceed() }
}

We no longer have to implement prepareForSegue and then switch on the segue's identifier or destination controller, but we can implement a separate method for every segue from this view controller instead which makes the code much more readable.

NibView

We wrote a NibView class to make it more convenient to embed custom views in other views from IB. We marked this class with @IBDesignable so that it knows to render itself in IB. All we have to do is drag out a regular UIView from the object library and change its class. If there is a XIB with the same name as the class, NibView will automatically instantiate it and render it in the canvas at design time and on screen at runtime.

Every standalone view we design in IB (which effectively means every view in our app) inherits from NibView so we can have an "unlimited" number of nested views show up and see the final result.

Basic @IBDesignables

Since a lot of our views have corner radii and borders, we have created this UIView extension:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public extension UIView {
    @IBInspectable public var cornerRadius: CGFloat {
        get { return self.layer.cornerRadius }
        set { self.layer.cornerRadius = newValue }
    }

    @IBInspectable public var borderWidth: CGFloat {
        get { return self.layer.borderWidth }
        set { self.layer.borderWidth = newValue }
    }

    @IBInspectable public var borderColor: UIColor {
        get { return UIColor(cgColor: self.layer.borderColor!) }
        set { self.layer.borderColor = newValue.cgColor }
    }
}

This lets us easily set these properties on any view (including the ones from UIKit) from Interface Builder.

Linter

We wrote a linter to make sure views are not misplaced, have accessibility labels, trait variations are disabled (since we only officially support portrait mode on iPhone), etc.

ibunfuck

A bug impacting developers that use Interface Builder on both Retina and non-Retina screens (which at Lyft is every developer) has caused us enough grief to write ibunfuck - a tool to remove unwanted changes from IB files.

Color palette

We created a custom color palette with the commonly used colors in our app so it's easy to select these colors when building a new UI. The color names in the palette follow the same names designers use when they give us new designs, so it's easy to refer to and use without having to copy RGB or hex values.

Our approach

In addition to these tools and project-level improvements, we have a number of "rules" around our use of IB to keep things sane:

Of course, even with these improvements everything is not peaches and cream. There are definitely still problems. New versions of Xcode often change the XML representation which leads to a noisy diff. Some properties can simply not be set in IB meaning we're forced to break our "do everything in IB" rule. Interface Builder has bugs we can't always work around.

However, with our improved infrastructure and the points from above, we are happy with how IB works for us. We don't have to write tons of Auto Layout code (which would be incredibly painful due to the nature of our UIs), get a visual representation of how a view looks without having to run the app after every minor change, and maybe one day we can get our designers make changes to our UI without developers' help.