Reusable data sources in Swift
Lists and collections of items are arguably two of the most common types of UIs found in apps. Whether we're building an app for browsing content, for communicating with others, or for performing purchases - many apps render a big part of their data through a UITableView
or a UICollectionView
.
One thing both table- and collection views have in common is their use of a data source object to provide them with the cells and data to display. While this pattern can initially seem pretty heavy handed and be a major source of boilerplate - it's a core part of what makes this type of views capable of easily reusing cells and to do a ton of performance optimizations on our behalf.
This week, let's take a look at how we can implement data sources for table- and collection views in a more reusable manner, and how doing so can let us make our list-based UI code more composable and easier to work with. While all code samples in this post will be based on table views, the exact same techniques can also be used for collection views. Let's dive in!
Moving out of view controllers
Just like other code that heavily relates to displaying and handling interactions from the UI, it's easy for data source implementations to end up in our view controllers. For example, let's say that we're building an email client app, and that we're using a UITableView
to render the user's inbox in an InboxViewController
. A very common solution would be to have that view controller act as its table view's data source, like this:
extension InboxViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
return messages.count
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let message = messages[indexPath.row]
let cell = tableView.dequeueReusableCell(
withIdentifier: "message",
for: indexPath
)
cell.textLabel?.text = message.title
cell.detailTextLabel?.text = message.preview
return cell
}
}
While the above approach is nice for simpler, one-off table view controllers that don't contain a lot of logic, things can start to become quite messy if we either need to have our view controller also perform several other tasks (which is usually the case) - or if we want to reuse the same data source logic elsewhere in our app.
For example, chances are pretty high that our email app needs to render messages in many different places - for example in a list containing sent messages, archived ones, or maybe in a Drafts or Folders view. In this kind of situation, being able to easily reuse our data source code can be really nice, as it lets us build new UIs that are based on the same underlying data model very quickly.
One way of doing so is to simply move all data source-related logic out from our view controllers and into separate classes - and to have those classes conform to UITableViewDataSource
instead, like this:
class MessageListDataSource: NSObject, UITableViewDataSource {
// We keep this public and mutable, to enable our data
// source to be updated as new data comes in.
var messages: [Message]
init(messages: [Message]) {
self.messages = messages
}
func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
return messages.count
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let message = messages[indexPath.row]
let cell = tableView.dequeueReusableCell(
withIdentifier: "message",
for: indexPath
)
cell.textLabel?.text = message.title
cell.detailTextLabel?.text = message.preview
return cell
}
}
The benefit of the above approach, is that we start moving towards more of a plugin-style UI architecture, where we can quickly and easily assemble new UIs and features by plugging in existing building blocks. Just like with child view controllers, things tend to become easier to reason about once they are broken down into separate parts that can be worked on more in isolation.
Generalizing
Breaking code up into separate types can be a great solution to avoid massive classes, but can sometimes lead to code duplication. While duplication isn't necessarily bad - sometimes some light duplication is better than creating abstractions that are too wide and hard to customize - but it's usually nice to not have to repeatedly write highly similar code.
When it comes to table- and collection view data source code, we often want to perform the exact same tasks for many (sometimes even all) of our models. We usually want to display one cell for each model instance, and we always need to perform the same dequeueing dance for each type of data.
Let's see if we can introduce a very lightweight abstraction to make this nicer, by generalizing our dedicated data source class from before. Rather than having an implementation that's specifically tied to rendering Message
models, let's create a generic class that can render any model, given a cell reuse identifier and a closure that configures a table view cell for a given model - like this:
class TableViewDataSource<Model>: NSObject, UITableViewDataSource {
typealias CellConfigurator = (Model, UITableViewCell) -> Void
var models: [Model]
private let reuseIdentifier: String
private let cellConfigurator: CellConfigurator
init(models: [Model],
reuseIdentifier: String,
cellConfigurator: @escaping CellConfigurator) {
self.models = models
self.reuseIdentifier = reuseIdentifier
self.cellConfigurator = cellConfigurator
}
}
Above we use a closure-based approach, but an alternative could also be to use the configurator pattern, like in "Preventing views from being model aware in Swift".
With the above in place, we can now implement UITableViewDataSource
without any knowledge of any model implementation details, by simply using the Model
array and configuration closure that was injected in our data source's initializer - like this:
func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
return models.count
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let model = models[indexPath.row]
let cell = tableView.dequeueReusableCell(
withIdentifier: reuseIdentifier,
for: indexPath
)
cellConfigurator(model, cell)
return cell
}
Now, whenever we need a data source for displaying an array of models, we can simply create an instance of TableViewDataSource
, for example to replace our previous MessageListDataSource
:
func messagesDidLoad(_ messages: [Message]) {
let dataSource = TableViewDataSource(
models: messages,
reuseIdentifier: "message"
) { message, cell in
cell.textLabel?.text = message.title
cell.detailTextLabel?.text = message.preview
}
// We also need to keep a strong reference to the data source,
// since UITableView only uses a weak reference for it.
self.dataSource = dataSource
tableView.dataSource = dataSource
}
Pretty neat! 👍 And the cool thing is that we can now use the same class to render any other models we most likely have - for example contacts, drafts, templates, and so on - and we can even add static convenience methods to make it extra easy to create data sources for our most common models:
extension TableViewDataSource where Model == Message {
static func make(for messages: [Message],
reuseIdentifier: String = "message") -> TableViewDataSource {
return TableViewDataSource(
models: messages,
reuseIdentifier: reuseIdentifier
) { (message, cell) in
cell.textLabel?.text = message.title
cell.detailTextLabel?.text = message.preview
}
}
}
Adding those kind of convenience methods doesn't only further decrease the need to duplicate code, it also enables us to create data sources using dot syntax in a very nice way:
func messagesDidLoad(_ messages: [Message]) {
dataSource = .make(for: messages)
tableView.dataSource = dataSource
}
Changes like the above might seem purely cosmetic at first, but can really have a big positive impact on developer productivity, especially when we're working on an app that requires quick iterations and experimentation - as creating most data sources is no longer a big deal.
Composing sections
Let's continue exploring the idea of reusable data sources one bit further. So far we've only implemented data sources that render a single section, but sometimes we want to render multiple ones, each with their own type of data. For example, in our email app, we might want to implement a HomeViewController
that shows the user a list of recent contacts as well as the top messages from the user's inbox.
While this could be done using a new dedicated object that takes all the data for the home screen and provides it through a custom UITableViewDataSource
implementation, let's try to use composition to combine multiple small data sources together to form the one we need.
To do that, let's implement a SectionedTableViewDataSource
that simply takes an array of other data sources and uses each of them to form a section within the table view it's providing data to. We'll start out like this:
class SectionedTableViewDataSource: NSObject {
private let dataSources: [UITableViewDataSource]
init(dataSources: [UITableViewDataSource]) {
self.dataSources = dataSources
}
}
We'll then conform to UITableViewDataSource
by forwarding most calls down to the underlying data source matching the current index path's section index:
extension SectionedTableViewDataSource: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return dataSources.count
}
func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
let dataSource = dataSources[section]
return dataSource.tableView(tableView, numberOfRowsInSection: 0)
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let dataSource = dataSources[indexPath.section]
let indexPath = IndexPath(row: indexPath.row, section: 0)
return dataSource.tableView(tableView, cellForRowAt: indexPath)
}
}
With the above in place, we can now easily create sectioned data sources with almost no code duplication - and combined with our convenience APIs for creating model-specific data sources from before, we can start composing data sources made up from multiple sets of models with ease:
let dataSource = SectionedTableViewDataSource(dataSources: [
TableViewDataSource.make(for: recentContacts),
TableViewDataSource.make(for: topMessages)
])
This is really the power of composition in general, the fact that we don't always have to start out with a new type, but can instead often compose functionality from existing ones.
Conclusion
By writing data sources that are a bit more generic, we can often end up reusing the same code in multiple parts of an app where the same kind of models are rendered. Since data sources are primarily based on index paths, they also lend themselves very well to composition - since the core functionality of a data source can often be performed in a completely model agnostic way.
While there are definitely times where a custom, highly specialized, implementation of UITableViewDataSource
or UICollectionViewDataSource
is in order - the goal of reusable data sources (such as the examples in this post) is to eliminate much of the boilerplate and duplication that's often involved when writing data sources that perform simple data binding.
What do you think? Are you usually building your data sources as separate, reusable objects - or is it something you'll try out? Let me know - along with your questions, comments or feedback - on Twitter @johnsundell.
Thanks for reading! 🚀