Search code examples
iosswiftuitableviewreloaddata

How To Update All The Visible UITableViewCell Every Time A New Row Appears


I want the UITableView's refreshing to be triggered by a new cell's appearing.

Simple setup. UITableView, dataSource is [Double]. Each row in the table will present one number in the array.

The special thing I wanna do is that I want the table to label the cell with max number of all visible cells. Then each of the other cells will calculate the difference from the max number onscreen. And this should update every time a new cell appears.

the demo of how it should look like

import UIKit

class ViewController: UIViewController {
    var max = 0.0
    var data:[Double] = [13,32,43,56,91,42,26,17,63,41,73,54,26,87,64,33,26,51,99,85,57,43,30,33,20]
    
    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .red
        self.tableView.dataSource = self
        self.tableView.delegate = self
        self.tableView.reloadData()
    }
    
    private func calculate() {
        // calculate the max of visible cells
        let indexes = tableView.indexPathsForVisibleRows!
        max = indexes
            .map { data[$0.row] }
            .reduce(0) { Swift.max($0,$1) }
        
        // trying to update all the visible cells.

//         tableView.reloadRows(at: indexes, with: .none) ****
//         tableView.reloadData() ****
        
    }
}

extension ViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        data.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell  = tableView.dequeueReusableCell(withIdentifier: "default")!
        cell.textLabel?.text = "\(data[indexPath.row]) : \(max)"
        return cell
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 64
    }
    
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        calculate()
    }
    
    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        calculate()
    }
}

What I Have Done

There are 2 lines of code with marks of **** at the end.

If I uncomment tableView.reloadRows(at: indexes, with: .none), Xcode produces error: 'NSRangeException', reason: '*** -[__NSArrayM objectAtIndexedSubscript:]: index 15 beyond bounds [0 .. 13]'. I don't really understand why, but I know onscreen visible cells are at most 14 in order to fit in the screen, but in table loading phase, the simulator thinks there were 19 visible cells at some point

If instead, I uncomment tableView.reloadData(), this lines of code will trigger willDisplay func which will again call calculate func which has reloadData() in it. It was a infinite recursive cycle, and no row successfully displayed onscreen.

If instead, I don't uncomment anything, the table will not update cells that are already onscreen. Only newly appearing cells will correctly display the effect that I want.

Thank you for reading all this and trying to offer help.


Solution

  • You don't want to call reloadCells since that will trigger willDisplay and then you get an infinite loop.

    Rather, you can access the visible cell and update it directly by calling cellForRow(at:) for the visible cells.

    private func updateVisibleCells() {
        for indexPath in self.tableView.indexPathsForVisibleRows ?? [] {
            if let cell = self.tableView.cellForRow(at: indexPath) {
                cell.textLabel?.text = "\(data[indexPath.row]) : \(max)"
            }
        }
    }
    

    Now, if you call updateVisibleCells straight after you call calculate() in willDisplayCell you will get a message in the console:

    Attempted to call -cellForRowAtIndexPath: on the table view while it was in the process of updating its visible cells, which is not allowed.

    This will cause a crash in a release build.

    To work around this we can defer the call to updateVisibleCells by dispatching it asynchronously so that it is called after the table view update is complete:

    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        self.calculate()
        DispatchQueue.main.async {
            self.updateVisibleCells()
        }
    }
    

    You want to use didEndDisplaying rather than willDisplay so that the rows are updated correctly when rows scroll out of view. You don't need anything in willDisplay