View controllers are glue-like components that hold an app together. They are responsible for moderating between the model and the view layer. As moderators, they are highly-specialized according to the needs of the module implement the UI usingInterface they belong to. As a result, the controller layer is often the part that is not reusable in other parts of the app or in other apps.
As the controller is responsible for many different tasks, it often tends to become big. It is a good practice to construct the controller layer of one specific feature out of different controller classes. For instance, beginners often put their networking code into the same class that is responsible for filling the UI with information. This results in a so-called god
class, a class that knows and controls everything.
Such classes are hard to write, read, and maintain, and should therefore be avoided. To make the View Controller showing the list of items clean, we will separate the data source and delegate of the table view out into its own class, the data provider. The communication between View Controller and data provider can be defined using protocols. This way, you can swap one implementation for another by just conforming to the protocol. In addition to this, when defining a protocol, you need to think about how to make the API surface (that is, the number of methods that are exposed to other classes) small and easy to understand. The result of this will be a modular architecture with clear separation of tasks into different classes and structs.
In this chapter, we will build the different classes that make up the controller layer of our app. In a later chapter, we will put all the modules we have implemented together in a running app.
We'll cover the following topics in this chapter:
ItemListViewController
DataProvider
DetailViewController
InputViewController
Let's start with the list showing the to-do items. This is the most important View Controller. It is the first view that a user sees when the app has started.
This controller is also responsible for presenting the input screen that allows the user to add to-do items to the list. In addition, it also presents the detail screen that shows the details of selected to-do items.
We first need to structure the files in the Project Navigator a bit in order to enable seamless navigation between the different files. Select the three model files that we already have (ToDoItem.swift
, Location.swift
, and ItemManager.swift
), and hold down the control key while you click on one of the selected files. Xcode presents a menu similar to what's shown in the following screenshot:
Select New Group from Selection and call it Model
. Do the same in the test target with the corresponding test cases.
With an easy-to-navigate project in the Project Navigator, let's return to the TDD workflow. To drive the implementation of ItemListViewController
, we need a test case to collect the tests.
Select the ToDoTests
group and add Unit Test Case Class. Put in the name ItemListViewControllerTest
and click on Next. Create a folder called Controller
and click on Create. As seen in the previous chapters, add the @testable import ToDo
import statement, and remove the two template test methods.
The data will be presented to the user using a table view. We need a test to make sure that ItemListViewController
has a table view and it is set after viewDidLoad()
. Add the following code to ItemListViewControllerTests
:
func test_TableViewIsNotNilAfterViewDidLoad() { let sut = ItemListViewController() }
The static analyzer complains that ItemListViewController
is an unresolved identifier. We have seen this message so often that we already expected this to happen. There is no ItemListViewController
yet. Select the ToDo
group in the Project Navigator in Xcode, and go to File | New | File. Create iOS | Source | Cocoa Touch Class, name it ItemListViewController
, make it a subclass of UIViewController
, and click on Next. Create a folder called Controller
, select it as the destination of the new file, and click on Create. Remove the code within the ItemListViewController
class so that it looks like this:
import UIKit class ItemListViewController: UIViewController { }
To make writing tests easier, set up the Xcode window as you did earlier, with the test case on the left-hand side and the implementation code in the Assistant Editor on the right-hand side. Run the tests to make sure that we have set up everything correctly.
Add the following code at the end of test_TableView_AfterViewDidLoad_IsNotNil()
:
_ = sut.view XCTAssertNotNil(sut.tableView)
The line _ = sut.view
is to trigger the call of viewDidLoad()
. Never call viewDidLoad()
directly.
Again, the static analyzer complains. This is because of Value of type 'ItemListViewController' has no member 'tableView'
. To fix this, add the tableView
property:
var tableView: UITableView?
Run the test. It compiles but fails. This is because we do not test whether the property is present, but if the property is set to a value different from nil
after viewDidLoad()
is called. And we have not done anything in the implementation to set it to some value.
This is the simplest implementation to make the test pass:
override func viewDidLoad() { tableView = UITableView() }
Run the tests to make sure that all the tests pass.
You might wonder why there is no call to super
in viewDidLoad()
. The reason for this is that it is not required. It's good practice to add this call, because you often have a View Controller as a superclass, and you use subclasses for specific implementations of your controller code. In this case, it is very probable that the superclass will do something in viewDidLoad()
that the subclass should also do.
Following the rules of TDD, we've done enough for now and the code looks clean. So, there should be nothing to refactor. But at this point, we need to make a decision. Do we want to implement the UI using Interface Builder (IB) in Xcode, or do we want to implement it completely in code?
IB has improved a lot over the last few years, and using storyboards can speed up the development of a small app, especially when you are not experienced in building user interfaces in code. In addition to this, you get a preview of what the UI would look like while you are building it. For larger projects, I would recommend that you at least have a look at how UIs are built without IB, because it is often easier to reason and maintain.
We will use IB for our project because TDD does not help a lot with UIs, and using IB gives us a clear-cut idea about what to test and what not to because normally, you would not test the position and color of your UI elements.
When we created the project for our app, Xcode added a storyboard file, Main.storyboard
, for the UI. Open Project Navigator and click on Main.storyboard
to open it in IB. You will see something like this:
There is already a scene for a View Controller in the storyboard. Also, there is a ViewController.swift
file from the Xcode template of a Single View Application. We won't use it, so let's remove the file and scene. First, select ViewController.swift
and press the delete key. Then, select the View Controller scene in the storyboard and press the delete key again.
Now we have a clean slate to build the UI. Open the object library by going to View | Utilities | Show Object Library, and drag View Controller onto the storyboard. Change the class in Identity Inspector to ItemListViewController
. Add a table view to the View Controller, make it fill up the scene, and add layout constraints to the edges of the superview:
Open ItemListViewController.swift
in the Assistant Editor and replace the tableView
property with this:
@IBOutlet var tableView: UITableView!
Now, hold the control key, and drag from the table view in the storyboard scene to the tableView
property to connect the two. Remove the implementation of viewDidLoad()
and run the tests. The test_TableView_AfterViewDidLoad_IsNotNil()
test fails because the tableView
property is nil
after viewDidLoad()
is called. The reason for this is that we are not using the storyboard to instantiate the View Controller yet. By calling the ItemListViewController()
initializer, we use the simple init()
initializer. But we need to use the storyboard to create the item list View Controller.
Open the storyboard and set Storyboard ID to ItemListViewController
in Identity Inspector. Replace test_TableView_AfterViewDidLoad_IsNotNil()
with the following code:
func test_TableView_AfterViewDidLoad_IsNotNil() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let viewController = storyboard.instantiateViewController( withIdentifier: "ItemListViewController") let sut = viewController as! ItemListViewController _ = sut.view XCTAssertNotNil(sut.tableView) }
This code first gets a reference to the Main
storyboard, and then it instantiates an instance of ItemListViewController
from the storyboard. This works because we have set the Storyboard ID.
Run the tests. Now, all the tests pass.
As mentioned previously, we would like to put the data source and delegate of the table view into a separate class. Add the following test to ItemListViewControllerTests
to drive the implementation:
func test_LoadingView_SetsTableViewDataSource() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let viewController = storyboard.instantiateViewController( withIdentifier: "ItemListViewController") let sut = viewController as! ItemListViewController _ = sut.view XCTAssertTrue(sut.tableView.dataSource is ItemListDataProvider) }
The assertion makes sure that the data source of the table view is of the ItemListDataProvider
type. To make the test compilable, we first need to add the ItemListDataProvider
class. Select the ToDo group in the Project Navigator, and add an iOS | Source | Cocoa Touch Class called ItemListDataProvide
as a subclass of NSObject
; select the Controller
folder as the destination of the file.
Now the test compiles, but it fails because we need to set an instance of ItemListDataProvider
as the data source of the table view. Let's add a property for the data provider to ItemListViewController
:
@IBOutlet var dataProvider: ItemListDataProvider!
We will connect the data provider with an element in the storyboard. Doing this has the advantage that the data provider is instantiated when the View Controller is loaded from the storyboard.
Open Main.storyboard
and drag an Object from the object
library into the scene in the Document Outline storyboard, as shown in the following screenshot:
In the Identity Inspector, set the class to ItemListDataProvider
. Hold down the Ctrl key, and drag within the Document Outline from the Item List View Controller to the Item List Data Provider, as show in the following screenshot:
In the appearing popup, select dataProvider. This connects the dataProvider
property in ItemListViewController
to the Item List Data Provider object in the storyboard. Remember that we need to make sure that the data provider is set as the data source of the table view after viewDidLoad()
is called. Add the following implementation of viewDidLoad()
to ItemListViewController
:
override func viewDidLoad() { tableView.dataSource = dataProvider }
The static analyzer complains that ItemListDataProvider
does not conform to the UITableViewDataSource
protocol. To fix this, open ItemListDataProvider
and replace the class implementation with the following code:
class ItemListDataProvider: NSObject, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 0 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { return UITableViewCell() } }
Run the tests. All the tests pass. Let's take a look at whether there is something to refactor. In ItemListViewController
, dataProvider
is of type ItemListDataSource
. This is needed to make the connection between IB and the property. But now that we have the connection, we can replace the type with the UITableViewDataSource
protocol:
@IBOutlet var dataProvider: UITableViewDataSource!
With this change, ItemListViewController
only knows that dataProvider
conforms to the UITableViewDataSource
protocol. This means that the two classes are decoupled from each other, and there is a defined interface in the form of the protocol.
Run the tests to make sure that everything still works.
There is more to refactor. We have some code duplication in the test methods. Remove the following code from the test methods:
let storyboard = UIStoryboard(name: "Main", bundle: nil) let viewController = storyboard.instantiateViewController( withIdentifier: "ItemListViewController") let sut = viewController as! ItemListViewController _ = sut.view
Add the var sut: ItemListViewController!
property to ItemListViewControllerTests
, and add this code to setUp()
:
let storyboard = UIStoryboard(name: "Main", bundle: nil) let viewController = storyboard.instantiateViewController( withIdentifier: "ItemListViewController") sut = viewController as! ItemListViewController _ = sut.view
Run the tests again. Everything still works.
Next, we need to make sure that the data provider is also the delegate of the table view. Add the following test to ItemListViewControllerTests
:
func test_LoadingView_SetsTableViewDelegate() { XCTAssertTrue(sut.tableView.delegate is ItemListDataProvider) }
To make the test pass, add the UITableViewDelegate
conformance in the declaration of the dataProvider
property, such that it looks like this:
@IBOutlet var dataProvider: (UITableViewDataSource & UITableiewDelegate)!
Add the following line at the end of viewDidLoad()
:
tableView.delegate = dataProvider
Run the tests. All the tests pass.
The data source and delegate need to be the same instance because otherwise, selecting a cell could result in showing the details of a completely different item. Add the following test:
func test_LoadingView_SetsDataSourceAndDelegateToSameObject() { XCTAssertEqual(sut.tableView.dataSource as? ItemListDataProvider, sut.tableView.delegate as? ItemListDataProvider) }
Run the tests. All the tests pass. This is already implemented.