Storyboards and Their (Better) Alternatives

This article uses Swift 2.1.

Originally published on Macoscope's Blog.

It seems that in almost every iOS project, one of the first questions developers ask themselves is:

Should we use storyboards, XIBs or write the whole UI in code?

It's always hard to answer it because preferences tend to vary even among members of the most closely-knit teams. However, enforcing a consistent approach to the way UI flow is handled within an app almost always results in higher quality of the project.

Every decision of this magnitude requires the team to take a closer look at the pros and cons (or tradeoffs 🙅) of all available solutions. This article discusses the majority of the known (and popular) ways of dealing with UI flow management to help you choose the one that fits your or your team's goals the best.

1. Storyboards

What exactly is a storyboard in the context of iOS development? According to Apple, a storyboard is:

A file that contains a visual representation of the app’s UI (user interface), showing screens of content and the transitions between them, that you work on in Interface Builder.

When you create a new project using any of the iOS → Application templates, you end up with a Main.storyboard file in your project. This suggests that Apple is trying very hard to persuade us to use storyboards in all our projects, but should we actually bend to their will in all cases?

Well, let's take a look pros and cons of storyboards in a couple of different contexts.

Pros

Beginners

Like a great many of my peers, I started learning iOS development through the CS193P course humbly provided for free by Stanford University. I have to admit that for a beginner, creating an app by dragging-and-dropping elements into a storyboard may definitely have some appeal, as it allows a student to visualize the flow of an app in a very accessible way. Some parts of the API may seem weird from the start (especially when it comes to Swift), but overall I think that storyboards actually kept a lot of people going down the path to becoming full-fledged iOS developers who would have otherwise quit a long time ago.

Prototyping

Storyboards work quite well as a prototyping tool, too. With them it's possible to put together a simple app or a feature in just a few hours or days. If you need to create something that will be later rewritten, e.g. a prototype that you show to potential investors, they're the way to go.

Static Flows

Let's say that we have to implement some rather static part of our app: a signup flow or some kind of help section. In these cases storyboards act almost as a WYSIWYG tool. They're a really good solution to this sort of problem.

Good Support from Apple

There are some indications that Apple thinks that storyboards are the way of the future. For example, on watchOS all the UI has to be created using storyboards. Additionally, some important features, like layout guides, are missing from XIBs.

Cons

View Controller Initialization

When you use a storyboard, the target view controller for a segue is created by UIKit and not by you. UIKit does this by calling init?(coder:) on the UIViewController (sub)class. This means that we can't use dependency injection via initializer, which is my preferred form of dependency injection, because it makes all dependencies explicit and enforced by the compiler.

Even when we don't use the constructor injection, dependencies still have to be provided in one way or another. We'll probably end up with either optional properties for our dependencies, e.g.:

var viewModel: SomeViewModel?

which are safe, but kind of a pain to use (checking whether a value exists all the time) or with implicitly unwrapped optionals, which are easier to use but can lead to runtime errors:

var viewModel: SomeViewModel!

The way we set a value of a non-private property from the outside is cumbersome too, which brings us to the next point.

prepareForSegue(_:sender:) API

Storyboards are pretty good until we have to leave the land of the Interface Builder. The way storyboards interact with instances of view controllers isn't all it's cracked up to be, either. Most of the methods provided for us, like

  • prepareForSegue(_:sender:) or
  • shouldPerformSegueWithIdentifier(_:sender:)

lead to code filled with giant if or switch statements. This, however, too closely resembles the famous KVO method: observeValueForKeyPath(_:ofObject:change:context:) which is almost universally accepted as an example of badly designed API.

As you can see above, it's hard to provide some context when performing segues from code. For example, if we want to push a view controller after a user selects a cell in a table view, we have to perform the following actions in our override of prepareForSegue(_:sender:):

  1. use indexPathForSelectedRow to get the index path of the selected row. We have to assume that this segue identifier is used only when some sort of cell is selected,
  2. fetch the model (e.g. a profile identifier) associated with that row and assign it to the destination view controller.

Stringly-Typed API

Another issue is that we're working with an inherently stringly-typed API. If we use out-of-the-box infrastructure, we end up with code like:

performSegueWithIdentifier("showProfile", sender: nil)

or

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    switch segue.identifier {
    case "showProfile"?:
        if let destinationViewController = segue.destinationViewController as? ProfileViewController {
            destinationViewController.profileIdentifier = profileIdentifier
        }
    default:
        break;
    }
}

To make things worse, we have to update that code every time we change something in the storyboard file. This leads to bugs really quickly when the team working on it consists of more than one person.

There are build-time code generators that allow us to introduce some type-safety in place of these strings. Their mere existence, however, indicates that there are deficiencies in the API.

Everything Tangled Together

Over the course of our development practice we learned of the Separation of Concerns, a principle that helps us build better software. In my opinion, however, storyboards contradict that principle rather directly. We've got two things in one file: the UI for individual view controllers AND the handling of the flow of view controllers.

I know that this approach has one major advantage, that is a bird's-eye view on a part of our app, but is having this one benefit really worth all the drawbacks we're bringing up?

👋 Generics

Sadly, if we use storyboards, our view controllers can't be generic.

Version Control

Apps are usually made by teams. Due to that collaborative nature of the development effort, we encounter two issues related to version control:

  • If two developers work simultaneously on one storyboard file, conflicts will undoubtedly appear during merges and rebases. They're much harder to resolve than conflicts in, for example, a single xib file for a view, because there is simply more stuff in such a file.
  • During code review it's hard to understand the flow of the app without opening a storyboard file in Xcode. It would be great if we there was some kind of preview (like Quick Look) for storyboards, e.g. in the GitHub UI.

Interface Builder

Finally, there are some issues we need to deal with stemming from the current implementation of storyboards in Interface Builder:

  • They're really slow to render, to a point that you become irritated whenever you have to open a .storyboard file (and start to think about buying that quad-core i7 4.0 GHz 🖥).
  • They take a lot of screen real estate; Xcode behaviors can help a little with that, though.

2. Everything in Code

Given everything we've written so far, it may seem that storyboards come bundled with a lot of issues. But do we have any better alternatives? I often see someone getting stung by storyboards and going straight back to the other end of the spectrum, which is doing everything in code. This is a radical change. I've got three major reservations about this sort of approach.

First of all, with features such as IBInspectable and IBDesignable it's now a lot easier to design a UI component visually than by looking at code and having to constantly recompile the entire project.

Second, when everything is in code, it makes it harder than necessary for a new person joining a team to get oriented in a codebase. There are no visual clues that allow newcomers to determine what a given part of the code is responsible for in the app. They have to rely on tools such as View Hierarchy Debugging in Xcode, Reveal or Chisel.

Third, in my opinion, Auto Layout (especially with UIStackView) is a great tool that makes working with layout a lot easier than it was in the past (it comes with a slight performance penalty, but it's becoming less and less important with advances in CPU technology). When going the "doing everything in code" way, the API for Auto Layout that Apple provides isn't the most readable. There are alternatives, such as SnapKit, but still, I think that graphical representation is just easier to handle in the long run.

So, is there something that we can use that sits between storyboards and pure code? Well, yes, there is 🎉!

3. XIB per View Controller

If there's a sweet spot with just enough amount of control and GUI-ness, I think it's having one xib file per each view controller. You don't even have to create those xibs by hand, Xcode will happily do that for you if you check "Also create XIB file" when creating a new view controller subclass.

From now on, I'd like to strengthen my argument by focusing on a real-world™ example. Let's say we're building a gallery consisting of two view controllers:

  • GridViewController: view controller with thumbnails (left side on the image below),
  • PhotoViewController: view controller showing a fullscreen photo (right side on the image below).

So, how can we approach an implementation of such a flow? Let's start with something straightforward. GridViewController will be initializable with an array of image URLs.

class GridViewController: UIViewController {

    let imageURLs: [NSURL]

    init(imageURLs: [NSURL]) {
        self.imageURLs = imageURLs

        super.init(nibName: nil, bundle: nil)
    }

    ...
}

We can safely pass nils as nibName and bundle because:

If you invoke this method with a nil nib name, then this class' -loadView method will attempt to load a NIB whose name is the same as your view controller's class. If no such NIB in fact exists then you must either call -setView: before -view is invoked, or override the -loadView method to set up your views programatically.

Under the hood, GridViewController will set up a collection view, trigger download of photos, and so on. We don't care about these details, though. We want to focus only on the flow.

So, when a cell is tapped, we initialize an instance of PhotoViewController by passing an image URL to it. Then, we push it onto a navigation controller stack.

extension GridViewController: UICollectionViewDelegate {

    func collectionView(collectionView: UICollectionView,
        didSelectItemAtIndexPath indexPath: NSIndexPath) {

        let URL = imageURLs[indexPath.row]
        let photoViewController = PhotoViewController(imageURL: URL)
        navigationController?.pushViewController(photoViewController, animated: true)
    }
}

Does this work? Yup. Is it any good? Nope.

Let me explain. Sadly, we've introduced a tight coupling in collectionView(_:didSelectItemAtIndexPath:) and our GridViewController now:

  1. knows that it's embedded in a navigation controller,
  2. knows that PhotoViewController exists and that it should use it when a cell is tapped.

The first issue can be fixed in a fairly simple manner. iOS 8 introduced an abstract way to present a view controller, and now we no longer have to know that we're embedded in a navigation controller. We can present a view controller with either showViewController(_:sender:) or showDetailViewController(_:sender:). The second method fits this particular case better semantically, but UINavigationController presents a view controller modally with it, so we should go with the first one.

However, because of the second issue, we won't be able to reuse GridViewController in different scenarios. It's not hard to imagine that we'll want this grid to act as, for example, a photo picker. So, let's improve this code by introducing a delegate pattern.

4. Delegate

Delegation is a fundamental design pattern in iOS development. However, I often see it omitted in places it would fit really well, like our GridViewController. Let's fix that by starting with a simple GridViewControllerDelegate protocol:

protocol GridViewControllerDelegate: class {

    func gridViewController(gridViewController: GridViewController, didSelectImageWithURL imageURL: NSURL)
}

Now we have to also add a property for it: weak var delegate: GridViewControllerDelegate? and update collectionView(_:didSelectItemAtIndexPath:) as follows:

func collectionView(collectionView: UICollectionView,
    didSelectItemAtIndexPath indexPath: NSIndexPath) {

    let URL = imageURLs[indexPath.row]
    delegate?.gridViewController(self, didSelectImageWithURL: URL)
}

Well, we got rid of the coupling. We still have to put that code for initialization and presentation of PhotoViewController somewhere. Where should we put it, you ask? I think in most cases it'll land in a view controller that displays a grid or in the app delegate 😱.

It rarely gets this bad in smaller projects. If you have a gazillion of view controllers implemented with a delegate pattern or with closures, however, you'll quickly notice that your flow code is duplicated and scattered across the app and codebases like these are really hard to work with. Thankfully, there's a remedy for this problem, too.

5. XIBs + Coordinators = 💗

Storyboards abstract flow management in a GUI style. There's nothing stopping us from abstracting flow handling into a separate entity in code, too! Last year, Soroush Khanlou came up with a Coordinator pattern that does exactly that. Other people had very similar ideas but used different names, like flow controllers or flow mediators. (“Great Minds” and all that.) I highly encourage you to read these articles (or watch the video) to work through the nitty gritty of the concept. If you don't have time for that, read on, as we'll be working through a simple example in a later part of this post.

GalleryCoordinator

Let's see how the flow for our gallery would look like with a coordinator. I think that the name GalleryCoordinator fits this use case well, so we have:

class GalleryCoordinator {

    let imageURLs: [NSURL]
    private weak var navigationController: UINavigationController?

    init(imageURLs: [NSURL]) {
        self.imageURLs = imageURLs
    }

    func startOverViewController(viewController: UIViewController) {
        let gridViewController = GridViewController(imageURLs: imageURLs)
        gridViewController.delegate = self

        let navigationController = UINavigationController(rootViewController: gridViewController)
        viewController.presentViewController(navigationController, animated: true, completion: nil)

        self.navigationController = navigationController
    }
}

We also need to conform to GridViewControllerDelegate:

extension GalleryCoordinator: GridViewControllerDelegate {

    func gridViewController(gridViewController: GridViewController,
      didSelectImageWithURL imageURL: NSURL) {

        let photoViewController = PhotoViewController(imageURL: imageURL)
        navigationController?.pushViewController(photoViewController, animated: true)
    }
}

We should also add a delegate to the coordinator, so that a client of our API knows that a user finished browsing the gallery:

protocol GalleryCoordinatorDelegate: class {

    func galleryCoordinatorDidFinish(galleryCoordinator: GalleryCoordinator)
}

I'll leave the writing of the code for calling this method to the reader as exercise 💪.

ImagePickerCoordinator

Let's also take a quick look at how we could design the public API for an image picker coordinator:

protocol ImagePickerCoordinatorDelegate: class {

    func imagePickerCoordinator(imagePickerCoordinator: ImagePickerCoordinator, didChooseImageWithURL: NSURL)
    func imagePickerCoordinatorDidCancel(imagePickerCoordinator: ImagePickerCoordinator)
}

extension ImagePickerCoordinator: GridViewControllerDelegate {

    func gridViewController(gridViewController: GridViewController,
                            didSelectImageWithURL imageURL: NSURL) {
        delegate?.imagePickerCoordinator(self, didChooseImageWithURL: imageURL)
    }
}

Implementation of ImagePickerCoordinator could be similar to that of GalleryCoordinator (pro tip: we should consider abstracting them in the future). In the gridViewController(gridViewController:didSelectImageWithURL:) implementation we now call our delegate instead of pushing PhotoViewController on the stack. Nice, clean, and easy!

Conclusion

We analyzed different ways of managing flow in iOS codebases. We saw that storyboards are a good fit in some simple scenarios, but the more advanced our app becomes, the less they tend to fit the problem. After noticing that there's a problem, we proceeded to find a solution for it.

We went from something really easy, which is letting a view controller know what view controller comes after it, to a solution which will prove simpler in the long run although right now it may seem a little complicated, namely using coordinators. We've seen that there are numerous approaches which we can use to tackle the problem and now we will be able to choose the one that fits our case the best.

Flow handling on iOS is a real problem that we encounter every day. We have to appreciate its existence and choose our tools carefully. Otherwise, it's highly probable that our codebase will end up in a bad state.