UITableViewCell のタップした位置の IndexPath を取得する

検証環境:
Xcode 11.3
Swift 5.1.3

UITableViewCell 上に置いたボタンをタップしたときにそのセルをアニメーション削除したい。
UITableViewDataSource プロトコルの cellForRowAt メソッドにセルのコールバックプロパティに引数の indexPath を渡す実装にしたところ、セル削除時に Index out of range が発生してクラッシュした。
原因はキャッシュされた indexPath を使っているためで、例えば2つセルがあって、先に1つ目を消し、その後2つ目を消すと存在しない index を指定することになるため。

修正前

// セル
class MyCell: UITableViewCell {

    var tapButtonHandler: (() -> Void)?
    
    @IBAction func handleButton(_ sender: UIButton) {
        tapButtonHandler?(event)
    }
}

// UITableViewDataSource
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath) as! MyCell
    cell.tapButtonHandler = { [weak self] in
        self?.items.remove(at: indexPath.row)
        tableView.deleteRows(at: [indexPath], with: .fade)
    }
    return cell
}

タップした位置に存在する IndexPath を取得するように修正した。

  1. UIEvent から UITouch を取得
  2. UITouch の locationInView メソッドで TableView 上の CGPoint 取得
  3. UITableView の indexPathForRowAtPoint メソッドで CGPoint が含まれる IndexPath 取得

という流れ。

修正後

// セル
class MyCell: UITableViewCell {

    var tapButtonHandler: ((UIEvent) -> Void)?
    
    @IBAction func handleButton(_ sender: UIButton, event: UIEvent) {
        tapButtonHandler?(event)
    }
}

// UITableViewDataSource
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath) as! MyCell
    cell.tapButtonHandler = { [weak self] event in
        guard let self = self else { return }
        if let touch = event.allTouches?.first {
            let point = touch.location(in: self.tableView)
            if let selectedIndexPath = tableView.indexPathForRow(at: point) {
                self.items.remove(at: selectedIndexPath.row)
                tableView.deleteRows(at: [selectedIndexPath], with: .fade)
            }
        }
    }
    return cell
}