Dependency injection is a pattern that's often overlooked, ignored, or discarded by developers in favor of other patterns, such as the singleton pattern. I've talked and written about dependency injection and Swift quite a bit on Cocoacasts.
Developers that are new to dependency injection tend to have a few questions about the practical side of the pattern. They're usually not sure how to implement dependency injection in their projects.
The most common question I receive about dependency injection relates to view controllers. How do storyboards and XIB files fit into the story? In this series, I show you how dependency injection is used in combination with view controllers. We cover view controllers in code, view controllers that are linked to a XIB file, and view controllers that live in a storyboard.
Storyboards
Unlike many other developers, I enjoy using storyboards. The introduction of storyboard references has made working with storyboards more flexible and less daunting. Storyboards aren't perfect, but they make several tasks a lot easier.
The most important limitation of storyboards is the loss of control. You're no longer in charge of instantiating the view controllers of your application. It's a benefit in many ways, but it's an important limitation in the context of dependency injection.
But it doesn't mean you can't adopt dependency injection if you're using storyboards in a project. Because we're not in control of the initialization of the view controllers of the project, initializer injection isn't an option. It isn't possible to inject the view controller's dependencies when it's being initialized.
Initializer injection isn't the only tool in your toolbox, though. Remember from Nuts and Bolts of Dependency Injection in Swift that we define three types of dependency injection:
- initializer injection
- property injection
- method injection
Initializer injection and property injection are the preferred options for view controllers. I've never used method injection in combination with view controllers. It doesn't make much sense.
Because we can't use initializer injection if we're taking advantage of storyboards, we need to rely on property injection. This implies that we need to find a moment, soon after the initialization of the view controller, to inject its dependencies, that is, to set its properties. Remember that dependency injection is nothing more than giving an object its instance variables. View controllers are no different.
Remember that dependency injection is nothing more than giving an object its instance variables. View controllers are no different.
The question is "When can or should we inject the dependencies into a view controller?" The answer is surprisingly simple. Storyboards aren't terribly useful without segues and segues don't make sense without storyboards. The moment a view controller performs a segue to present another view controller, it's given the opportunity to prepare for the segue before it's performed. That opportunity is translated into the invocation of the view controller's prepare(for:sender:)
method.
Let me show you how this works with an example. Download the starter project of this episode if you'd like to follow along. The initial view controller of the main storyboard is an instance of the RootViewController
class. It shows the user a list of Note
instances in a table view. When the user taps a note in the table view, an instance of the DetailViewController
class is presented modally, showing the user the details of the selected note.
This features doesn't work yet because the detail view controller doesn't know which note the user wants to see the details of. It's the responsibility of the root view controller to pass the selected note to the destination view controller of the segue, the detail view controller.
To make this work, we need to implement the prepare(for:sender:)
method of the RootViewController
class.
// MARK: - Navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
}
We ask the table view for the index path of the currently selected row and use that index path to fetch the note the user is interested in. Notice that we use a guard
statement to safely unwrap the result of indexPathForSelectedRow
. If the result of indexPathForSelectedRow
is equal to nil
, then there's no need to continue. I usually throw a fatal error in that scenario since we're about to present a DetailViewController
instance without a Note
instance to display. But that's another discussion.
// MARK: - Navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let indexPath = tableView.indexPathForSelectedRow else { return }
// Fetch Note
let note = notes[indexPath.row]
}
We ask the segue for the destination view controller and cast the result to an instance of the DetailViewController
class. We use another guard
statement to safely cast the destination view controller to an instance of the DetailViewController
class. We pass the Note
instance to the detail view controller. The detail view controller is ready to present the details of the note to the user.
// MARK: - Navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let indexPath = tableView.indexPathForSelectedRow else { return }
// Fetch Note
let note = notes[indexPath.row]
guard let destination = segue.destination as? DetailViewController else { return }
// Configure Detail View Controller
destination.note = note
}
Adding More Safety
While this simple implementation of the prepare(for:sender:)
method works fine, I'd like to add some improvements. I recommend naming each segue and making sure you're handling the correct segue in the prepare(for:sender:)
method, even for simple user interfaces. Because I don't like random string literals in a project, I define a private, nested enum, Segue
, with a static property, NoteDetails
.
import UIKit
class RootViewController: UIViewController {
// MARK: - Types
private enum Segue {
static let NoteDetails = "NoteDetails"
}
...
}
We assign the segue identifier to the segue in the main storyboard. Select the segue, open the Attributes Inspector on the right, and set Identifier to NoteDetails.
Let me show you how I usually implement the prepare(for:sender:)
method of a view controller. I safely unwrap the identifier of the segue. Because I'm only interested in segues with an identifier, I use a guard
statement. If a segue doesn't have an identifier, then it makes no sense to continue because I don't know which segue I'm dealing with.
// MARK: - Navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier else { return }
guard let indexPath = tableView.indexPathForSelectedRow else { return }
// Fetch Note
let note = notes[indexPath.row]
guard let destination = segue.destination as? DetailViewController else { return }
// Configure Detail View Controller
destination.note = note
}
We switch on the value stored in the identifier
constant and add a default
case to make the switch
statement exhaustive. Notice that I throw a fatal error if indexPathForSelectedRow
returns nil
and when the destination view controller cannot be cast to an instance of the DetailViewController
class. Because neither of these scenarios should happen in production, I throw a fatal error.
// MARK: - Navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier else { return }
switch identifier {
case Segue.NoteDetails:
guard let indexPath = tableView.indexPathForSelectedRow else {
fatalError("No Note Selected")
}
guard let destination = segue.destination as? DetailViewController else {
fatalError("Unexpected Destination View Controller")
}
// Fetch Note
let note = notes[indexPath.row]
// Configure Detail View Controller
destination.note = note
default:
break
}
}
That's it. Thanks to the Segue
enum, we don't need to rely on a random string literal in the implementation of the prepare(for:sender:)
method.
What About the Root View Controller
Another common question I hear about dependency injection is related to the initial view controller of a storyboard. The operating system loads the storyboard specified in the target's deployment details and instantiates its initial view controller.
This is nice as long as you don't need to inject any dependencies into the initial view controller. The solution isn't difficult, though. Let's take a look at the application delegate of the project.
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
// MARK: - Properties
var window: UIWindow?
// MARK: -
var notes: [Note] {
return [
Note(title: "Monday, January 11", contents: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ac dolor justo, ac tempus leo. Etiam pulvinar eros at lectus sollicitudin scelerisque."),
Note(title: "Another Day", contents: "Aliquam erat volutpat. Suspendisse eu eros non elit blandit suscipit. Morbi scelerisque euismod tempus. Vestibulum elementum tincidunt tempor. Mauris sodales tristique adipiscing."),
Note(title: "Ideas", contents: "Sed venenatis lorem quis eros hendrerit consequat. Sed a est leo. Donec sapien libero, rutrum eget luctus ac, accumsan vel lectus. Ut quis libero ante. Ut volutpat, massa ac aliquam molestie, neque est blandit diam, non adipiscing purus magna vitae massa."),
Note(title: "Help", contents: "Vestibulum fermentum consectetur sem, non aliquet nisl varius porta. Nulla consectetur tellus vel nibh tincidunt nec tincidunt nunc pellentesque. Etiam vel arcu sit amet quam auctor tincidunt commodo eu leo. Aliquam in arcu nulla. Donec eget imperdiet dui. Praesent vitae odio leo. Morbi bibendum lobortis sapien sit amet posuere.")
]
}
// MARK: - Application Life Cycle
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// Initialize Root View Controller
guard let rootViewController = UIStoryboard(name: "Main", bundle: Bundle.main).instantiateInitialViewController() as? RootViewController else {
fatalError("Unable to Instantiate Root View Controller")
}
// Configure Root View Controller
rootViewController.notes = notes
// Configure Window
window?.rootViewController = rootViewController
// Make Key and Visible
window?.makeKeyAndVisible()
return true
}
}
The AppDelegate
class defines a private, computed property, notes
, that returns an array of Note
instances. In production, you'd load the notes from a file on disk or fetch them from a remote server. We use the notes
computed property for convenience.
We need to inject the array of notes into the RootViewController
instance. Let's take a look at the implementation of application(_:didFinishLaunchingWithOptions:)
to see how this works.
// MARK: - Application Life Cycle
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// Initialize Root View Controller
guard let rootViewController = UIStoryboard(name: "Main", bundle: Bundle.main).instantiateInitialViewController() as? RootViewController else {
fatalError("Unable to Instantiate Root View Controller")
}
// Configure Root View Controller
rootViewController.notes = notes
// Configure Window
window?.rootViewController = rootViewController
// Make Key and Visible
window?.makeKeyAndVisible()
return true
}
We instantiate the initial view controller of the main storyboard and use a guard
statement to safely cast the result of instantiateInitialViewController()
to an instance of the RootViewController
class.
// Initialize Root View Controller
guard let rootViewController = UIStoryboard(name: "Main", bundle: Bundle.main).instantiateInitialViewController() as? RootViewController else {
fatalError("Unable to Instantiate Root View Controller")
}
With a reference to the root view controller, we can set its notes
property.
// Configure Root View Controller
rootViewController.notes = notes
This technique only works if we set the rootViewController
property of the application delegate's window
property to the root view controller.
// Configure Window
window?.rootViewController = rootViewController
We invoke the makeKeyAndVisible()
method of the window and return true
from the method.
// Make Key and Visible
window?.makeKeyAndVisible()
return true
What's Next
Even though the example we discussed in this episode is simple, it doesn't get more complex than this. If you decide to use storyboards, then property injection is your only viable choice. You can also rely on a framework or library, such as Typhoon or Swinject, if you don't mind adding another dependency to your project. That's a choice you need to make.