Populating views with data, from zero to Hero
TL;DR - Stop putting your data source on your view controller when populating a table view or a collection view
As iOS developers we have had to populate a UITableView
countless amounts of times. A lot of this times we write the same code in the same application multiple times. This post is about thinking about go a table view can be populated in a way that provides testable and reusable components. Note that I’m assuming that you’re familiar with how to populate a UITableView
and you have a reasonable knowledge about Swift. The post is not meant to be read as a tutorial but more like a journey to understand how the architecture can be improved. The code from this post can be downloaded from DataArchitectureSample.
Problem statement
Let’s say you have to complete the following task:
"Display a list of countries using a custom style."
For the sake of simplicity this could look something like this:
Disclaimer: No idea if the list of countries is correct, got it from a dodgy looking website…
Ways of solving this problem
To start solving this problem I will define some types to provide context. Let’s say we have already created a new Xcode project with a UIViewController
subclass with a UITableView
that has a custom UITableViewCell
with a nib (called CountryTableViewCell
).
final class CountryTableViewCell : UITableViewCell {
@IBOutlet var countryNameLabel: UILabel!
}
Also for simplicity, each country is represented by a very basic Country
struct:
struct Country {
let name: String
}
A convenience struct returns a mock list of countries:
struct CountryLoader {
static func allCountries() -> [Country] { ... } // Returns an array of countries
}
1. MVC (Massive View Controller)
The first solution and most basic solution is the gold old Massive View Controller. This barely needs introduction, effectively this pattern suggests that everthing should go into one view controller. For example, in this case the conformance to UITableViewDataSource
. The code is pretty self explanatory, so I won’t go into much detail:
private let reuseIdentifier = "CountryTableViewCell"
final class MassiveViewController: UIViewController, UITableViewDataSource {
@IBOutlet weak var tableView: UITableView!
private let countries: [Country] = CountryLoader.allCountries()
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
let customCellNib = UINib(nibName: "\(CountryTableViewCell.self)", bundle: nil)
tableView.registerNib(customCellNib, forCellReuseIdentifier: reuseIdentifier)
}
//MARK: UITableViewDataSource
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCellWithIdentifier(reuseIdentifier) as? CountryTableViewCell else {
fatalError("Could not dequeue cell with identifier: \(reuseIdentifier)")
}
let country = countries[indexPath.row]
cell.countryNameLabel?.text = country.name
return cell
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return countries.count
}
}
Naturally this doesn’t look very massive since it’s only a UIViewController
with a very simple UITableView
without a UITableViewDelegate
. However, hopefully it’s possible to understand how this can get very big very quickly after adding table view sections and user interaction among other things. It would quite hard to write unit tests for this code. If you had to draw the architecure of this, it would like this…
2. Extracting the UITableViewDataSource
to another object
A very good improvement to the MVC pattern is extract the UITableViewDataSource
onto another object. I first read about this in 2013 on the Lighter View Controllers article from objc.io’s first issue. For our case, the code would look something like this:
private let reuseIdentifier = "CountryTableViewCell"
final class LighterViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
let countriesDataSource = CountriesDataSource(reuseIdentifier: reuseIdentifier)
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = countriesDataSource
let customCellNib = UINib(nibName: "\(CountryTableViewCell.self)", bundle: nil)
tableView.registerNib(customCellNib, forCellReuseIdentifier: reuseIdentifier)
}
}
final class CountriesDataSource: NSObject, UITableViewDataSource {
private let countries: [Country] = CountryLoader.allCountries()
private let reuseIdentifier: String
init(reuseIdentifier: String) {
self.reuseIdentifier = reuseIdentifier
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCellWithIdentifier(reuseIdentifier) as? CountryTableViewCell else {
fatalError("Could not dequeue cell with identifier: \(reuseIdentifier)")
}
let country = countries[indexPath.row]
cell.countryNameLabel?.text = country.name
return cell
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return countries.count
}
}
This is certainly an improvement since now we have a much smaller view controller. We could add some unit tests to the CountriesDataSource
and reuse it somewhere else if we wished to as well. This could be drawn like this:
3. Introducing the DataProvider
The previous idea is great and I have used it for quite some time but it could be improved. The data itself is a bit too coupled to the UITableViewDataSource
. We could extract this responsibility into a new object. This new object could be defined by a new DataProvider
protocol that uses an associatedtype to define the model object:
protocol DataProvider {
associatedtype DataObject
func objectAtIndexPath(indexPath: NSIndexPath) -> DataObject?
func numberOfItemsInSection(section: Int) -> Int
}
Then we can create a CountriesDataProvider
type that will provide the countries data to the data source:
struct CountriesDataProvider : DataProvider {
private let allCountries: [Country] = CountryLoader.allCountries()
func objectAtIndexPath(indexPath: NSIndexPath) -> Country? {
return allCountries.indices.contains(indexPath.row) ? allCountries[indexPath.row] : nil
}
func numberOfItemsInSection(section: Int) -> Int {
return allCountries.count
}
}
This may not seem that much because the example is so simple but now we have extracted the data from the table view datasource. This means we can now easily test the data is provided correctly, for example if the order is correct. Similarly, the source of the data could be changed by swapping only the CountriesDataProvider
for another object that conforms to the DataProvider
protocol. For example, if the data now comes from CoreData instead of an array of countries.
The next step is to move the registering of the custom nib for the cell to our original CountriesDataSource
and make it retrieve the data from the CountriesDataProvider
. At this point I have renamed the CountriesDataSource
because the responsibility has changed slightly as it no longer is just a data source.
private let reuseIdentifier = "CountryTableViewCell"
final class CountriesDataCoordinator : NSObject, UITableViewDataSource {
let countriesDataProvider = CountriesDataProvider()
init(tableView: UITableView) {
super.init()
let customCellNib = UINib(nibName: "\(CountryTableViewCell.self)", bundle: nil)
tableView.registerNib(customCellNib, forCellReuseIdentifier: reuseIdentifier)
tableView.dataSource = self
}
//MARK: UITableViewDataSource
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCellWithIdentifier(reuseIdentifier) as? CountryTableViewCell else {
fatalError("Could not dequeue CountryTableViewCell with identifier: \(reuseIdentifier)")
}
if let country = countriesDataProvider.objectAtIndexPath(indexPath) {
cell.countryNameLabel?.text = country.name
}
return cell
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return countriesDataProvider.numberOfItemsInSection(section)
}
}
A view controller that uses the CountriesDataCoordinator
would look similar to the previous one though slightly lighter as there is no reference to a custom UITableViewCell
(Why should the view controller need to know about the type of cell anyways?):
final class DataCoordinatorViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
var countriesDataCoordinator: CountriesDataCoordinator?
override func viewDidLoad() {
super.viewDidLoad()
countriesDataCoordinator = CountriesDataCoordinator(tableView: tableView)
}
}
This new architecture could be drawn like this:
4. Using generics to rewriting the UITableViewDataSource
code
As you may or may not have figured out from the title (the <Hero> part) the final solution I will explain will use generics. Wouldn’t it be great if we didn’t have to write the UITableViewDataSource
and UICollectionViewDataSource
over and over again? As an iOS developer I rarely write anything custom for these protocol that cannot be reused. After several years, I’m tired of writing this, and I’ll like my code DRY.
The first thing to do is create a ConfigurableCell
that the custom cell will conform to. There is an associatedtype
used for the model object that will be used to configure the cell.
protocol ConfigurableCell {
associatedtype DataObject
func configureForObject(object: DataObject)
static func reuseIdentifier() -> String
}
Making our original CountryTableViewCell
conform to ConfigurableCell
would look something like this:
extension CountryTableViewCell: ConfigurableCell {
func configureForObject(country: Country) {
countryNameLabel.text = country.name
}
static func reuseIdentifier() -> String {
return "CountryTableViewCell"
}
}
Now comes one of the hardest things to understand about this post. We need to make our original data coordinator into a generic TableViewDataCoordinator
. This is key to avoid writing the UITableViewDataSource
code over and over again. The generic defition could be broken down in into 4 parts:
- A generic
DataProviderType
needs to conform toDataProvider
- i.e.
DataProviderType: DataProvider
- A genericCellType
needs to inherit fromUITableViewCell
- i.e.CellType: UITableViewCell
- The genericCellType
needs to conform toConfigurableCell
- i.e.CellType: ConfigurableCell
- TheDataObject
from theCellType
needs to be the same as theDataObject
from theDataProviderType
, so that the objects the data provider vends can be used to configure the cell - i.e.CellType.DataObject == DataProviderType.DataObject
- i.e.
Putting all these statements together we get:
<DataProviderType: DataProvider, CellType: UITableViewCell where CellType: ConfigurableCell, CellType.DataObject == DataProviderType.DataObject>
Now that we have our generic definition for the generic data coordinator, we now replace the specific types from the previous CountriesDataCoordinator
with the generic types and update the cell using the new configureForObject(_:)
method.
class GenericTableViewDataCoordinator<DataProviderType: DataProvider, CellType: UITableViewCell where CellType: ConfigurableCell, CellType.DataObject == DataProviderType.DataObject> : NSObject, UITableViewDataSource {
private let dataProvider: DataProviderType
init(tableView: UITableView, dataProvider: DataProviderType) {
self.dataProvider = dataProvider
super.init()
tableView.dataSource = self
}
//MARK: UITableViewDataSource
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCellWithIdentifier(CellType.reuseIdentifier()) as? CellType else {
fatalError("Could not dequeue cell of type: \(CellType.self) with identifier: \(CellType.reuseIdentifier())")
}
if let object = dataProvider.objectAtIndexPath(indexPath) {
cell.configureForObject(object)
}
return cell
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataProvider.numberOfItemsInSection(section)
}
}
Subclassing the reusable GenericTableViewDataCoordinator
and specializing it with our previous CountriesDataProvider
and CountryTableViewCell
we create the CountriesTableViewDataCoordinator
. This can then be used in a view controller to populate a table view.
final class CountriesTableViewDataCoordinator : GenericTableViewDataCoordinator<CountriesDataProvider, CountryTableViewCell> {
init(tableView: UITableView, countriesDataProvider: CountriesDataProvider = CountriesDataProvider()) {
let customCellNib = UINib(nibName: "\(CountryTableViewCell.self)", bundle: nil)
tableView.registerNib(customCellNib, forCellReuseIdentifier: CountryTableViewCell.reuseIdentifier())
super.init(tableView: tableView, dataProvider: countriesDataProvider)
}
}
These concepts can be applied in a very similar fashion to create a GenericCollectionViewDataCoordinator
and CountriesTableViewDataCoordinator
. If it’s not immediately obvious you can check the files on the github repository. Putting it altogether into a view controller:
final class GenericDataCoordinatorViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var collectionView: UICollectionView!
var tableViewDataCoordinator: CountriesTableViewDataCoordinator?
var collectionViewDataCoordinator: CountriesCollectionViewDataCoordinator?
override func viewDidLoad() {
super.viewDidLoad()
let countriesDataProvider = CountriesDataProvider()
// Example of how the CountriesDataProvider can be reuse for different coordinators
tableViewDataCoordinator = CountriesTableViewDataCoordinator(tableView: tableView, countriesDataProvider: countriesDataProvider)
collectionViewDataCoordinator = CountriesCollectionViewDataCoordinator(collectionView: collectionView, countriesDataProvider: countriesDataProvider)
}
}
This new architecture could be drawn like this:
Conclusion
Naturally, when comparing the MassiveViewController example with the generic data coordinator example there is a lot more code. However, it’s important to understand that this “extra” code will only need to be written once and so in most cases there is huge value in following this pattern.
There is definitely a lot to take in and the concepts describe here are by no means new. The basic concepts come from reading the Lighter View Controllers article from objc.io and the code from SPXDataSources. Swift has allowed me to extend some of these concepts further by using generics, to have reusable and type-safe components. In the interest of keeping an already long post short, this architecture is only the tip of the iceberg and hopefully it’s a good starting point. For example, it can be extended to use view models, to avoid having to pass the model itself to the cell.
Advantages
- Highly reusable, it’s very easy to setup a new
UITableView
once the pattern is implemented, i.e. DRY - Very modular approach where the objects have clear responsiblity, i.e SRP
- Easy to test that the various components do what they are meant to do
- Easy to interchange
UITableView
forUICollectionView
and viceversa if needed - Easy to change the data backing, e.g. from backing a
UITableView
with a static array to an array of CoreData entities using aNSFetchedResultsController
Limitations
- One reusable identifier per cell class as per the
ConfigurableCell
protocol - If multiple cell classes are needed, some type safety will be lost as the
DataObject
would need to be a protocol or a base class - Going from the “Massive View Controller” approach to generic data coordinators, may take some time to get you head around it if you are new to Swift and generics