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.

Exploring the Starter Project

Exploring the Starter Project

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.

Initializer Injection with View Controllers and Storyboards

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.

Setting the Storyboard Identifier in Interface Builder

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.