Scene-based applications come with a few challenges for developers, one of them being dependency injection. How do you inject a dependency, such as a view model, into the root view controller of the window? That is the question I answer in this episode.
Understanding Scene-Based Applications
As of iOS 13, your application can choose to support multiple windows on iPad. To make that possible, Apple had to rethink the application and user interface life cycles on iOS. If you want to learn more about these changes and how they affect developers, I recommend taking a look at Understanding Scene-Based Applications. In this episode, I focus on dependency injection and scene-based applications.
Creating the Project
Fire up Xcode and create a project by choosing the App template from the iOS > Application section.
Give the project a name and make sure to set Interface to Storyboard. This episode doesn't apply to applications that use SwiftUI for their user interface.
Storyboards, XIB Files, or Code
Even though we use a storyboard in this episode, know that you don't have to. What you learn in this episode applies to projects that use storyboards, XIB files, or code for their user interface. Create a Swift file and name it ViewModel.swift and define a struct with name ViewModel
. The struct defines a computed property, title
, of type String
.
import Foundation
struct ViewModel {
var title: String {
"Welcome to Cocoacasts!"
}
}
The view model defines what the view controller displays. In this example, the view model defines the title the view controller displays.
Open ViewController.swift and define a private, constant property, viewModel
, of type ViewModel
.
import UIKit
class ViewController: UIViewController {
// MARK: - Properties
private let viewModel: ViewModel
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
}
}
The view model is injected into the view controller during initialization. Since we use a storyboard, we need to define an initializer that accepts an NSCoder
instance as its first argument. The second argument is the view model. How the pieces fit together becomes clear in a moment.
import UIKit
class ViewController: UIViewController {
// MARK: - Properties
private let viewModel: ViewModel
// MARK: - Initialization
init?(coder: NSCoder, viewModel: ViewModel) {
self.viewModel = viewModel
super.init(coder: coder)
}
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
}
}
We are also required to implement the init(coder:)
initializer, a required initializer. We throw a fatal error in the body of the initializer since this initializer shouldn't be used to create a ViewController
instance.
import UIKit
class ViewController: UIViewController {
// MARK: - Properties
private let viewModel: ViewModel
// MARK: - Initialization
init?(coder: NSCoder, viewModel: ViewModel) {
self.viewModel = viewModel
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("Use `init(coder:viewModel:)` to instantiate a `ViewController` instance.")
}
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
}
}
Creating the User Interface
The user interface of the view controller is simple. We display a label in the center of its view. Define an outlet for a label, titleLabel
, in ViewController.swift.
import UIKit
class ViewController: UIViewController {
// MARK: - Properties
private let viewModel: ViewModel
// MARK: -
@IBOutlet private var titleLabel: UILabel!
...
}
We set the text
property of the label in the view controller's viewDidLoad()
method by asking the view model for the value of its title
property.
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
titleLabel.text = viewModel.title
}
Open the main storyboard and add a label to the view controller scene. Add the necessary constraints to center the label in its superview, the view controller's view. Connect the label to the outlet we defined earlier.
Updating the Scene Delegate
Open SceneDelegate.swift. We need to make a few changes to the scene(_:willConnectTo:options:)
method. First, we replace the _
in the guard statement with windowScene
.
// MARK: - Scene Life Cycle
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else {
return
}
}
Second, we ask the main storyboard to instantiate its initial view controller. Notice that we pass a closure as an argument. The closure accepts an NSCoder
instance as its only argument and its return type is UIViewController?
. We use the NSCoder
instance to create a ViewController
instance, invoking the initializer we implemented earlier in this episode. We pass the NSCoder
instance as the first argument and a ViewModel
object as the second argument.
// MARK: - Scene Life Cycle
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else {
return
}
// Initialize Root View Controller
let rootViewController = UIStoryboard(name: "Main", bundle: .main).instantiateInitialViewController { coder in
ViewController(coder: coder, viewModel: ViewModel())
}
}
The remaining steps are straightforward. We create a UIWindow
instance using the UIWindowScene
instance, set the view controller as the root view controller of the window, and make the window the key window.
// MARK: - Scene Life Cycle
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else {
return
}
// Initialize Root View Controller
let rootViewController = UIStoryboard(name: "Main", bundle: .main).instantiateInitialViewController { coder in
ViewController(coder: coder, viewModel: ViewModel())
}
// Initialize Window
window = UIWindow(windowScene: windowScene)
// Configure Window
window?.rootViewController = rootViewController
// Make Window Key Window
window?.makeKeyAndVisible()
}
Build and run the application to see the result. The application should crash because the init(coder:)
initializer of the ViewController
class is invoked instead of the init(coder:viewModel:)
initializer. Why is that?
We need to make one more change. Select the target's Info.plist and expand the Application Scene Manifest entry. Remove the entry with name Storyboard Name. If this entry is present, the application instantiates the initial view controller of the storyboard with that name and sets it as the root view controller of the window. That is not what we want.
With the Storyboard Name entry removed, run the application one more time. The view controller should display the title the view model defines, which confirms the view model was successfully injected into the view controller.
What's Next?
As I mentioned earlier, the approach described in this episode also works for projects that use XIB files or define their user interface in code. We had to jump through a few hoops, but know that you only need to go through this setup once.