Thinking > story
What Do We Say to Storyboards? "Not today."

For iOS native UI development, there are currently two main technologies pushed by Apple: Interface Builder and Auto Layout. Interface Builder is a drag-and-drop GUI builder that can create .storyboard and .xib files to define UI, make connections to variables, and set up Auto Layout constraints for the UI. I will analyze the problems Interface Builder brings and how using just Auto Layout programmatically leads to a more efficient work flow. I will not be exploring other third party frameworks like ReactNative or Flutter as this post is more concerned about staying closer to Apple's technology. For brevity I will refer to .storyboards and .xibs as just storyboards as they are conceptually similar.

Storyboards are great for visualization and developing quick prototypes. The problem is they’re more like training wheels for learning how to build UI as it becomes apparent that Storyboards have their own problems and force hidden rules on developers. Once the developer understands how to build UI, Storyboards and xibs should be abandoned as they become super massive black holes in large projects.

Git Conflicts and Nonsense Errors

The number one complaint about Storyboards is Git conflicts. The way the iOS developer community handles team-based development using storyboards is to avoid any parallel development on storyboards. This makes storyboards a bottleneck in larger development efforts. These files are xml based and no one wants to read them. Reviews are likely to involve mainly skimming through them if anything at all. Here's an example conflict where I reordered buttons in one branch and changed the font and color of the same buttons in another branch. Keep in mind that real projects have even more changes and even bigger conflicts.

As text: example-conflict

In merge tool: mergetool-conflict

This kind of conflict has to be resolved manually as both changes are needed. Mergetool will actually duplicate the buttons so this obviously shouldn't be done.

Be careful with XML: resolution-error

Of course I made a mistake when resolving the conflict.

Does this look familiar: frame-shifts This is an example where a view position or size may inexplicably be displaced by 0.5 or some small number. Fixing this is annoying even though it just involves using the update frames functionality in Interface Builder. A less experienced developer may waste time thinking there's something wrong with the constraints. The Storyboard metadata may also change when viewing a Storyboard. These are all avoidable and pointless distractions.

Double the Work

When conditional logic and reusable styling is needed, UI solutions becomes fragmented, brittle, and incredibly cumbersome to develop with Storyboards.

Here's how styling is commonly done:

// The simplest way to style
struct Theme {
    static let confirmButtonTitleColor: UIColor = .white
    static let confirmButtonBackgroundColor: UIColor = .gray
    static let confirmButtonTitleFont: UIFont = UIFont.boldSystemFont(ofSize: 16)
}

// In a view or view controller implementation
override func awakeFromNib() {
    super.awakeFromNib()

    confirmButton.titleLabel?.font = Theme.confirmButtonTitleFont
    confirmButton.setTitleColor(Theme.confirmButtonTitleColor, for: .normal)
    confirmButton.backgroundColor = Theme.confirmButtonBackgroundColor
}

This does not scale well as every occurrence of this same confirm button needs to be styled in an awakeFromNib override. It addition, the developer needs to style this in IB as well to have a proper preview in IB. It's common to accidentally miss a few details when doing these kinds of styling all day. If we want to change the styling, we now need to change the styling constants, and also update the styles in IB. If we want to add more styling, we now have to search for all occurences of this styling.

Can we improve on this? Of course, we can refactor:

// A reusable way to style in Storyboards and xibs.
// Storyboards made me do it this way.
protocol Themable: UIAppearance {
    func applyStyling(with theme: Theme)
}

class Theme: NSObject {
    let confirmButtonTitleColor: UIColor = .white
    let confirmButtonBackgroundColor: UIColor = .gray
    let confirmButtonTitleFont: UIFont = UIFont.boldSystemFont(ofSize: 16)
}

class RectangleConfirmButton: UIButton, Themable {
    @objc dynamic func applyStyling(with theme: Theme) {
        titleLabel?.font = theme.confirmButtonTitleFont
        setTitleColor(theme.confirmButtonTitleColor, for: .normal)
        backgroundColor = theme.confirmButtonBackgroundColor
    }
}

// In AppDelegate
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    let theme = Theme()
    let stylingProxies: [Themable] = [RectangleConfirmButton.appearance()]
    stylingProxies.forEach { $0.applyStyling(with: theme) }

    return true
}

This proxy styling can be called somewhere along application(application:didFinishLaunchingWithOptions:) (see AppDelegate). We can now do all the styling once and just change the class of the UI element within IB to get our styling automatically for free. Storyboards aren't really OOP, but we can use indirect means for reusability instead. The problem is this makes it harder to discover how styling is done since it's done way in the app launch sequence instead of in views or view controllers. If there's no onboarding for developers this will lead to confusion.

Or we can put styling into the class itself:

// What's that smell?
class RectangleConfirmButton: UIButton {
    func applyStyling(with theme: Theme) {
        titleLabel?.font = theme.confirmButtonTitleFont
        setTitleColor(theme.confirmButtonTitleColor, for: .normal)
        backgroundColor = theme.confirmButtonBackgroundColor
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    func commonInit() {
        // Reject?
        applyStyling(with: Theme())
    }
}

If we want our styling to also work in IB, we can use IBDesignable. This ensures that our stlying in IB and code is the same without having to use IB to style anything, but now we need an extra method. The drawback is it's not obvious that IB isn't doing the styling anymore.

@IBDesignable
class RectangleConfirmButton: UIButton, Themable {
    @objc dynamic func applyStyling(with theme: Theme) {
        titleLabel?.font = theme.confirmButtonTitleFont
        setTitleColor(theme.confirmButtonTitleColor, for: .normal)
        backgroundColor = theme.confirmButtonBackgroundColor
    }
    
    override func prepareForInterfaceBuilder() {
        applyStyling(with: Theme())
    }
}

By now it's starting to look like it's much easier to style things in code than in IB. Styles can use constants and code is just much easier to write for styling many things. In IB, we can't access the same constants and styling requires clicking around in the GUI for each element to be styled which is very time consuming, or use IBDesignable which can be pretty slow. Furthermore, addition and removal of elements requires changes in both the code and IB. Failure to do so will result in crashes or features not working and time is wasted on debugging.

Reordering elements is pretty difficult to do in IB because view hierarchies may be pretty complicated and multiple constraints need to be removed and added. Stack views may solve this problem but stack views have their own issues too. Views may suddenly disappear at runtime. Stack views sometimes forces developers to make many container views because the alignment needs some variation. Sometimes developers also place dummy views for spacing. Unnecessary views are never good. Stack views also seem to visibly layout slower than manually constrained views in performance demanding apps.

Segues are an Anti Pattern

Segues are how transitions between view controllers in Storyboard are defined. They're basically connections between two view controllers and can be triggered by some UI element or called programmatically with its identifier. In practice, this actually makes it harder to reason about the flow.

// An example view controller implementation performing a transition with the segue API

func didSubmit(nextModel: NextModel) {
    self.nextModel = nextModel
    
    // What does this segue do?  Where is it defined? ¯\_(ツ)_/¯
    // I have to search for this in the entire project to figure that out.  
    // It'll be tucked away in some massive Storyboard file probably.
    performSegue(withIdentifier: SegueConstants.submitId, sender: self)
}
    
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segue.identifier {
    case SegueConstants.submitId:
        // Force casting is generally frowned upon.  Crash?  Should I use `guard let`?
        let vc = segue.destination as! NextViewController
        vc.setup(with: nextModel)    
        
    // many other cases perhaps?
    
    default:
        return
    }
}

This view controller is now tightly coupled with NextViewController. This will lead to a massive view controller. It's possible to use a declarative approach and inject blocks instead of putting the transitions in these function overrides, but it makes the view controller require two blocks to perform one transition. There's this awkward need to store this nextModel before transitioning to the next view controller so that NextViewController can be set up properly. It's not obvious what kind of transition this is until we figure out where it's defined. The same segue identifier must be entered into the relevant storyboard and also saved as a constant.

This is what storyboards start to look like with more screens: storyboard-segues

Large apps will have many branching flows and can make Storyboards really complicated. It also locks out an entire network of view controllers in the same Storyboard from being edited since multiple developers aren't supposed to edit the same Storyboard. We now have massive storyboards. Some teams eventually decide to only put one view controller per storyboard.

UI as Code

We saw that styling is more flexibile and easier to do in code than in Storyboards, but now there's two files to manage: Storyboards for layout, and code for styling. Why not take the idea all the way and also do layout and view creation in code?

The next solution is coding with Layout Anchors. The good news is styling and layout code are separate so making the same changes as the above Storyboard example will not produce any Git conflicts. The bad news is Layout Anchor based code is very verbose, hard to skim through, and pretty error prone to develop with. The work flow is pretty much think about which anchor of a UI element is connected to which anchor of another UI element. When doing many of these, it starts to become tempting to copy and paste these anchor connections, but that's when mistakes are made. It's easy to accidentally forget to change a variable name which results in connecting the wrong anchors.

Here's an example with a layout bug in it:

// Can you spot the bug?
createAccountButton.topAnchor.constraint(equalTo: secondaryButtonGuide.topAnchor),
createAccountButton.bottomAnchor.constraint(equalTo: secondaryButtonGuide.bottomAnchor),
createAccountButton.leftAnchor.constraint(equalTo: secondaryButtonGuide.leftAnchor),
dividerLabel.topAnchor.constraint(equalTo: secondaryButtonGuide.topAnchor),
dividerLabel.bottomAnchor.constraint(equalTo: secondaryButtonGuide.bottomAnchor),
dividerLabel.leftAnchor.constraint(equalTo: createAccountButton.rightAnchor, constant: 8),
forgotPasswordButton.leftAnchor.constraint(equalTo: dividerLabel.rightAnchor, constant: 8),
forgotPasswordButton.topAnchor.constraint(equalTo: secondaryButtonGuide.topAnchor),
forgotPasswordButton.bottomAnchor.constraint(equalTo: secondaryButtonGuide.topAnchor),
forgotPasswordButton.rightAnchor.constraint(equalTo: secondaryButtonGuide.rightAnchor)

The bug is:

forgotPasswordButton.bottomAnchor.constraint(equalTo: secondaryButtonGuide.topAnchor)

The bottom anchor is connected to the top of the guide, so this results in conflicting constraints. I had a similar bug before when doing this kind of layout and lost a lot of time grieving and debugging. Keep in mind that the above is only part of an entire layout.

This may not seem that much better than Storyboards since it's so verbose and it brings its own type of problems. It's obvious that layout code needs to be abstracted and easier to read and write.

The problems of using Anchor Layout for making layouts is already being addressed with differing levels of success by the open source community. I'm also making my own solution.

Introducing StraitJacket

StraitJacket is an Auto Layout solution that I designed to create a more efficient workflow for code based layout. It takes inpiration from Visual Format Language but has a different philosophy behind it for a more effective work flow:

  • Skimmable
  • Strongly typed
  • One idea per line
  • Many constraints per line
  • Can make references to underlying constraints
  • Constraint collections are programmatically switchable
  • Robust against mistakenly expressing unintended constraints

As mentioned earlier, anchor based code is very verbose, hard to read through, and error prone. Anchors require quite a bit of thinking which slows down the work flow. StraitJacket is designed to be concise, highly abstracted, and make incorrect connections impossible or difficult to do. Here's an example of what the previous anchor based layout looks like in StraitJacket:

let aRestraint = Restraint(self.view)
    .chainHorizontally([forgotPasswordButton, dividerLabel, createAccountButton], in: secondaryButtonGuide)

This layout code has 10X less lines and 6X less characters! The bug from the Anchor Layout solution can't be recreated using this method. The coding style of StraitJacket is very uniform and concise as the above example. It's obvious what kind of advantages this style has over anchor layout. Its design philosophies produces very concise yet expressive code.

There's many things going on in the oneliner StraitJacket layout above. Firstly, it's connecting the given views left to right together via their anchors with default spacing. Then it aligns the top and bottom of each view to the secondaryButtonGuide. Lastly the first and last views are connected to the left and the right side of secondaryButtonGuide respectively.

Here's a diff of the same scenario that produced the Storyboard conflict example above, but implemented in StraitJacket:

@@ -46,7 +46,8 @@ class MyViewController : UIViewController {
     lazy var createAccountButton: UIButton = {
         let aButton = UIButton(type: .custom)
         aButton.setTitle("Create Account", for: .normal)
-        aButton.setTitleColor(.blue, for: .normal)
+        aButton.setTitleColor(.red, for: .normal)
+        aButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 15)
         aButton.sizeToFit()
         
         return aButton
         
@@ -65,7 +66,8 @@ class MyViewController : UIViewController {
     lazy var forgotPasswordButton: UIButton = {
         let aButton = UIButton(type: .custom)
         aButton.setTitle("Forgot Password", for: .normal)
-        aButton.setTitleColor(.blue, for: .normal)
+        aButton.setTitleColor(.red, for: .normal)
+        aButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 15)
         aButton.sizeToFit()
         
         return aButton

@@ -105,7 +105,7 @@ class MyViewController : UIViewController {
                               buttonGuide],
                              in: allItemsBoundaryGuide)
             .alignItems([secondaryButtonGuide], to: [.centerX, .top, .bottom], of: buttonGuide)
-            .chainHorizontally([createAccountButton, dividerLabel, forgotPasswordButton],
+            .chainHorizontally([forgotPasswordButton, dividerLabel, createAccountButton],
                                in: secondaryButtonGuide)
         
         return aRestraint

There is no conflict in this. This diff is very short and easy to read. An entire team can now work in parallel on UI tasks.

Since StraitJacket is purely layout code, it has the same benefits of other code based layout solutions: Its code is reviewable, and does not conflict with non layout code changes. The code produced using StraitJacket compared to other Auto Layout solutions can have 5-6X less lines and 3X less characters. A more detailed analysis of an example and where these metrics come from can be seen in the StraitJacket GitHub page.

I looked at serveral Auto Layout solutions but none of them did everything I wanted. Many of them have similar problems as the Anchor Layout approach, produces hard to read code, does not work with layout guides, makes it difficult or impossible to reference constraints, or can't change layout priorities. StraitJacket allows developers to reference created layout constraints, easily manage constraints in collections, use layout guides, and easily set layout priorities. I haven't found another library that allows the developer to do all of this.

The Need to Preview

Although Storyboards has the advantage of being readily viewable, UI code needs to be run for it to be visually verified. Playgrounds can be used to preview views and view controllers which makes it possible to preview code layout without running them in an app. The StraitJacket examples are actually made in this manner. In using Playgrounds, we can see the intent and the result at the same time at every line of code.

An alternative to Playgrounds as it's currently kind of slow in Xcode 9.4.1, is Injection, which provides hot reloading without having to add any new libraries. It's pretty easy to set this up and has just a few steps:

  • Download their app from the App Store
  • Run the Injection app
  • Open the project's containing folder
  • Add to application launched: Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle")!.load() // for iOS
  • Implement @objc func injected() in some relevant class to perform an update (in this case the view controller)

When the developer saves changes to a file, Injection will automagically inject the changes into the app while its running in the simulator.

Xcode 10 makes Playgrounds much faster and also broke Injection. I would stick to Playgrounds for focused development since it allows us to run a specific piece of code instead of the full app and won't break our work flow with each release of Xcode.

Retaining Sanity

Storyboards are hives of bugs and anti patterns. Storyboards create work flow problems the more they are used. Apple's Auto Layout solution is a good start to get away from Storyboards, but more can be done. Developers can improve maintainability of their frontend workflow if they build UI purely in code perhaps with a library like StraitJacket. Furthermore, by not using Storyboards, we can use OOP patterns that would otherwise not be available. We already have ways to preview layout code that outclasses Storyboards. So what do we say to Storyboards? "Not today."

Recent Posts