blog.hal.codes

A smarter way to storyboard

Pure protocol factory for Interface Builder-based views

I’ll admit: I love laying out views in Interface Builder. It’s a bit of a controversial topic but for me, there’s no faster way to build screens for an app. It’s indispensable to be able to see exactly how my views look in IB without recompiling my whole app.

Interface Builder has downsides, no doubt. One of the biggest: multiple developers working on a single Storyboard file can easily cause merge conflicts that are hard or impossible to reconcile. The solution, for me, is to create per-screen Storyboards (really, one for every UIViewController) and handle transitions entirely in code. This approach means that Storyboard views need to be instantiated pretty often in code, which can be a bit ugly:

class MyViewController: UIViewController {
    /// ...
}

let storyboard = UIStoryboard(name: "MyViewController",
                              bundle: Bundle(for: MyViewController.self))
guard let controller = storyboard.instantiateInitialViewController() as? MyViewController else {
    return
}

Not great! There’s a magic string literal for the Storyboard’s filename, and type casting is required to get the desired type. This isn’t a code block I want littered throughout my project.

Recently I came up with a helper protocol for constructing view controllers from Storyboards that has helped clean up my code at work. I prefer a protocol to a subclass or extension of UIViewController. With a protocol I can conform only the view controllers with associated Storyboards to the protocol, and prevent the helper property from polluting the namespace of my code-only view controllers.

Let’s build that helper protocol:

protocol IBConstructible: AnyObject {
    static var nibName: String { get }
    static var bundle: Bundle { get }
}

First, we define a pretty normal looking protocol, IBConstructible. The AnyObject protocol inheritance (AnyObject is itself a protocol) makes this a class-only protocol. The nibName (the Storyboard’s filename) and bundle properties are the requirements for instantiating a view controller from a Storyboard. By itself this isn’t helpful, but using a default protocol implementation, we can automatically provide smart defaults for these properties:

extension IBConstructible {
    static var nibName: String {
        return String(describing: Self.self)
    }

    static var bundle: Bundle {
        return Bundle(for: Self.self)
    }
}

Extensions of protocols in Swift allow for adding default implementations. In this case, the default assumption is that the Storyboard filename is the same as the class name (MyViewController.storyboard for a class named MyViewController) and that the Storyboard and class definition exist in the same Bundle; both are almost always true. For when that’s not the case, we can always override these properties when we adopt the IBConstructible protocol!

We can use these in yet another default implementation to create our factory helper property:

extension IBConstructible where Self: UIViewController {
    static var fromNib: Self {
        let storyboard = UIStoryboard(name: nibName, bundle: bundle)
        guard let viewController = storyboard.instantiateInitialViewController() as? Self else {
            fatalError("Missing view controller in \(nibName).storyboard")
        }
        return viewController
    }
}

Note the where Self: UIViewController, which limits this default implementation to UIViewController subclasses, and allows for typecasting from the Storyboard without using generics. Our nibName and bundle properties provide smart defaults based on the name of a conforming type, so we don’t have to specify any variables when invoking the factory!

Creating view controllers with the factory property is as simple as calling .fromNib:

/// conform to IBConstructible
class MyViewController: UIViewController, IBConstructible {
    /// ...
}

/// call the static factory property
let controller = MyViewController.fromNib

That’s it! Much cleaner than before. And this isn’t limited to UIViewControllers! We can also easily extend the same concept to UIViews created in .xib files:

extension IBConstructible where Self: UIView {
    static var fromNib: Self {
        let xib = UINib(nibName: nibName, bundle: bundle)
        guard let view = xib.instantiate(withOwner: nil, options: nil).first as? Self else {
            fatalError("Missing view in \(nibName).xib")
        }
        return view
    }
}

I hope someone else finds this useful. Here’s the full IBConstructible protocol if you’d like to include it in your projects:

protocol IBConstructible: AnyObject {
    static var nibName: String { get }
    static var bundle: Bundle { get }
}

extension IBConstructible {
    static var nibName: String {
        return String(describing: Self.self)
    }

    static var bundle: Bundle {
        return Bundle(for: Self.self)
    }
}

extension IBConstructible where Self: UIViewController {
    static var fromNib: Self {
        let storyboard = UIStoryboard(name: nibName, bundle: bundle)
        guard let viewController = storyboard.instantiateInitialViewController() as? Self else {
            fatalError("Missing view controller in \(nibName).storyboard")
        }
        return viewController
    }
}

extension IBConstructible where Self: UIView {
    static var fromNib: Self {
        let xib = UINib(nibName: nibName, bundle: bundle)
        guard let view = xib.instantiate(withOwner: nil, options: nil).first as? Self else {
            fatalError("Missing view in \(nibName).xib")
        }
        return view
    }
}