Roll your own UITableView with cell recycling

If you’ve ever used a UITableView or UICollectionView you’ve no doubt been exposed to cell recycling. One of the most important features of these two scrolling views is that they enable you (and in the latter case force you) to reuse cells. This makes for more efficient and performant scrolling, regardless of how much data we have to show. But how does it work under the hood?

Note: This post is just for fun! It’s not very practical to roll your own table view and you shouldn’t reinvent the wheel. With that said I hope you keep reading and maybe learn something.

Let’s dive in. We’re going to need a subclass of UIScrollView view and a protocol for our data source.

@interface RecyclingScrollView : UIScrollView {
    UIView*       contentView;
    NSRange       renderedRange;
    NSMutableSet* reusableCells;
}
 
@property (nonatomic, assign) id <RecyclingScrollViewDataSource>dataSource;
@property (nonatomic, assign) CGSize itemSize;
 
- (UIView *)dequeueReusableCell;
 
@end
 
@protocol RecyclingScrollViewDataSource <NSObject>
 
- (NSInteger)numberOfItemsInRecyclingScrollView:(RecyclingScrollView *)scrollView;
- (UIView *)recyclingScrollView:(RecyclingScrollView *)scrollView
             cellForItemAtIndex:(NSInteger)index;
@end

Most of this should look familiar but let’s go over some things:

  • contentView will be the parent view of our cells.
  • renderedRange will be used to keep track of the cells currently on screen.
  • reusableCells will contain recycled cell views that can be dequeued.

It will be our custom scroll view’s job to figure out what cells are visible at any given time and render them. If any cells are scrolled out of view they should be recycled and reused to render other cells that come into view. This way we will only ever allocate as many cells as can be visible at one time. The bulk of this logic will happen in layoutSubviews, which is called every time the scroll position (or bounds) changes.

- (void)layoutSubviews {
 
    [super layoutSubviews];
 
    // Figure out what range of cells should be visible right now
    // and check it against our current rendered range.
    NSRange visibleRange = [self computeVisibleRange];
 
    if (!NSEqualRanges(visibleRange, renderedRange)) {
 
       // The visible range differs from what we've currently rendered.
       // First, recycle any cells that are not in the visible range.
       for (NSInteger i = renderedRange.location; i < NSMaxRange(renderedRange); i++) {
            if (!NSLocationInRange(i, visibleRange)) {
                [self recycleCellAtIndex:i];
            }
        }
 
        // Next, render any cells that are in the visible range that 
        // we haven't rendered already.
        for (NSInteger i = visibleRange.location; i < NSMaxRange(visibleRange); i++) {
            if (!NSLocationInRange(i, renderedRange)) {
                [self renderCellAtIndex:i];
            }
        }
 
        // And we're done. Update our current rendered range.
        renderedRange = visibleRange;
    }
}

The next function creates an NSRange to represent what cells are currently visible. The location will be equal to the scroll position x value divided by the cell width. The length is a little trickier to compute because we have to take into consideration that a cell can be partially visible. To determine if this is the case we can use modulo. Take a look.

- (NSRange)computeVisibleRange {
 
    CGRect bounds = [self bounds];
    NSInteger location = MAX(0, (int)(bounds.origin.x / itemSize.width));
    NSInteger length = MAX(0, ceil((bounds.size.width + fmodf(bounds.origin.x, itemSize.width)) / itemSize.width));
    NSInteger totalItems = [dataSource numberOfItemsInRecyclingScrollView:self];
    if ((location + length) > totalItems) {
        length = MAX(0, totalItems - location);
    }
    return NSMakeRange(location, length);
}

To render a cell we first ask the data source to provide one. Then we compute it’s frame and add it to the content view. We also use objc_setAssociatedObject to keep track of the index currently assigned to the cell.

- (void)renderCellAtIndex:(NSInteger)index {
 
    UIView *cell = [dataSource recyclingScrollView:self cellForItemAtIndex:index];    
 
    CGRect rect = CGRectZero;
    rect.size = itemSize;
    rect.origin = CGPointMake(index * itemSize.width, 0);
    [cell setFrame:rect];
 
    [contentView addSubview:cell];
 
    objc_setAssociatedObject(cell, &RecyclingScrollViewIndexKey, @(index), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

Lastly we need to implement recycling. We’ll need one method to remove a cell from our content view and add it to reusableCells and another method to dequeue them.

- (id)dequeueReusableCell {
 
    UIView *cell = [reusableCells anyObject];
    if (cell) {
        [reusableCells removeObject:cell];
    }
    return cell;
}
 
- (void)recycleCellAtIndex:(NSInteger)index {
 
    for (UIView *cell in [contentView subviews]) {
        NSNumber *num = objc_getAssociatedObject(cell, &RecyclingScrollViewIndexKey);
        if (index == [num integerValue]) {
            [reusableCells addObject:cell];
            [cell removeFromSuperview];
        }
    }
}

From here you should be good to go! Implement the data source protocol and dequeue cells just like you normally would. Link to the full source can be found on Github.