Weekly Swift articles, podcasts and tips by John Sundell.

Handling view controllers that have custom initializers

Published on 25 Aug 2020

Especially when using dependency injection to provide our various objects with their external dependencies when they’re being created, it’s very common to want to replace the standard initializers that UIKit classes like UIViewController ship with.

For example, let’s say that an app that we’re working on contains a ContactListViewController that requires a ContactList model object to function. Ideally, we wouldn’t want to be able to create an instance of that view controller without its required model, and to make that happen, we might implement a custom initializer that looks like this:

class ContactListViewController: UIViewController {
    private let contactList: ContactList

    init(contactList: ContactList) {
        self.contactList = contactList
        super.init(nibName: nil, bundle: nil)
    }
    
    ...
}

However, if we now try to compile our app after making the above change, we’ll get a build error — saying that subclasses of UIViewController always need to provide an implementation of the init(coder:) initializer.

That’s because UIKit uses NSCoder to decode objects like views and view controllers when storyboards are used, and although Xcode will at this point suggest that we simply add that required initializer with a fatalError as its body — there are often other approaches that we can take, especially if we still want those objects to remain storyboard-compatible.

One way that can be used if the dependencies that we’re looking to inject can be retrieved statically (such as when using the singleton pattern) is to turn init(coder:) into a convenience initializer, and then have it call our custom one using those static instances as arguments — like this:

class ContactListViewController: UIViewController {
    private let contactList: ContactList

    init(contactList: ContactList) {
        self.contactList = contactList
        super.init(nibName: nil, bundle: nil)
    }

    required convenience init?(coder: NSCoder) {
        self.init(contactList: .shared)
    }
    
    ...
}

Another option that also lets our ContactListViewController remain compatible with storyboards is to use another flavor of dependency injection, rather than a custom initializer. For example, here we’re using property-based dependency injection to have our view controller use ContactList.shared by default, while also enabling that dependency to be overridden by assigning a new value to its property (optionally only within debug builds):

class ContactListViewController: UIViewController {
    @DebugOverridable
    var contactList = ContactList.shared
    ...
}

If storyboard compatibility is not an issue, we could of course go with what Xcode originally suggested, and implement the required NSCoder-based initializer with a call to fatalError — but if we end up doing that, let’s also mark that initializer as unavailable to make it impossible for us to accidentally call it within our own code. Here we’re doing just that within a view controller that requires a Product model to be passed into its initializer:

class ProductDetailsViewController: UIViewController {
    private let product: Product

    init(product: Product) {
        self.product = product
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("This class does not support NSCoder")
    }
    
    ...
}

In iOS 13 and above, there are also a few other options that we could explore that both enable us to keep using storyboards, while also making it possible for our view controllers to use custom initializers.

One such option is the instantiateViewController method that’s available on UIStoryboard (the runtime API for Xcode’s storyboards). Using that API, we can programmatically create any view controller that our storyboard contains, which in turn enables us to take complete control over its initialization.

To make it possible to use that API, let’s first tweak our ProductDetailsViewController from before to also accept an NSCoder instance as part of its custom initializer, and to then delegate that initializer to super.init(coder:), rather than its nibName equivalent:

class ProductDetailsViewController: UIViewController {
    private let product: Product

    init?(product: Product, coder: NSCoder) {
        self.product = product
        super.init(coder: coder)
    }

    @available(*, unavailable, renamed: "init(product:coder:)")
    required init?(coder: NSCoder) {
        fatalError("Invalid way of decoding this class")
    }
    
    ...
}

Note how we’ve also added a renamed: parameter to our above @available declaration, to again make our intentions crystal clear, both to any other developers that we might be working with, and to our future selves.

Then, here’s how we could use the above new implementation to create an instance of ProductDetailsViewController, for example within another view controller that displays a list of products:

class ProductListViewController: UIViewController {
    ...

    private func showDetails(for product: Product) {
        guard let viewController = storyboard?.instantiateViewController(
            identifier: "ProductDetails",
            creator: { coder in
                ProductDetailsViewController(product: product, coder: coder)
            }
        ) else {
            fatalError("Failed to create Product Details VC")
        }

        show(viewController, sender: self)
    }
}

Note that we’re retrieving the storyboard reference for our ProductDetailsViewController using its identifier, which can be assigned using the Storyboard ID text field within the Identity Inspector when a view controller was selected in Xcode’s storyboard editor.

Another option that enables us to use storyboard segues, instead of creating our view controllers manually, is to use the @IBSegueAction function attribute. Marking any function that accepts a single NSCoder argument with that attribute lets us delegate the creation of a given segue’s destination view controller to that function — for example like this:

class ProductListViewController: UIViewController {
    private let productLoader: ProductLoader
    
    ...

    @IBSegueAction func showCurrentOffers(
        _ coder: NSCoder
    ) -> ProductOffersViewController? {
        ProductOffersViewController(
            productLoader: productLoader,
            coder: coder
        )
    }
}

To connect a custom @IBSegueAction function to a storyboard segue, select the segue within Xcode’s storyboard editor, open the Connections Inspector, and drag the instantiation action to the method that you’d like to connect it to.

So there you have it — several options and approaches that can be used to handle view controllers that have custom initializers, with or without storyboard support. Hope you found this article useful, and feel free to reach out if you have any questions or comments.

Support Swift by Sundell by checking out this sponsor:

Paw
Paw

Paw: A GraphQL and REST API client that lets you test and describe the APIs that you call from your app. Just enter the URL of the API endpoint that you’re looking to call, add any headers, parameters, authentication, or body data. Hit return — and everything is automatically checked for you, from the standard OAuth 2 login to very custom API flows.