anz blog

didEndDisplayingCellが呼ばれたからと言って表示されていないわけじゃない

2019-04-02 #Swift

ざっくりまとめ。

  • UITableView.relodRows()を実行すると予期せぬdidEndDisplaying cellが呼ばれる
    • cellForAtRow -> willDisplay cell -> didEndDisplaying の発生順
  • Cell自体は表示されているので本来呼ばれるべきではないのでは?
  • 回避策は reloadData() ぐらいしか思い浮かばない
    • 求むよい解決策(笑)

です。

環境

  • Xcode v10.2
  • シミュレータ iOS v12.2

問題

UITableView で一部の行を更新かけるために reloadRows() を実行した場合のライフサイクルはどうなるかわかりますか?
例えば下のGIFのような操作(表示されているTableでタップ後に更新をするだけの単純なもの)をした場合などどうでしょう?

正解はこんな感じになります。

cellForRowAt: 2 - 0
willDisplay: 2 - 0
didEndDisplaying: 2 - 0
didEndDisplaying: 2 - 0

※ row - section という表示です

cellForRowAt: わかる
willDisplay: わかる
didEndDisplaying: ホワイジャパニーズピーポー!

GIF 見て分かる通り IndexPath(row: 2, section: 0) の Cell は表示されている状態です。
が、UITableViewDelegate から通知されるものだけを信じると非表示(領域外)になっているべきなのです。
一体なぜ。。。🤔

一応検証コードをまるっとのせておきます。

class ViewController: UIViewController {
    
    @IBOutlet private weak var tableView: UITableView! {
        didSet {
            tableView.register(UITableViewCell.self, forCellReuseIdentifier: "CELL")
            tableView.dataSource = self
            tableView.delegate = self
        }
    }
    
    private var rows: [String] = {
        return (1...100).map { "row: \($0)" }
    }()
}

extension ViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return rows.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        print("cellForRowAt: \(indexPath.row) - \(indexPath.section)")
        let cell = tableView.dequeueReusableCell(withIdentifier: "CELL", for: indexPath)
        cell.textLabel?.text = rows[indexPath.row]
        return cell
    }
}

extension ViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        rows[indexPath.row] = "Selected Row: \(indexPath.row + 1)"
        tableView.reloadRows(at: [indexPath], with: .automatic)
    }
    
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        print("willDisplay: \(indexPath.row) - \(indexPath.section)")
    }
    
    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        print("didEndDisplaying: \(indexPath.row) - \(indexPath.section)")
    }
}

いろいろごにょります

更新をかけるということではも1つ代表的なものがあります。そう、reloadData() です。
ではこの場合だとどうなるのでしょう?

こうなります。

didEndDisplaying: 11 - 0
didEndDisplaying: 10 - 0
didEndDisplaying: 9 - 0
didEndDisplaying: 8 - 0
didEndDisplaying: 7 - 0
didEndDisplaying: 6 - 0
didEndDisplaying: 5 - 0
didEndDisplaying: 4 - 0
didEndDisplaying: 3 - 0
didEndDisplaying: 2 - 0
didEndDisplaying: 1 - 0
didEndDisplaying: 0 - 0
cellForRowAt: 0 - 0
willDisplay: 0 - 0
cellForRowAt: 1 - 0
willDisplay: 1 - 0
cellForRowAt: 2 - 0
willDisplay: 2 - 0
cellForRowAt: 3 - 0
willDisplay: 3 - 0
cellForRowAt: 4 - 0
willDisplay: 4 - 0
cellForRowAt: 5 - 0
willDisplay: 5 - 0
cellForRowAt: 6 - 0
willDisplay: 6 - 0
cellForRowAt: 7 - 0
willDisplay: 7 - 0
cellForRowAt: 8 - 0
willDisplay: 8 - 0
cellForRowAt: 9 - 0
willDisplay: 9 - 0
cellForRowAt: 10 - 0
willDisplay: 10 - 0
cellForRowAt: 11 - 0
willDisplay: 11 - 0

長いですけど、要は
didEndDisplaying -> cellForRowAt -> willDisplay
という流れになります。これは納得です。
reload する流れとして該当 cell を破棄して再度描画しなおすということでしょうから、
破棄(didEndDisplay)で再描画(cellForRowAt->willDisplay)となるわけですから。

がしかし、同じく reload 処理であるはずの reloadRows() はなぜか、再描画->破棄という流れに見える順序で発生するのです。
ますます不可解です。

どうにか回避できないのか?

表示されているのに didEndDisplaying が呼ばれるのは気持ち悪いし、よばれても実行したくない!と思った場合、
小手先の回避方法として思いつくのが、呼ばれたときに表示されている状態であれば何もしないということです。

func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows, !indexPathsForVisibleRows.contains(indexPath) else {
        return
    }
    print("didEndDisplaying: \(indexPath.row) - \(indexPath.section)")
}

こういう感じでしょうか。
こういうチェックをしておくと、確かに reloadRows() を実行したときに willDisplay 後に呼ばれる didEndDisplaying の処理を無効化はできます。

コレで問題になってくるのが、 reloadData() の場合です。
reloadData() を実行したときにも先に示したとおり didEndDisplaying は呼ばれます。
そして、そのとき実は indexPathsForVisibleRows には画面上表示されている行データがまだ保持されているのです。
なので、チェックに引っかかってしまって無効化されてしまいます。
でも、reloadData() の場合は正しい(期待している)順序で呼ばれてくるわけですから、無効化にはしたくないのです。

ではどうするのか?

正直わかりませんでした。あきらめました。
僕としてはもう reloadData() を使うことでしかこちらが期待している順序で呼ばれるように制御できませんでした。

ただ単に表示内容を変えたい!ということであれば、UITableView が用意している reload 系のものは使わずに
UITableView.cellForRow() を使うなどして該当 Cell を直接とりだし更新かけるという手もありますが、
この場合 Cell の高さが変わるような変更はできないですし、reloadData() の時とライフサイクルが大きく変わってしまうのも気になります。(willDisplayなどが呼ばれなくなるので)

ということで、reload 時に破棄(didEndDisplaying)-> 再描画(cellForRowAt & willDisplay)という発生順を期待して頼っているようなロジックを組んでしまった場合、reloadRows() は使わないほうがいいかもしれません。
reloadData() は実際に更新したい行以外も再描画されるため、 コストを考えるとぜひとも使いたいところなのですが😇

ちなみに reloadSections() も同じ様に再描画->破棄という流れで来ます。

なぜこんな挙動になっているのだろう?

個人的にはバグじゃないの?感はすごいですが...
なにか理由があるのだろうかと思いググっていると、下記ページがちょっときになりました。

(2) didEndDisplayingCell
従来、表示領域を超えて直ちに破棄されていたセルが、しばらく残ることになりました。

例えば、高速にスクロールしている時は、興味あるセルを見つけても、止めるのが間に合わなくて、そのセルが画面外に行ってしまったような場合、直ぐに逆方向に戻せば、まだ、そのセルは破棄されていないので、改めてセルを生成する必要が無いため、スムーズなスクロールに貢献することになります。

なるほど、遅延で破棄されると。。
もしかすると reloadRows() の場合もこの考えみたいのが適用されていたりするのでしょうか。。🤔

破棄(けど遅延実行する、あるいは遅延通知)->再描画->破棄通知

みたいな感じですか。なので、willDisplay 後に didEndDisplaying が呼ばれると。
そう考えればなんとなくわかるのですが、、
破棄を遅延させるのはあくまでもスクロールによる再描画のときであって、reloadRows() のときには適用すべきではないと思いますよね。
reload だと明示的に指定しているのだから、、。やはりバグ感強いのが正直なところ。

とはいえ、もちろん僕の実装が悪い可能性もなくはない、むしろそうだと手が打てるのでありがたいところなのですが、、
てめーここが間違ってんぞ!という指摘をもらえることを期待しながら、こちらからは以上です(笑)

参考

Tells the delegate that the specified cell was removed from the table.

🤔
通知されるタイミングよね。。(汗)