LSCTableView: Building an Open, Drop-in Replacement of UITableView

Comments | posted in LSCTableView, Objective-C, UITableView, iOS

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 UITableViewCellLSCTableView 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
@interface LSCTableViewSection : NSObject

@property(nonatomic, readwrite) NSUInteger globalIndexOfFirstRow;
@property(nonatomic, readwrite) CGFloat    totalHeight;
@property(nonatomic, readwrite) CGFloat    yOffset;
@property(nonatomic, readonly)  NSInteger  numberOfRows;
@property(nonatomic, readonly)  CGFloat    *rowHeights; // C array pointer. uses custom setter.
@property(nonatomic, readwrite) CGFloat    headerHeight;
@property(nonatomic, readwrite) CGFloat    footerHeight;

// Assigns both `self.rowHeights` and `self.numberOfRows` properties.
// `rowHeights` should be a pointer to a C array that contains `numberOfRows` number of CGFloats.
- (void)setRowHeights:(CGFloat *)rowHeights count:(NSUInteger)numberOfRows;

- (NSUInteger)globalIndexOfLastRow;

@end

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
@implementation LSCTableView

- (NSArray *)captureTableStructure
{
  NSMutableArray *structure = [NSMutableArray array];

  NSInteger numberOfSections = [self.dataSource respondsToSelector:@selector(numberOfSectionsInTableView:)] ?
                                   [self.dataSource numberOfSectionsInTableView:self] : 1;

  NSUInteger globalRowIndex = 0;
  CGFloat totalHeight = 0.f;
  CGFloat yOffset = 0.f;

  for(NSInteger sectionIndex = 0; sectionIndex < numberOfSections; sectionIndex++)
  {
    // Section
    LSCTableViewSection *section = [LSCTableViewSection new];

    section.globalIndexOfFirstRow = globalRowIndex;
    globalRowIndex++;

    section.yOffset = yOffset;

    // Header
    if([self.delegate respondsToSelector:@selector(tableView:heightForHeaderInSection:)])
    {
      CGFloat headerHeight = [self.delegate tableView:self heightForHeaderInSection:sectionIndex];

      section.headerHeight = headerHeight;
      totalHeight += headerHeight;
    }

    // Rows
    NSInteger numberOfRows = [self.dataSource tableView:self numberOfRowsInSection:sectionIndex];
    CGFloat *rowHeights = calloc(1, sizeof(CGFloat) * numberOfRows);

    if([self.delegate respondsToSelector:@selector(tableView:heightForRowAtIndexPath:)])
    {
      for(NSInteger rowIndex = 0; rowIndex < numberOfRows; rowIndex++)
      {
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:rowIndex inSection:sectionIndex];
        CGFloat rowHeight = [self.delegate tableView:self heightForRowAtIndexPath:indexPath];

        rowHeights[rowIndex] = rowHeight;
        totalHeight += rowHeight;
        globalRowIndex++;
      }
    }
    else
    {
      for(NSInteger rowIndex = 0; rowIndex < numberOfRows; rowIndex++)
      {
        rowHeights[rowIndex] = kLSCTableViewDefaultRowHeight;
        totalHeight += kLSCTableViewDefaultRowHeight;
        globalRowIndex++;
      }
    }

    [section setRowHeights:rowHeights count:numberOfRows];
    free(rowHeights);

    // Footer
    if([self.delegate respondsToSelector:@selector(tableView:heightForFooterInSection:)])
    {
      CGFloat footerHeight = [self.delegate tableView:self heightForFooterInSection:sectionIndex];

      section.footerHeight = footerHeight;
      totalHeight += footerHeight;
    }

    section.totalHeight = totalHeight;

    [structure addObject:section];

    totalHeight = 0.f;
    yOffset += totalHeight;
  }

  return structure;
}

@end

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
@interface LSCTableView ()

@property(nonatomic) NSArray *tableData; // each object = LSCTableViewSection

@end


@implementation LSCTableView

- (void)reloadData
{
  self.tableData = [self captureTableStructure];

  LSCTableViewSection *lastSection = [self.tableData lastObject];

  self.contentSize = CGSizeMake(self.bounds.size.width, lastSection.yOffset + lastSection.totalHeight);
}

@end

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
@interface LSCTableView ()

@property(nonatomic) NSMutableArray *visibleCells;
@property(nonatomic) NSUInteger     visibleCellsGlobalIndexOffset;

@end

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
- (void)layoutTableForYOffset:(CGFloat)yOffset height:(CGFloat)height

Now, let’s break down the layout system into two main steps:

  1. identifying and removing any subviews that are no longer visible
  2. 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
// returns a boolean indicating if the given ranges intersect
#define RANGES_INTERSECT(location1, length1, location2, length2) ((location1 + length1 >= location2) && (location2 + length2 >= location1))

// Remove
NSMutableArray *newVisibleCells = [NSMutableArray array];
__block NSUInteger newVisibleCellsGlobalIndexOffset = self.visibleCellsGlobalIndexOffset;

[self.visibleCells enumerateObjectsUsingBlock:^(LSCTableViewCell *cell, NSUInteger idx, BOOL *stop) {
  CGFloat cellYOffset = CGRectGetMinY(cell.frame);
  CGFloat cellHeight  = CGRectGetHeight(cell.frame);

  if(RANGES_INTERSECT(yOffset, height, cellYOffset, cellHeight))
  {
    if(newVisibleCells.count == 0)
    {
      newVisibleCellsGlobalIndexOffset = self.visibleCellsGlobalIndexOffset + idx;
    }

    [newVisibleCells addObject:cell];
  }
}];

[self.visibleCells removeObjectsInArray:newVisibleCells];

[self.visibleCells enumerateObjectsUsingBlock:^(LSCTableViewCell *cell, NSUInteger idx, BOOL *stop) {
  [self enqueueReusableCell:cell withIdentifier:cell.reuseIdentifier];
  [cell removeFromSuperview];
}];

self.visibleCells = newVisibleCells;
self.visibleCellsGlobalIndexOffset = newVisibleCellsGlobalIndexOffset;

(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:

  1. identify which sections indexes should be visible
  2. identify the set of global row indexes that are already in visibleCells
  3. 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 and visibleCells, accordingly

Step 1 of the process looks like this:

1
2
3
4
5
6
// Add
NSIndexSet *sectionIndexesThatShouldBeVisible = [self.tableData indexesOfObjectsPassingTest:^BOOL(LSCTableViewSection *section, NSUInteger idx, BOOL *stop) {
  if(RANGES_INTERSECT(yOffset, height, section.yOffset, section.totalHeight)) { return YES; }
  else if(section.yOffset > yOffset + height) { *stop = YES; return NO; }
  else { return NO; }
}];

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
NSIndexSet *visibleCellGlobalRowIndexSet = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(self.visibleCellsGlobalIndexOffset, self.visibleCells.count)];

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
NSMutableArray *newCells = [NSMutableArray array];
__block NSUInteger newCellsGlobalIndexOffset = 0;

[self.tableData enumerateObjectsAtIndexes:sectionIndexesThatShouldBeVisible
                                  options:0
                               usingBlock:^(LSCTableViewSection *section, NSUInteger idx, BOOL *stop) {

                                 NSRange sectionGlobalRowIndexRange = NSMakeRange(section.globalIndexOfFirstRow, section.numberOfRows);

                                 if(![visibleCellGlobalRowIndexSet containsIndexesInRange:sectionGlobalRowIndexRange])
                                 {

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:

  1. the given section model has some rows that should be visible, and…
  2. 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
CGFloat precedingRowsYOffset = 0.f;
for(NSInteger i = 0; i < section.numberOfRows; i++)
{
    NSUInteger globalRowIndex = section.globalIndexOfFirstRow + i;
    CGFloat rowYOffset = section.yOffset + section.headerHeight + precedingRowsYOffset;
    CGFloat rowHeight  = section.rowHeights[i];

    if(![visibleCellGlobalRowIndexSet containsIndex:globalRowIndex] && RANGES_INTERSECT(yOffset, height, rowYOffset, rowHeight))
    {
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:idx];

        LSCTableViewCell *cell = [self.dataSource tableView:self cellForRowAtIndexPath:indexPath];
        cell.frame = CGRectMake(0.f, rowYOffset, self.frame.size.width, rowHeight);
        [self willDisplayCell:cell forRowAtIndexPath:indexPath];
        [self addSubview:cell];

        if(newCells.count == 0)
        {
            newCellsGlobalIndexOffset = globalRowIndex;
        }
        [newCells addObject:cell];
    }

    precedingRowsYOffset += rowHeight;
}

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
if(newCells.count > 0)
{
    if(newCellsGlobalIndexOffset > self.visibleCellsGlobalIndexOffset)
    {
        [self.visibleCells addObjectsFromArray:newCells];
    }
    else
    {
        self.visibleCellsGlobalIndexOffset = newCellsGlobalIndexOffset;
        [self.visibleCells insertObjects:newCells atIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, newCells.count)]];
    }
}

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:

  1. scrolling the table up, adding cells to the bottom of the visible cells collection
  2. 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).

« Open-Sourcing Rearview: Real-Time Monitoring with Graphite Coverband: production Ruby code coverage »



https://techblog.livingsocial.com/blog/2013/12/02/lsctableview-building-an-open/
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值