Do You Know (iOS 15) Table View?

How well do you know about iOS's table view? Did you know they added a new feature in iOS 15?

By ahchao in Learning Swift

October 31, 2021

How well do you know UITableView?

iOS Table View has been around for a long time since iOS 2.0+.

The concept of table view is to reuse cells (you can take it as a row = a cell).

Basic idea is this:

If the tableview can fit in 10 cells, but you only have 5 rows, it will create 5 rows in memory. If your data grows to show > 10 rows, it will create another 6 more cells (because you can have 11 cells being visible at once: the top and last row are partially visible), and keep reusing those pool of 11 cells. We can’t guarantee in what/which order the cells are being reused, but it doesn’t matter. It doesn’t destroy the object nor create extra for no reason, and we all know repeatedly allocating/deallocating memory is expensive and hurts performance. Quite simple, but I wonder how many iOS devs doesn’t know this.

2 more basic things we should know:

  1. When a cell is about to come into view (becoming visible/onscreen), tableView(_:cellForRowAt: is called.
  2. When a cell is being hidden (becoming non-visible/offscreen), tableView(_:didEndDisplaying:forRowAt:) is called.

Test yourself

Now that we have a quick recap, one of the project I am in, has code similar to the following. Assume no crash, code runs and everything is linked properly, and the table view is showing up on screen (code is probably weird but I had to simplify it, so bear with me). What the code is trying to do, is to basically show 50 rows and each has their row index number set, except for firstmost row (at index 0), where we want to set some text.

See if you think there will be a potential bug or not:

 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
# ViewController.swift
import UIKit

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 50
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        
        let cell = tableView.dequeueReusableCell(withIdentifier: "PrefetchTableViewCell", for: indexPath) as! PrefetchTableViewCell
        
        cell.leftLabel.text = "\(indexPath.row)"
        
        if indexPath.row == 0 {
            // Let's modify the label text label if this is the firstmost row.
            cell.rightLabel.text = "This is only shown for index 0."
        }
        
        return cell
    }
    
}
1
2
3
4
5
6
7
8
9
# PrefetchTableViewCell.swift
import UIKit

class PrefetchTableViewCell: UITableViewCell {
    
    @IBOutlet weak var leftLabel: UILabel! // For showing row number.
    @IBOutlet weak var rightLabel: UILabel! // For the 0th row to show some text. 
}

Hopefully the above code is easily understandable.

I think based on the above, it is easy for you (I hope!) to guess where a potential problem is, and how to solve it.

For those who are not familiar with iOS, since cells are being recycled, and since there is nowhere where cell.rightLabel.text is being reset to empty string, hence the text “This is only shown for index 0.” will appear in more than 1 row as the user scrolls down:

iOS Table View bug

Once the cell is modified, the change is there forever, unless we reset it back.

This is easily fixable, by something like the following:

  1. Add more code to reset it when it is becoming invisible:
1
2
3
4
5
6
7
8
# workaround 1
extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        if let cell = cell as? PrefetchTableViewCell {
            cell.rightLabel.text = ""
        }
    }
}
  1. Just set it directly in the cellForRow function when becoming visible:
1
2
3
4
5
6
7
# Replace line 22-25 with this:
        if indexPath.row == 0 {
            // Let's modify the label text label if this is the firstmost row.
            cell.rightLabel.text = "This is only shown for index 0."
        } else {
            cell.rightLabel.text = ""
        }

You can download the example project here: TableViewPrefetch

The ‘new’ iOS 15.0 UITableView

iOS 15.0 table view introduced a new concept called cell prefetching, not to be confused with data prefetching which already existed in iOS 10 (along with collection view). This cell prefetching is nothing new, and collectionView already has since it since iOS 10.0 on top of data prefetching, but with a new behaviour. I am not familiar with the internal details, but it basically prefetch the next few cells that is going to appear (probably based on your scrolling direction) in advance, so it has up to 2x more time to do the work needed to display the cell, before it becomes visible. Best if you watch both WWDC videos above.

However with this new tableview change, I notified that tableView(_:cellForRowAt: is no longer called. Apparently back in iOS 10 for collection view, with this prefetch on, the lifecycle is changed such that if say, the last cell disappears and reappears again, cellForItem(at:) is not called, but willDisplayCell will still be called. So I guess you can say it is ‘cached’. I don’t know the extend of this, but this had NEVER happen for table view before, and hence did not expected this kind of behaviour until now.

I initially thought this was a bug of iOS 15.0, since tableView(_:cellForRowAt: was not called when it go off screen and reappeared, but apparently not. It is now no longer guaranteed that the cell goes back into the reuse pool when it ends displaying, assuming you are on iOS 15 with isPrefetchingEnabled = true.

This causes a problem to my current project, where our labels are disappearing randomly for no apparent reason, and I found out that our code were dependent on tableView(_:didEndDisplaying:forRowAt:) being called (to delete the labels in the cell) followed by tableView(_:cellForRowAt: (to add back the new labels in the cell) when it reappears back on screen. Now that we know calls to tableView(_:cellForRowAt: is not guaranteed, there is a mismatch in the calling sequence which the coder had assumed, and it means labels might not show up in our cells anymore, hence the ‘randomly-disappearing-labels-and-randomly-reappearing-back’ bug we are seeing.

Here is a replicable code that illustrates what I am talking about. Run it on iOS 15 - isPrefetchingEnabled is true by default. Scroll up the cell row to make it disappear (end display), then release it so it bounces back into view but notice tableView(_:cellForRowAt: doesn’t get called as we always would for iOS versions < 15:

 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
//  ViewController.swift
import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }
    
    // This is not longer guaranteed. Set a breakpoint here to see what I mean.
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        
        let cell = tableView.dequeueReusableCell(withIdentifier: "PrefetchTableViewCell", for: indexPath) as! PrefetchTableViewCell
        
        // Always set the leftLabel when this function is called (which is now no longer guaranteed if data has changed but cell is cached when prefetching is enabled!)
        cell.leftLabel.text = "\(indexPath.row)"
        
        if indexPath.row == 0 {
            // Let's modify the label text label if this is the firstmost row.
            cell.rightLabel.text = "This is only shown for index 0."
        }
        
        return cell
    }
    
}

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        if let cell = cell as? PrefetchTableViewCell {
            // We clear this label, EXPECTING it to be set in cellForRow (but it does not if prefetching is enabled!).
            cell.leftLabel.text = ""
        }
    }
}

With the above information in hand, we can turn this off via tableView.isPrefetchingEnabled = false, or rework our code such that the displaying of information in the cells is not dependent on tableView(_:didEndDisplaying:forRowAt:) and tableView(_:cellForRowAt: to be called in pairs. If prefetching is on moving forward to take advantage of the performance, looks like we also need to use reconfigureRows as well now, though I am still figuring out what is the difference between that and directly calling cellForRow if I need to update the cell when the data changes. Anyone knows? Do point me in the right direction!

I just found this twitter thread while writing this post, which I thought is quite good at talking about it: https://twitter.com/smileyborg/status/1406769460032114689?lang=en (especially this: https://twitter.com/smileyborg/status/1406769463253364736). It also brought up a point where if the cell is cached (not updated) but underlying data has changed, then we have a problem. A good summary of what I said basically.

The above is based on my experience with table views and understanding of the documentation and various sources on the internet.

Please feel free to correct me if I am wrong, let’s learn together!

comments powered by Disqus