If you're familiar with Cocoacasts, then you may know that I'm allergic to string literals randomly scattered in a project. Open AppCoordinator.swift and navigate to the showQuotes()
method.
private func showQuotes() {
// Initialize Quotes View Controller
guard let quotesViewController = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(withIdentifier: "QuotesViewController") as? QuotesViewController else {
fatalError("Unable to Instantiate Quotes View Controller")
}
// Push Quotes View Controller Onto Navigation Stack
navigationController.pushViewController(quotesViewController, animated: true)
}
We use a few string literals to instantiate the quotes view controller from the main storyboard. This isn't a major issue, but we can clean up the implementation using a protocol and a protocol extension.
Protocols and Protocol Extensions
The goal of this episode is clear, (1) rid the project of as many string literals as possible and (2) simplify the instantiation of view controllers to avoid code duplication. To accomplish that goal, we take advantage of a protocol and a protocol extension. This will result in a clean and elegant API without adding a lot of complexity.
Defining a Protocol
Create a new group, Protocols, and add a Swift file with name Storyboardable.swift. Add an import statement for the UIKit framework and define a protocol with name Storyboardable
.
import UIKit
protocol Storyboardable {
}
The idea is simple. We should be able to instantiate a UIViewController
subclass conforming to the Storyboardable
protocol by invoking a static method with name instantiate()
. Let's start by defining that method.
import UIKit
protocol Storyboardable {
// MARK: - Methods
static func instantiate() -> Self
}
Notice that the return value of the instantiate()
method is of type Self
, the type conforming to the Storyboardable
protocol. The AppCoordinator
class instantiates an instance of the QuotesViewController
class in its showQuotes()
method.
private func showQuotes() {
// Initialize Quotes View Controller
guard let quotesViewController = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(withIdentifier: "QuotesViewController") as? QuotesViewController else {
fatalError("Unable to Instantiate Quotes View Controller")
}
// Push Quotes View Controller Onto Navigation Stack
navigationController.pushViewController(quotesViewController, animated: true)
}
The implementation of the showQuotes()
method shows that we need three pieces of information to instantiate the quotes view controller, (1) the name of the storyboard, (2) the bundle containing the storyboard, and (2) the storyboard identifier of the view controller.
// Initialize Quotes View Controller
guard let quotesViewController = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(withIdentifier: "QuotesViewController") as? QuotesViewController else {
fatalError("Unable to Instantiate Quotes View Controller")
}
Let's define a property for each of these pieces of information. We define a static computed property storyboardName
of type String
, a static computed property storyboardBundle
of type Bundle
, and a static computed property storyboardIdentifier
of type String
.
import UIKit
protocol Storyboardable {
// MARK: - Properties
static var storyboardName: String { get }
static var storyboardBundle: Bundle { get }
// MARK: -
static var storyboardIdentifier: String { get }
// MARK: - Methods
static func instantiate() -> Self
}
Creating a Protocol Extension
Every type conforming to the Storyboardable
protocol needs to implement these computed properties as well as the instantiate()
method. But a protocol extension can help us simplify the solution we're implementing.
We define a protocol extension for the Storyboardable
protocol and use a generic where
clause to require that Self
is a subclass of UIViewController
. Self
refers to the type conforming to the Storyboardable
protocol. This opens up several possibilities.
import UIKit
protocol Storyboardable {
...
}
extension Storyboardable where Self: UIViewController {
}
We use the protocol extension to provide default implementations for the computed properties and the instantiate()
method. If a UIViewController
subclass provides its own implementation, then that implementation is used instead of the default implementation provided by the protocol extension. We return Main
for the storyboardName
computed property and the main bundle for the storyboardBundle
computed property.
extension Storyboardable where Self: UIViewController {
// MARK: - Properties
static var storyboardName: String {
return "Main"
}
static var storyboardBundle: Bundle {
return .main
}
}
The default implementation of the storyboardIdentifier
computed property returns the name of the class. We invoke the init(describing:)
initializer of String
, passing in self
as the argument. In this example, self
refers to the conforming type.
extension Storyboardable where Self: UIViewController {
// MARK: - Properties
static var storyboardName: String {
return "Main"
}
static var storyboardBundle: Bundle {
return .main
}
// MARK: -
static var storyboardIdentifier: String {
return String(describing: self)
}
}
With the computed properties implemented, we can provide a default implementation for the instantiate()
method. We load the storyboard using the storyboardName
and storyboardBundle
computed properties. The view controller is instantiated by invoking the instantiateViewController(withIdentifier:)
method, passing in the storyboard identifier as defined by the Storyboardable
protocol. We cast the result of instantiateViewController(withIdentifier:)
to Self
, the type conforming to the Storyboardable
protocol.
We use a guard
statement to instantiate the view controller. If the instantiation of the view controller fails, a fatal error is thrown. The view controller is returned if the instantiation is successful.
extension Storyboardable where Self: UIViewController {
// MARK: - Properties
static var storyboardName: String {
return "Main"
}
static var storyboardBundle: Bundle {
return .main
}
// MARK: -
static var storyboardIdentifier: String {
return String(describing: self)
}
// MARK: - Methods
static func instantiate() -> Self {
guard let viewController = UIStoryboard(name: storyboardName, bundle: storyboardBundle).instantiateViewController(withIdentifier: storyboardIdentifier) as? Self else {
fatalError("Unable to Instantiate View Controller With Storyboard Identifier \(storyboardIdentifier)")
}
return viewController
}
}
Conforming QuotesViewController
to Storyboardable
Before we can use the instantiate()
method of the Storyboardable
protocol, the QuotesViewController
class needs to conform to the protocol. Open QuotesViewController.swift and conform the QuotesViewController
class to the Storyboardable
protocol.
import UIKit
class QuotesViewController: UIViewController, Storyboardable {
...
}
Thanks to the protocol extension, we don't need to implement any of the computed properties and we don't need to implement the instantiate()
method. Open AppCoordinator.swift and navigate to the showQuotes()
method. We can remove the guard
statement and simplify the implementation. We invoke the instantiate()
method on the QuotesViewController
class and assign it to the quotesViewController
constant.
private func showQuotes() {
// Initialize Quotes View Controller
let quotesViewController = QuotesViewController.instantiate()
// Push Quotes View Controller Onto Navigation Stack
navigationController.pushViewController(quotesViewController, animated: true)
}
Build and run the application to make sure we didn't break anything.
Conforming SettingsViewController
to Storyboardable
The QuotesViewController
class instantiates the settings view controller in its settings(_:)
method. Let's simplify the implementation by conforming the SettingsViewController
class to the Storyboardable
protocol. Open SettingsViewController.swift and conform the SettingsViewController
class to the Storyboardable
protocol.
import UIKit
class SettingsViewController: UIViewController, Storyboardable {
...
}
The SettingsViewController
class defines a static, computed property with name storyboardIdentifier
. We can remove the static, computed property since the protocol extension already provides a default implementation.
Open QuotesViewController.swift and navigate to the settings(_:)
method. We can instantiate a SettingsViewController
instance by calling the instantiate()
method on the SettingsViewController
class.
@IBAction func settings(_ sender: Any) {
// Initialize Settings View Controller
let settingsViewController = SettingsViewController.instantiate()
// Present Settings View Controller
present(settingsViewController, animated: true)
}
Build and run the application and tap the Settings button in the top right to make sure we didn't break anything.
Adding Unit Tests
Another benefit of the solution we implemented is increased testability. Instantiating a view controller should never fail and that's something we can guard against by writing a few unit tests. Add a test target to the project by choosing New > Target... from Xcode's File menu. Choose the iOS Unit Testing Bundle template from the iOS > Test section.
The default configuration of the test target is fine.
Expand the QuotesTests group and rename QuotesTests.swift to StoryboardableTests.swift. Rename the QuotesTests
class to StoryboardableTests
and remove the current implementation of the StoryboardableTests
class.
import XCTest
class StoryboardableTests: XCTestCase {
}
To gain access to the internal entities of the Quotes module we need to add an import statement for the Quotes module and prefix the import statement with the testable
attribute.
import XCTest
@testable import Quotes
class StoryboardableTests: XCTestCase {
}
The first unit test tests the instantiation of a QuotesViewController
instance. Create a method with name testQuotesViewController()
.
func testQuotesViewController() {
}
The unit test is very easy to implement. We call the instantiate()
method on the QuotesViewController
class. That's it. The XCTest
framework doesn't provide the ability to assert whether an expression throws a fatal error. We could override the fatalError(_:file:line:)
function to work around this limitation, but isn't an appealing solution. We're automatically notified if one of the unit tests fails because the test suite is interrupted if a fatal error is thrown. That's fine for now.
func testQuotesViewController() {
_ = QuotesViewController.instantiate()
}
Choose Test from Xcode's Product menu or press Command + U to run the test suite. The unit test should pass without issues.
The unit test for the SettingsViewController
class is almost identical. Create a method with name testSettingsViewController()
. In the unit test, we call the instantiate()
method on the SettingsViewController
class.
func testSettingsViewController() {
_ = SettingsViewController.instantiate()
}
Run the test suite one more time to make sure the unit tests pass.
What's Next?
Swift protocols and protocol extensions are quite powerful. This episode has illustrated how to use a protocol and a protocol extension to simplify a project without adding a lot of complexity. We were able to rid the project of a few string literals and made it easier and more elegant to instantiate view controllers from a storyboard.