UITableView含按钮单元格点击时索引路径打印不一致问题排查
UITableViewCell复用与按钮点击索引路径错误问题
问题描述
我有一个包含带UIButton的自定义UITableViewCell的UITableView,该UITableView需要每秒重载一次。但快速点击单元格时,有时会打印错误的索引路径。我尝试在prepareForReuse方法中重置索引路径、移除按钮响应关联等清理操作,但无法彻底解决问题。
我发现取消单元格复用或在prepareForReuse中重新创建按钮可以解决问题,但想了解为何仅通过prepareForReuse清理无法达到预期效果。
演示现象:点击第三个单元格时,有时会输出0-4而非0-2;点击第五个单元格时,有时会输出0-6而非0-4。
相关代码
ViewController代码
class ViewController: UIViewController, UITableViewDataSource { @IBOutlet weak var tableView: UITableView! var timer: Timer? var val = 0 override func viewDidLoad() { super.viewDidLoad() tableView.dataSource = self tableView.register(TableViewCell.self, forCellReuseIdentifier: "cell") timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateData), userInfo: nil, repeats: true) timer?.fire() } deinit { timer?.invalidate() } @objc func updateData() { val += 1 tableView.reloadData() } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 10 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! TableViewCell cell.indexPath = indexPath cell.configure(val: indexPath.row + val) cell.delegate = self return cell } } extension ViewController: TableViewCellDelegate { func didTap(cell: UITableViewCell) { let indexPath = tableView.indexPath(for: cell) print("indexPath = \(indexPath)") } }
Cell代码
protocol TableViewCellDelegate: AnyObject { func didTap(cell: UITableViewCell) } class TableViewCell: UITableViewCell { var button: UIButton = UIButton() var indexPath: IndexPath? weak var delegate: TableViewCellDelegate? override func prepareForReuse() { super.prepareForReuse() indexPath = nil delegate = nil button.removeTarget(nil, action: nil, for: .allTouchEvents) // button = UIButton() } func configure(val: Int) { if button.superview == nil { contentView.addSubview(button) button.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ button.topAnchor.constraint(equalTo: contentView.topAnchor), button.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), button.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), button.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) } button.setTitle("push me \(val)", for: .normal) button.addTarget(self, action: #selector(tap), for: .touchUpInside) button.backgroundColor = .red } @objc func tap() { delegate?.didTap(cell: self) } }
问题根源分析
问题出在UIButton触摸事件生命周期与UITableView复用的时序冲突:
- 快速点击按钮时,iOS会先记录
触摸开始事件,但此时单元格还未被复用;在触摸结束(触发touchUpInside)之前,tableView.reloadData()执行,该单元格被复用到新的indexPath位置。 prepareForReuse中的removeTarget和重置delegate操作,是在单元格被复用重新配置之前执行的,但如果触摸事件已经处于“触摸开始”状态,removeTarget无法取消已经触发中的触摸序列——也就是说,按钮的touchUpInside事件依然会绑定到旧的cell实例上执行。- 当
tap()方法最终执行时,这个cell已经被复用到新位置,tableView.indexPath(for: cell)会返回当前复用后的新索引,而非点击时的原始索引。
为什么重建按钮/取消复用能解决?
- 取消复用:每个单元格都是全新实例,不会被复用到其他位置,点击时的cell和当前显示的cell是同一个,索引自然正确。
- prepareForReuse中重新创建按钮:旧按钮被丢弃,新按钮没有绑定任何未完成的触摸事件,点击只会触发当前配置的事件,不会关联到之前的触摸序列。
正确解决方案(无需取消复用/重建按钮)
不需要重建按钮,只需要确保点击时获取的是单元格被点击时的正确索引,而非依赖复用后的indexPath(for:):
方案1:通过代理直接传递索引
- 修改Cell的代理协议和点击方法:
protocol TableViewCellDelegate: AnyObject { func didTap(cell: UITableViewCell, indexPath: IndexPath?) } class TableViewCell: UITableViewCell { // ... 其他代码不变 @objc func tap() { delegate?.didTap(cell: self, indexPath: indexPath) } }
- ViewController中直接使用传递的索引:
extension ViewController: TableViewCellDelegate { func didTap(cell: UITableViewCell, indexPath: IndexPath?) { print("indexPath = \(indexPath)") } }
方案2:优化按钮绑定逻辑(避免重复添加target)
在Cell初始化时只添加一次target,后续仅更新内容,减少冗余绑定:
class TableViewCell: UITableViewCell { var button: UIButton = UIButton() var indexPath: IndexPath? weak var delegate: TableViewCellDelegate? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupButton() } required init?(coder: NSCoder) { super.init(coder: coder) setupButton() } private func setupButton() { contentView.addSubview(button) button.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ button.topAnchor.constraint(equalTo: contentView.topAnchor), button.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), button.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), button.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) button.addTarget(self, action: #selector(tap), for: .touchUpInside) button.backgroundColor = .red } override func prepareForReuse() { super.prepareForReuse() indexPath = nil delegate = nil } func configure(val: Int, indexPath: IndexPath) { self.indexPath = indexPath button.setTitle("push me \(val)", for: .normal) } @objc func tap() { delegate?.didTap(cell: self, indexPath: indexPath) } }
同时修改ViewController的cellForRowAt方法:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! TableViewCell cell.configure(val: indexPath.row + val, indexPath: indexPath) cell.delegate = self return cell }
内容的提问来源于stack exchange,提问作者wattson
相关产品推荐
相关产品推荐

