iosswiftuitableviewreuseidentifier

Is Apple's iOS documentation incorrect about previously registered class with the same reuse identifier being replaced with the new cellClass?


Apple's documentation says that:

If you previously registered a class or nib file with the same reuse identifier, the class you specify in the cellClass parameter replaces the old entry. You may specify nil for cellClass if you want to unregister the class from the specified reuse identifier.

This doesn't seem to be correct as far as I can tell.

Below simple sample code demonstrates the issue. Basically, I have a slider which changes the padding value. When padding changes, it should re-register (replace the old entry) the class with reuse identifier and reload the table to show the new padding:

import UIKit
import SnapKit

extension String {
    static let kPadding = Self("padding")
    static let cellId = Self("cell")
}

class ViewController: UIViewController, UITableViewDataSource {
    
    let tableView = UITableView(frame: .zero, style: .plain)

    override func viewDidLoad() {
        super.viewDidLoad()
        
        registerCell()
        tableView.dataSource = self
        
        view.addSubview(tableView)
        tableView.snp.makeConstraints { make in
            make.horizontalEdges.top.equalToSuperview()
        }
        
        let slider = UISlider()
        slider.isContinuous = false
        slider.minimumValue = 0
        slider.maximumValue = 100
        slider.value = UserDefaults.standard.float(forKey: .kPadding)
        slider.addTarget(self, action: #selector(sliderChanged(slider:)), for: .valueChanged)
        
        view.addSubview(slider)
        slider.snp.makeConstraints { make in
            let padding = 15.0
            make.horizontalEdges.bottom.equalTo(view.safeAreaLayoutGuide).inset(padding)
            make.top.equalTo(tableView.snp.bottom).offset(padding)
        }
        
    }
    
    @objc func sliderChanged(slider : UISlider) {
        print("sliderChanged: \(slider.value)")
        UserDefaults.standard.set(slider.value, forKey: .kPadding)
        registerCell()
        tableView.reloadData()
    }
    
    func registerCell(){
        tableView.register(Cell.self, forCellReuseIdentifier: .cellId)
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        100
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: .cellId) as! Cell
        
        cell.label.text = "Hello \(indexPath.row)"
        
        return cell
    }

}

class Cell: UITableViewCell {
    
    let label = UILabel()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        label.font = UIFont.systemFont(ofSize: 34, weight: .bold)
        
        contentView.addSubview(label)
        label.snp.makeConstraints { make in
            make.edges.equalToSuperview().inset(UserDefaults.standard.float(forKey: .kPadding))
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

However, this doesn't seem to be the case. It continues to use the previous padding and old entry is not replaced.

Am I misunderstanding what Apple's documentation is saying?


Solution

  • EDIT - see the note at the end of this answer...


    While the posted example code is a bad approach to begin with, it does appear that the documentation is not correct. Or, it's not at all clear what is supposed to happen.

    Let's look at this example (NOT using SnapKit)...

    We'll create a "Base" cell class:

    class CellBase: UITableViewCell {
        
        let label = UILabel()
        
        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            label.font = UIFont.systemFont(ofSize: 20, weight: .regular)
            
            label.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(label)
            
            let g = contentView.layoutMarginsGuide
    
            let h = label.heightAnchor.constraint(equalToConstant: 44.0)
            h.priority = .required - 1
            
            NSLayoutConstraint.activate([
                label.topAnchor.constraint(equalTo: g.topAnchor),
                label.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                label.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                label.bottomAnchor.constraint(equalTo: g.bottomAnchor),
                h,
            ])
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    }
    

    and two subclasses - CellA and CellB (different colors to make it easy to see which one is being used):

    class CellA: CellBase {
        
        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            
            label.backgroundColor = .yellow
            contentView.backgroundColor = .systemYellow
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    }
    
    class CellB: CellBase {
        
        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            
            label.backgroundColor = .cyan
            contentView.backgroundColor = .systemBlue
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    }
    

    By subclassing the cells, we can use a cellForRowAt without without if/else:

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cellBase") as! CellBase
        cell.label.text = "Row: \(indexPath.row) - use Cell A: \(useCellA)"
        return cell
    }
    

    If we register CellA like this:

    tableView.register(CellA.self, forCellReuseIdentifier: "cellBase")
    

    a typical layout will look like this:

    a

    Or, if we register CellB like this:

    tableView.register(CellB.self, forCellReuseIdentifier: "cellBase")
    

    we get this output:

    b

    So, let's add a button at the bottom to toggle the registration:

    class ChangeCellRegVC: UIViewController, UITableViewDataSource {
        
        let tableView = UITableView(frame: .zero, style: .plain)
        let btn = UIButton()
        
        var useCellA: Bool = true
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBackground
        
            btn.setTitle("Switch to Cell B", for: [])
            btn.backgroundColor = .systemRed
            btn.setTitleColor(.white, for: .normal)
            btn.setTitleColor(.lightGray, for: .highlighted)
            btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
            
            tableView.translatesAutoresizingMaskIntoConstraints = false
            btn.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(tableView)
            view.addSubview(btn)
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                tableView.topAnchor.constraint(equalTo: g.topAnchor),
                tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
    
                btn.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 20.0),
                btn.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                btn.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                btn.bottomAnchor.constraint(equalTo: g.bottomAnchor),
            ])
    
            tableView.register(CellA.self, forCellReuseIdentifier: "cellBase")
            tableView.dataSource = self
        }
    
        @objc func btnTap(_ sender: UIButton) {
            useCellA.toggle()
            if useCellA {
                print("register CellA")
                tableView.register(CellA.self, forCellReuseIdentifier: "cellBase")
            } else {
                print("register CellB")
                tableView.register(CellB.self, forCellReuseIdentifier: "cellBase")
            }
    
            tableView.reloadData()
    
            // update button title
            btn.setTitle("Switch to Cell \(useCellA ? "B" : "A")", for: [])
        }
    
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            100
        }
        
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "cellBase") as! CellBase
            cell.label.text = "Row: \(indexPath.row) - use Cell A: \(useCellA)"
            return cell
        }
    }
    

    On launch, it looks like this:

    c

    If we tap the "Switch to..." button, we'll call tableView.register(...) again, swapping between CellA and CellB, and call reloadData()` (relevant code):

    @objc func btnTap(_ sender: UIButton) {
        useCellA.toggle()
        if useCellA {
            print("register CellA")
            tableView.register(CellA.self, forCellReuseIdentifier: "cellBase")
        } else {
            print("register CellB")
            tableView.register(CellB.self, forCellReuseIdentifier: "cellBase")
        }
        
        tableView.reloadData()
        
        // update button title
        btn.setTitle("Switch to Cell \(useCellA ? "B" : "A")", for: [])
    }
    

    So, what do we get on button tap?

    d

    Clearly, the cells have been reloaded as "true" has changed to "false" ... but they are still dequeuing CellA!

    So, let's scroll up a few rows:

    e

    The table view had to create 3 new cells on .dequeueReusableCell, using CellB class as expected... but starting at "Row: 14" it's back to re-using CellA!

    If we keep scrolling:

    f

    we see that the table view now has 11 CellA instances and 3 CellB instances in its "reusable queue" ... and it doesn't matter which class we have currently registered.


    Note also... the docs state:

    You may specify nil for cellClass if you want to unregister the class from the specified reuse identifier.

    But, if we do the initial load, the execute this code:

    tableView.register(nil as UINib?, forCellReuseIdentifier: "cellBase")
    tableView.reloadData()
    

    cellForRowAt will be called for each of the visible cells and they will still update!

    If we then try to scroll, the app will crash in cellForRowAt with "Fatal error: Unexpectedly found nil while unwrapping an Optional value"

    The crash on scroll makes sense, even though the reloading of already queued cells does not.


    EDIT

    As pointed out in the comments by Paulw11, it seems the key point is the first phrase in the Discussion section of that doc page:

    Prior to dequeueing any cells...

    So, if you have already dequeued any cell with that Identifier, ignore everything in the Discussion section after those 5 words.