How to Do Fast & Smooth Table Scrolling in iOS
Does your tableview scroll at 60 fps (frames per second)? Or does it look a jerky and not smooth? Mine’s running smoothly at 60 fps now. It used to be erratic and averaged about 38 fps. This is how I did it.
Tables with complex cells in each row can be difficult to make scroll smoothly. You’re configuring and laying out cells as the scrolling happens, there’s a limited amount of time. And when it takes to look, scrolling gets slow and jerky. Mine was averaging around 38 fps (although had a broad distribution). It was visibly slow. The problem was that the table cell was a custom view with other custom views inside it and various constraints on the positioning and size of those views. It looks very nice, it’s a great UI. But when our table view controller delegate is asked for the row height, well, it’s hard to figure that out quickly.
In our app the table cell could be very different in size depending on the data, almost a 2x difference! So we configure and lay out the cell once when the
heightForRowAtIndexPath method is called, then we do that all again when
cellForRowAtIndexPath method is called. We could have used the
estimatedHeightForRowAtIndexPath method. But for us it’s hard to estimate without doing a full layout.
Another developer wanted the table to be accurate. So they just set up the cell view and did layout on it, then asked the cell view what height it is. That works and it’s accurate. But very slow.
Why do I care if it’s 38 fps and not 60? The responsiveness of an app is very visible in the way fast actions work, fast actions like scrolling. If the scrolling is smooth, the users view the app as fast and smooth. And scrolling erratically and with an average of 38 fps looks jerky and not smooth. The 60 fps runs as fast as the screen updates, so has to be updated every 16mSec (actually less due to system and graphics overhead).
I put a breakpoint on the
cellForRowAtIndexPath methods in my delegate and saw that was called multiple times. This code was redoing the cell layout each time. And interestingly, I was seeing called
heightForRowAtIndexPath. The opposite of what I expected, but this helped.
I tried to streamline the process and calculate the height based on the row’s elements and potential configuration. This devolved into a set of special cases that left me uneasy. This was complex and not supportable. It meant that changing the cell isn’t just matter of changing the xib files, but re-reverse engineering the layout calculations again for estimating or calculating height.
I don’t like putting in landmines in code for whomever’s going to maintain the code. (It could be me!) This wasn’t going to work.
The real solution:
So I went back to the layout-and-ask-the-height approach. But this time I would cache the height when we did layout on a cell.
I created a class like:
@interface MyCachedRowHeight : NSObject
@property (nonatomic, strong) NSIndexPath *path;
@property (nonatomic,) CGFloat height;
Then in my table delegate, I created a
NSMutableArray. When either
heightForRowAtIndexPath had to configure and layout a cell, I would cache the cell height and path with the above class in the array. In
heightForRowAtIndexPath I would first check to see if that path existed in the cache. If found it would return that height.
I also wrote some methods not only to cache the height and check the cache, but also to remove a row from the cache or delete the cache contents completely. These were to allow for configuration changes or edits on a given row, or from refetching the whole table’s contents from the server.
This cache allowed me to reach my 60 fps goal and made the scrolling in my table smooth and easy.
What I could have done
In my case only doing one layout per cell worked. But what if this hadn’t let me reach 60 fps, what might I have done? In my cache I didn’t try to optimize the row order or to search intelligently. It was a straight linear search. As the total number of items in this cache size is under a few hundred and most likely under 10–20, so getting fancy with the search or ordering wasn’t important in this case. But for larger caches good ordering and intelligent searching could be useful.
The real cost in the original implementation was the multiple configuration and layout of the cell, the layout was especially expensive. And with
cellForRowAtIndexPath being called once and
heightForRowAtIndexPath being called several times, and layout being done for each call, the multiple layout time was killing me. If the layout was even more costly and still causing problems, I could have simply cached the whole cell view. My method potentially did a layout twice - once for the height and once for the cell.
I didn’t need to do this since I’d hit my 60 fps goal. This is possibly because I’d found that
cellForRowAtIndexPath was being called before
heightForRowAtIndexPath. This call order means only one layout was really needed for my table cell. But for an even more complex cell and scrolling up and down (thus re-laying out the cell) a cell cache could have been useful.
How did I track this down?
I used the tools in Xcode. I started Instruments with the Xcode menu Product>Profile. Initially I used just time profiling to see where the problem was overall. Then I used the Core Animation tool which does both time profiling and shows the frames per second rate. What I wanted was smooth animation and motion on screen, I knew that was related to fps, so I used the tool that showed that.
Moral of the story: Measure what you want to improve. Cache the data you need that’s expensive.