Today for my come-back post (hopefully), I’ll be talking about a tip for your UITableViewDataSource
and UITableViewDelegate
. The idea is in fact applicable to a lot of other things following the same delegate
pattern, but the use case of UITableView
will hopefully be the most concrete example for most people.
The idea
A lot of developers, me included, have taken the habit of making your UIViewController
the delegate
and dataSource
of their UITableView
. That’s all good and fine, but in effort to minimize the code size of your UIViewController
(you know, that Massive-View-Controller thing all we want to avoid) and limit the responsibility of your UIViewController
(separation of concern), there’s a better solution.
The general idea is in fact very simple: extract your UITableViewDataSource
and UITableViewDelegate
into a separate object. And make e.g. your UIViewController
retain that object and set it as the tableView
’s delegate
and dataSource
.
In practice, you can move all the responsibility to a single object (making it both the delegate and dataSource, and making it the responsibility to provide the cells), or you can make that separate objects (one for delegate, one for dataSource), or you can make that separate object just be a proxy, that will be reusable for multiple ViewControllers, and delegate more simple queries to each UIViewController
using it to customize just the right things.
Concrete example
Let’s start with a very simple example: a list of products.
struct Product {
let name: String
let category: String
let price: Double
}
Splitting your DataSource into a dedicated type would look like that:
class ListDataSource: NSObject {
let products: [Product]
init(products: [Product]) {
self.products = products
}
func product(at indexPath: IndexPath) -> Product {
return self.products[indexPath.row]
}
}
extension ListDataSource: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.products.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return cell(for: product(at: indexPath))
}
func cell(for product: Product) -> UITableViewCell {
// Of course in a real project we'd dequeue a custom cell from tableView, etc.
let cell = UITableViewCell()
cell.textLabel?.text = product.name
return cell
}
}
And then you’ll use it this way in your UIViewController
:
class DemoViewController : UIViewController {
var tableView: UITableView!
var currentDataSource: UITableViewDataSource? {
didSet {
self.tableView.dataSource = currentDataSource
self.tableView.reloadData()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let products = loadProducts()
self.currentDataSource = ListDataSource(products: products)
}
}
In that very simplistic example, the benefits might not seem very visible at first, especially because for now our DemoViewController
doesn’t do much yet. But in a real world example where that DemoViewController
probably does a lot more — like some logic specific to your app, or handling any @IBAction
you have on the rest of your screen, etc — I hope you can see how at least extracting all the UITableView
display logic would help here.
It’s also generally easier to reason about, as all the logic about the display of that DataSource is now extracted in its dedicated file & class, instead of it being mixed with the rest of the code of your UIViewController
, and that separation will probably help you when tweaking or editing the way the UITableView
should display its data, by focusing on one specific and isolated file, instead of scrolling amongst other unrelated code.
Retain your DataSources!
One tricky thing however when you put your dataSource in a dedicated, separate object, is to not forget to retain it.
The dataSource
property on UITableView
is weak
(as all dataSources and delegates should be), so if you just affect tableView.dataSource
to a newly created ListDataSource(…)
without retaining it, that ListDataSource
instance will then be released from memory and your UITableView
will go back to being empty.
So when you use that trick, just don’t forget to retain your object serving as DataSource. In the example above, I made the DemoViewController
retain it by using a var currentDataSource
property, and used that occasion to use a didSet
on it to propagate it to the tableView
and reload it afterwards.
Improving the DataSource
Now, imagine that without changing your app’s logic itself, you just wanted to adjust the way the list of products were displayed, e.g. group your products in sections according to their category.
All you’d have to do is to modify the implementation of your DataSource
class, without changing anything in your DemoViewController
!
But let’s take that one step further: instead of modifying the existing DataSource, let’s keep it but also duplicate it to create a new one (SectionDataSource
below). By keeping both of them, we’ll then be able to switch between the two at runtime later!
So let’s create our new SectionDataSource
, very similar to the first one, but which now handles sections:
class SectionsDataSource: NSObject {
struct Section {
let title: String
let items: [Product]
}
let sections: [Section]
init(products: [Product]) {
// Dispatch products into a dictionary according to their category
let groups = Dictionary(grouping: products, by: { product in product.category })
// Convert that dictionary into an array of Sections, then sort it by section title
self.sections = groups.map(Section.init).sorted(by: { $0.title < $1.title })
}
func product(at indexPath: IndexPath) -> Product {
return self.sections[indexPath.section].items[indexPath.row]
}
}
extension SectionsDataSource: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return self.sections.count
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return self.sections[section].title
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.sections[section].items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return cell(for: product(at: indexPath))
}
func cell(for product: Product) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = product.name
return cell
}
}
And now that we have our new SectionsDataSource
class, all we have to do in our DemoViewController
is… replace the self.currentDataSource = ListDataSource(products: products)
line by self.currentDataSource = SectionsDataSource(products: products)
, and that’s all!
See how everything continues to work, while the majority of your changes was located in a dedicated file and your ViewController was barely affected. That also helps to focus your editing in one specific class in a single file, instead of finding in a Massive ViewController which lines to modify.
Switching DataSources
Now that we have both ListDataSource
and SectionsDataSource
, we can easily imagine switching them at runtime.
You could just add a UISegmentedControl
or similar to your UI, and depending on which segment gets selected, you’d set self.currentDataSource
to either ListDataSource(products: products)
or SectionsDataSource(products: products)
, and that’s all!
Imagine if you had to do all that logic in your DemoViewController
directly instead of having separate objects… That would very likely mean:
- A lot of
if
tests insidenumberOfRowsInSection
andcellForRowAtIndexPath
methods to return different things depending on the mode (flat list or sections by categories) - Be sure to make those
if
conditions consistent (have the same conditions and branches innumberOfRowsInSection
andcellForRowAtIndexPath
so that the count returned in one matches the object index used in the other) - Either two properties to hold the data (an array of Products for the flat list, and an array of Sections for the sectionned list mode), or just use an array of Sections and trick the logic by using an array with only one section in the case of the flat list… not very consistent and readable months later anyway…
A lot could go wrong in this setup, from mismatching the conditions in different methods you have to implement to having to focus on understanding the logic as a whole and understand the big picture at once to see how everything works together, with risks of inconsistencies along the way.
But instead, because we separated the concerns of the UITableViewDataSource
in their own classes, it’s all easier to read and reason about!
Going further
From there we can go way further and improve this a lot:
- Of course we can imagine adding more and more classes for displaying your
UITableView
differently - But we could also imagine make those DataSources generic over a type
T
(instead of using them only for theProduct
type) to reuse them to display other types elsewhere in our application - We could then accept a closure in the
init
ofSectionsDataSource<T>
to tell how to extract the section name (String
) for eachT
(where before we hardcoded that it wasproduct.category
) - We could extend the idea to
UITableViewDelegate
- We could allow to customize the cells
UITableViewDelegate
For extending the idea to UITableViewDelegate
, you could go multiple ways:
- You could either imagine making your existing classes extend
UITableViewDelegate
, so that you’d use the same dedicated object for both being thedataSource
and thedelegate
of yourtableView
:extension SectionsDataSource: UITableViewDelegate
, etc. - Or you could decide to use an entirely separate object again, so you’d have one dedicated object for your
tableView.dataSource
, and a different one to use as yourtableView.delegate
Both options are valid, it depends of your needs and what your dedicated implementations are doing. The fact that for UITableView
, the delegate and dataSource are often closely related might make you go towards the first solution though 1.
Also, imagining you go with the first route (use the same object for dataSource and delegate), you could give closures to that object on init
to tell it what to do especially when a Product
is selected, then forward delegate method to call that closure:
// let onProductSelected: (Product) -> Void
extension ListDataSource: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
self.onProductSelected(product(at: indexPath))
}
}
Once again, the benefit of that is, if you do the same on the SectionsDataSource
, then just switching your currentDataSource
from ListDataSource
to SectionsDataSource
wouldn’t require to change your ViewController
, which would still work as expected, even if in one case the list is flat and in the other it’s structured in sections!
That’s because now your DataSource object, whichever one is used, is responsible for translating your IndexPath
into Product
instances, so that you don’t have to care about how those products are organized in the DataSource, all you have to implement in your ViewController
is what to do when a given Product
is selected (instead of when a given IndexPath
is selected), abstracting away all that IndexPath->Product
translation logic out of your ViewController.
Allowing custom cells
For allowing custom cells to be provided by the outside (i.e. being able to use the same ListDataSource
for different tableViews
which are using different cells, without having to duplicate ListDataSource
for each cell type), once again you have multiple solutions, some of them being:
- Provide a
cellFactory
closure to your DataSource classes atinit
, to tell how to build and return anUITableViewCell
from aProduct
- Provide the cell class to use to instantiate each cell (alongside its
cellIdentifier
), but make it sure that cell class conforms to some protocol declaring a method likefunc fill(with product: Product)
that your cell class would have to implement, and that yourListDataSource
andSectionsDataSource
would call after instantiating the cell to populate it - Make your DataSource classes themselves have a
delegate
property of some custom protocol type (e.g.protocol CellProvider
), and conform to that protocol to implement how to build and fill a cell with aProduct
There are probably other ways to let your UIViewController
tell how to build each cell for cases where you want that flexibility, all being quite similar in concept, but I hope that gives you some interesting ideas.
Explore & Playground
It’s time for you to play with all those concepts yourself! Especially the ones I didn’t detail much in that last part (Generics, etc) could be interesting to explore on your own. But if you’re curious about my take on one possible solution, I’ve attached a playground for you to browse the code we discussed — and even a solution using Generics so those DataSources are reusable for types other that Products
in other places in the same app.
Also, keep in mind that this idea we just applied is indeed well suited for UITableViewData
& UITableViewDelegate
, but can also be used for other delegate
patterns as well 😉
Happy Swifting!
-
For example, the
titleForHeaderInSection
function is in theUITableViewDataSource
protocol, but theviewForHeaderInSection
function is in theUITableViewDelegate
protocol… go figure; also, they both expect the cells to be indexed the same way by yourIndexPaths
, so using a delegate which assumes one way of organizing your data (e.g. in a flat list) and a dataSource which assumes another (e.g. in sections) probably wouldn’t fit well together ↩