Handling view controllers that have custom initializers
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.