Storyboards and Their (Better) Alternatives
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:)
:
- 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, - 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 nil
s 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:
- knows that it's embedded in a navigation controller,
- 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.