Storyboards have many benefits, but they also have a number of significant downsides. Not being able to control the initialization of a view controller is one of them, especially if you want to use initializer injection. As of iOS 13 and tvOS 13, that is no longer a problem. In this episode, I show you how to use initializer injection in combination with storyboards.
Exploring the Starter Project
Fire up Xcode and open the starter project of this episode if you want to follow along. The application displays a collection of images in a table view. Each cell displays a thumbnail and a title. Tapping a cell takes the user to a detail view, showing a larger version of the image.
The project isn't complex. Let's start with the storyboard. It contains two scenes, Images View Controller and Image View Controller. The images view controller is the initial view controller of the storyboard. Notice that there is no segue from the Images View Controller scene to the Image View Controller scene.
Select the image view controller and open the Identity Inspector on the right. Because there is no segue that leads to the Image View Controller scene, Storyboard ID is defined in the Identity section of the Identity Inspector.
Open ImagesViewController.swift and navigate to the tableView(_:didSelectRowAt:)
method. The images view controller fetches the image that corresponds with the user's selection and passes it to the showImage(_:)
method.
// MARK: - Table View Delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
// Fetch Image
let image = dataSource[indexPath.row]
// Show Image
showImage(image)
}
In showImage(_:)
, the images view controller loads the main storyboard and instantiates an ImageViewController
instance by invoking instantiateViewController(withIdentifier:)
on the storyboard, passing in the storyboard identifier we defined in Main.storyboard.
The ImageViewController
class defines a property, image
, of type Image?
. We use property injection to inject the Image
object that is passed to the showImage(_:)
method into the ImageViewController
instance. The images view controller presents the image view controller modally to the user.
// MARK: - Helper Methods
private func showImage(_ image: Image) {
// Initialize Image View Controller
guard let imageViewController = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(withIdentifier: "ImageViewController") as? ImageViewController else {
fatalError("Unable to Instantiate Image View Controller")
}
// Configure Image View Controller
imageViewController.image = image
// Present Image View Controller
present(imageViewController, animated: true)
}
The Image
struct is a lightweight data type. It defines three properties, title
of type String
, fullSizeUrl
of type URL
, and thumbnailUrl
of type URL
.
struct Image {
// MARK: - Properties
let title: String
// MARK: -
let fullSizeUrl: URL
let thumbnailUrl: URL
}
Property Injection
In Nuts and Bolts of Dependency Injection in Swift, I explain what dependency injection is and why it is an important pattern to understand. There are several types of dependency injection. The images view controller uses property injection to inject the Image
object into the image view controller. This is fine, but it has a number of downsides.
Open ImageViewController.swift. There are three problems I would like to address in this episode. First, the image
property isn't declared privately. Second, the image
property is a variable property. Third, the image
property is of an optional type. Property injection is only possible by declaring the image
property as a variable property of type Image?
and exposing it to the rest of the project.
import UIKit
internal final class ImageViewController: UIViewController {
// MARK: - Properties
var image: Image?
...
}
In viewDidLoad()
, we safely unwrap the image
property to access the value of the fullSizeUrl
property. This isn't the best solution because the image view controller isn't very useful if the image
property is equal to nil
.
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
if let url = image?.fullSizeUrl {
// Fetch Image
dataTask = fetchImage(with: url)
}
}
We could use an implicitly unwrapped optional instead of an optional, but that is a solution I tend to avoid. Once you start using implicitly unwrapped optionals, it gets harder to justify not using them. I only use implicitly unwrapped optionals for outlets.
Initializer Injection
We can solve the three problems I mentioned a moment ago by using initializer injection instead of property injection. Let me show you how that works. As of iOS 13 and tvOS 13, initializer injection is possible in combination with storyboards. The UIKit framework defines a method that accepts a creator in addition to the storyboard identifier. A creator is a closure that accepts an NSCoder
instance and returns a view controller. The NSCoder
instance contains the data loaded from the storyboard to create the view controller.
In the closure, we are in control of and responsible for the creation of the view controller. Adopting initializer injection requires two steps. First, we define a custom initializer for the ImageViewController
class. Second, we invoke the custom initializer in the creator.
Defining a Custom Initializer
Open ImageViewController.swift and add an initializer with name init(coder:image:)
. The initializer accepts an NSCoder
instance as its first argument and an Image
object as its second argument. In the initializer, we set the image
property and invoke the inherited init(coder:)
initializer.
// MARK: - Initialization
init?(coder: NSCoder, image: Image) {
self.image = image
super.init(coder: coder)
}
We are also required to implement the init(coder:)
initializer. Because it shouldn't be used to instantiate an ImageViewController
instance, we throw a fatal error in the init(coder:)
initializer.
required init?(coder: NSCoder) {
fatalError("Use `init(coder:image:)` to initialize an `ImageViewController` instance.")
}
Because the initializer of the ImageViewController
class accepts an Image
object, we can make a few improvements. We declare the image
property as a private, constant property and we change its type from Image?
to Image
.
import UIKit
internal final class ImageViewController: UIViewController {
// MARK: - Properties
private let image: Image
...
}
We no longer need to safely unwrap the image
property in the view controller's viewDidLoad()
method.
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
// Fetch Image
dataTask = fetchImage(with: image.fullSizeUrl)
}
With the initializer in place, it is time to put it to use. Revisit the showImage(_:)
method in ImagesViewController.swift. We instantiate the image view controller by invoking the instantiateViewController(identifier:creator:)
method. Because the return type isn't an optional, we no longer need the guard
statement.
The instantiateViewController(identifier:creator:)
method accepts two arguments, the storyboard identifier we defined in Main.storyboard and the creator, a closure. The closure accepts an NSCoder
instance as its only argument. We need to define the type the closure returns. The only requirement is that the returned type inherits from UIViewController
.
// Initialize Image View Controller
let imageViewController = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(identifier: "ImageViewController", creator: { coder -> ImageViewController? in
})
In the closure, the images view controller invokes the initializer we implemented a moment ago, passing in the the NSCoder
instance and the Image
object. The return type of the closure is ImageViewController?
. If the closure returns nil
, it instantiates the view controller using the default init(coder:)
initializer.
// Initialize Image View Controller
let imageViewController = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(identifier: "ImageViewController", creator: { coder -> ImageViewController? in
ImageViewController(coder: coder, image: image)
})
Because we pass the Image
object to the initializer, we no longer need to set the image
property of the image view controller.
// MARK: - Helper Methods
private func showImage(_ image: Image) {
// Initialize Image View Controller
let imageViewController = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(identifier: "ImageViewController", creator: { coder -> ImageViewController? in
ImageViewController(coder: coder, image: image)
})
// Present Image View Controller
present(imageViewController, animated: true)
}
Build and run the application to see the result. The application should work as before.
What's Next?
Even though property injection is useful, you learned in this episode that it has a number of downsides. The instantiateViewController(identifier:creator:)
method makes it possible to use initializer injection for view controllers that are created from a storyboard. This is a very welcome addition.
Can you use this technique in combination with segues? The answer is yes. I show you how that works in the next episode.