UITableView
is everywhere in the world of iOS. Yet, it relies on a relatively unusual API design – delegates, dataSources, and object queuing. Why did Apple design UITableView
in such a seemingly confusing way? To understand how this popular class is working, let’s break it down all the way to the UIScrollView
and reimplement our own open, drop-in replacement for UITableView
…
If you want to follow along in Xcode, the LSCTableView implementation is available on GitHub.
The Objective
Our custom reimplemention of UITableView
and UITableViewCell
– LSCTableView
and LSCTableViewCell
, respectively – will both use the same parent classes as their UIKit counterparts: LSCTableView
will be a subclass of UIScrollView
, and LSCTableViewCell
will be a subclass of UIView
.
To highlight how Apple (might) implement UITableView
, we will implement a version of UITableView
that’s an API-compatible, open, drop-in replacement for the original Cocoa Touch class.
Implementation Strategy
UITableView
is deeply complex and it’s worth exploring a few core concepts and strategizing some, before writing any code.
Arranging subviews in LSCTableView
First, let’s take a step back. If you wanted to manually implement a UIScrollView
that looked like a table – forgetting about delegates, data sources, and object queueing – you’d probably just add a bunch of subviews to a scroll view until you achieve something that looks like a table.
Since this is going to be a table, let’s assume you’d organize every subview into a big set of individual ‘rows’. These ‘rows’ (as we’ll call them) would be rectangular. They would all be the full width of the table, and each would have some arbitrary height.
With these constraints in place, we would be able to calculate the frame
for any row using only two pieces of data: row height, and height of all of the rows before it (or, the y-offset). Let’s think about how to do that…
First, how would we obtain information for the row height?
It turns out UITableViewDelegate
offerstableView:heightForRowAtIndexPath:
which gives us just that (and, if the programmer doesn’t implement this method, UITableView
will use a standard, default value).
And how would we capture the y-offset?
Well, if we captured all of the row heights at once and cache them, we could just calculate it… and it turns out the documentation for tableView:heightForRowAtIndexPath:
mentions this:
Every time a table view is displayed, it calls tableView:heightForRowAtIndexPath: on the delegate for each of its rows…
This means our implementation will capture and cache the row height for every row at a single time (this ‘single time’ is a deliberate part of our implementation… I’ll touch more on this, later). And, with all of that information cached, the y-offset of every row can just be calculated with a basic calculation.Since we said earlier that every row will be the full-width of the table, and now that we know we’ll be caching the height and y-offset of any row, we have all of the data we need to calculate the frame
for any row in the table!
Managing memory for unlimited rows
Let’s also consider how we’ll manage the memory of all of the components of the table.
For performance reasons, LSCTableView
can’t keep an instance of every row’s subview in memory, all the time. If a table had 100,000,000 rows, that would require lots of memory!
In order for the table view to perform well, regardless of the number of rows it has to contain, and while minimizing memory consumption, it will have to maintain only as many instances of row subviews (or ‘cells’) in memory as is necessary. This is where the UITableViewDataSource
protocol comes in handy.
(Sidenote: After reverse engineering UITableView
, I noticed that Apple chooses the word ‘row’ to describe the concept of an item in the table (i.e. tableView:heightForRowAtIndexPath:
), whereas the word ‘cell’ usually refers to an instance of a subview (i.e. UITableViewCell
, a subclass of UIView
). Browse the UITableView
-related APIs, and you’ll see what I mean.)
In the UITableViewDataSource
protocol, there’s the method tableView:cellForRowAtIndexPath:
. If you’ve ever placed an NSLog
in that method before, you’ll notice that this method gets called each time a cell appears on screen.
From this, we can deduce that Apple is clearly initializing cells only once they become visible.
And how do we know what cells should be visible?
In the last section, we talked about caching the row heights and y-offset data for everything in the table. Figuring out the currently visible cells would be a simple calculation using this data model and comparing it to the bounds
property (reminder: in UIScrollView
, the bounds
property indicates the visible region of the entire contentView
).
A picture may be starting to form in your head about how this is all coming together. Let’s jump into some code…
Implementing LSCTableView
Caching the table structure
Now that we’ve determined that we have to maintain an internal cache of the table structure, we’ll need to come up with a modeling system for this cache.First off, the most pragmatic way to design the model will be to group all data at the section level. A section model can contain all of the info regarding the rows it contains. Also, with this grouping structure, we can store all of the section models in an NSArray
, and the array index will map directly to the section number of each section.Let’s implement a ‘global index’ for every row, too. (That is, if there’s 2 sections of 10 rows, the last row of the second section would have a global row index of 19.) This will allow us to pass a single number around our implementation, and rely on ‘dumb’ mapping methods to take care of translating that number to a specific row.
We can also carefully design the model to have a few high-level properties that will allow us iterate over the entire collection of models faster, when we’re trying to identify specific rows or regions of the table view. These properties include: globalIndexOfFirstRow
, totalHeight
, and yOffset
.
Let’s take a look at the section model header:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
We implement publicly assignable properties for each property, and the rowHeights
property is a pointer to a C array, which is assigned using a custom setter method. By selecting these data points, we’ll have the information we need to calculate the height and y-offset for all subviews within a section. We’ll be able to do this using a handful of basic, fast equations, too.
All of this data will be captured through delegate
and dataSource
protocol methods. Take a look at how that’s implemented:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 | |
Notice how some parts of the implementation check to see whether the delegate
or dataSource
implements a method and, if not, falls back on default values. This has the effect of making these protocol methods @optional
for the end-user to implement. The result of that behavior is appropriately explained in the documentation, and usually means constant values are used, here.
Similarly, for the @required
protocol methods, such as tableView:numberOfRowsInSection:
, we don’t check whether the dataSource
will respond; we simply call them. If they are not implemented, they will result in an exception being thrown (as is the case with UITableView
).
Now we need to determine the appropriate time to capture the table structure.
Referring to the UITableView
documentation, you’ll notice that UITableView
is actually deliberately implemented around when the table view structure is cached. The discussion for reloadData
, indicates that this method is responsible for triggering the cache:
Call this method to reload all the data that is used to construct the table, including cells, section headers and footers, index arrays, and so on.
With that in mind, let’s go ahead and implement reloadData
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
reloadData
simply takes the output generated by the captureTableStructure
we implemented earlier and stores it within the class.
Also, since the table model is only cached at this ‘single time’, the parameters of the view geometry are presumed to stay the same (until this method is called again). Given that, it’s now a good time to assign the contentSize
of the UIScrollView
, too.
With the modeling system and timings ironed out, the last step to implementing a working table view is a laying out the subviews…
Laying out the subviews
Up to now, we’ve established that we’ll need to create a layout system that calculates which subviews are visible, based on the cached model data and the bounds
property.
Conveniently enough, our UIScrollView
base class calls layoutSubviews
every time the view is scrolled by any amount. Better still, right before layoutSubviews
is called, the bounds
property is updated by the OS. This means, once layoutSubviews
is called, the view is about to be updated and we know exactly what region of the contentView
will become visible, based on the bounds
property. This is exactly the layout system implementation should go.
The layout system will need to keep a set of strong references to all of the cell subviews it’s managing, so an internal NSArray
property will be necessary. However, this is where the ‘global index’ we talked about earlier will be useful.
In order to make it easier to figure out exactly which cell subviews are in the array, we’ll implement an integer offset property, along side the subview array. This offset will allow us calculate the global row index, based on the subview object’s array index. That is, the global row index for any cell in the visible cells array will be: globalRowIndex = <subview object index in self.visibleCells> + visibleCellsGlobalIndexOffset
As we modify the contents of the array, we’ll also keep the visibleCells
global index offset value updated, accordingly.
Thus, the private header looks like this:
1 2 3 4 5 6 | |
Also, we’re going to need a single method to call to take care of subview layout. Since everything we’re implementing is based off of yOffsets and heights, let’s use those as our arugments:
1
| |
Now, let’s break down the layout system into two main steps:
- identifying and removing any subviews that are no longer visible
- identifying and adding any subviews that just became visible
Removing old subviews
Removing old subviews is fairly straightfoward: we check the frame
of all of the subviews inside visibleCells
and create a new array of the ones that are still visible. After, we update visibleCells
and visibleCellsGlobalIndexOffset
, accordingly:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | |
(You’ll notice in this code snippet I call enqueueReusableCell:withIdentifier:
. This method is responsible for recycing cell subview instances that are no longer in use. The implementation for this method won’t be covered here, but is available in the full source code – link at bottom.)
Now, we’ve removed the cell subviews that are no longer visible from the visibleCells
array and updated the global index offset. It’s time to add the new subviews.
Adding new subviews
Adding new subviews is a slightly more complex task.
For this part of the implementation, we don’t have an existing frame
property we can simply validate, like when we removed old subviews.
Also, since we’re extracting data from the whole data model, it’s a good idea to build more optimized procedures here, causing the overall implementation to be a little more tricky.
The high-level summary of how this part should work is:
- identify which sections indexes should be visible
- identify the set of global row indexes that are already in
visibleCells
- enumerate the set of section models that should be visible and, if any given section contains any rows aren’t already in
visibleCells
, add those rows to the table view andvisibleCells
, accordingly
Step 1 of the process looks like this:
1 2 3 4 5 6 | |
Here, we iterate over all of the section models, identifying whether or not they contain any rows that are supposed to be visible, based on the yOffset
and totalHeight
section model properties. This is a fast calculation, since the RANGES_INTERSECT
macro is simply a couple of value comparisons.
Also, you’ll notice we use the NSIndexSet
class. If you’re not familiar with this class, there’s an in-depth discussion over at NSHipster but, in summary, it’s just a collection of integer values that represent indexes (i.e. the index set of ‘3, 5, 7’ maps to someArray[3], someArray[5], someArray[7]).
The NSIndexSet
we generate contains the indexes of section model objects in tableData
which have rows that should be visible. Remember that, since tableData
’s index maps directly to section numbers, this index set is actually the same as the set of section numbers that should be visible!
Next, Step 2 of the process looks like this:
1
| |
We take advantage of the index offset value – visibleCellsGlobalIndexOffset
– as well as the count of visibleCells
to generate another index set: the set of global row indexes of cells that are already visible.
Now, we can combine what we found in Step 1 – the set of sections that contain visible rows – and Step 2 – the set of rows that are already visible – and, in Step 3, identify and add the subviews that should be visible.
Step 3 starts like this:
1 2 3 4 5 6 7 8 9 10 11 | |
We start off by creating an array to hold our new cells and a global index offset value for it. Ignore this for now, we’ll come back to that.
Next, we use sectionIndexesThatShouldBeVisible
to iterate over the specific section models that contain rows that should be visible, and may need to be added to the table view.
Inside that iteration, we start by creating a range. This range – sectionGlobalRowIndexRange
, based on globalIndexOfFirstRow
and numberOfRows
from the section model – is the range of global row indexes contained in the given section model.
Next, we check if the index set of already visible cells – visibleCellGlobalRowIndexSet
– contains every index in that range. If it does not, that means the following two things are true:
- the given section model has some rows that should be visible, and…
- every row contained in the given section model is not already visible
With those two conditions asserted, we can iterate over every row in the section model to determine whether each row should be visible, based on the row’s global row index and yOffset
/rowHeight
. If so, now we’re ready to finally retrieve the cell subview and add it to the table view:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | |
The most interesting part of this is where we finally query the dataSource
for the cellForRowAtIndexPath:
. We can set the frame
of the cell subview, based on the parameters we’ve extracted from the section models.
We also need to keep references to the cell objects we’re adding to the view, so we add them to the newCells
array that we created at the start of our loop.
In a similar way as visibleCells
and visibleCellsGlobalIndexOffset
, we’re going to add cell subview objects to newCells
and keep track of the global index that the array starts at using newCellsGlobalIndexOffset
.
Once we’re done adding all of the newly visible cells to the table view, we have to merge our newCells
and visibleCells
arrays.
This doesn’t come without one last curveball. Take a look at how the two arrays are merged:
1 2 3 4 5 6 7 8 9 10 11 12 | |
The trick to this part of the implementation is knowing that, whenever you scroll a scroll view, there’s only one of two scenarios that can be encountered:
- scrolling the table up, adding cells to the bottom of the visible cells collection
- scrolling the table down, adding cells to the top of the visible cells collection
Therefore, we have to look at the newCellsGlobalIndexOffset
to determine whether the new cells we’re adding should either be inserted at the top or the bottom of the visibleCells
array.
Once we determine that, we update the visibleCells
array and visibleCellsGlobalIndexOffset
offset accordingly, and all of the new cell subviews have been added to the table view.
Source code and more UITableView features
And that’s all there is to it!
This was a review of just the basic machinery required to make a working reimplementation of UITableView
.
Go ahead, try dropping in this implementation in your existing UITableView
implementations and seeing how it performs!
One big piece that’s obviously missing from this tutorial is the header and footer implementation. These were left out for simplicity sake, but will have implementations that are highly similar to the way cells are implemented (i.e. a visibleHeaders
array and global indexes and offsets).
…But what about cell enqueueing and other methods like scrollToRowAtIndexPath:
?Those methods and much more UITableView
functionality is implemented in the rest of the LSCTableView
source code on GitHub!
Want to contribute to LSCTableView
? Pull requests to LSCTableView
on GitHub are appreciated!
Any questions? Don’t hesitate to tweet me (@joshavant) or email (josh.avant@livingsocial.com).