RxDataSources × UITableView: アニメーション付きでセルを追加した際にスクロール位置を固定する


RxDataSourcesで困ってる人のために。
(もしかしたらもっと良い方法があるのかも)

追記:
Simulatorと実機とで挙動が違うっぽい。
今回の記事は実機でのみ有効です。

TL;DL

RxDataSources × UITableViewでスクロール位置を固定したい
→セルの更新処理がすべてRxDataSourcesに隠蔽されているのでスクロール位置を固定できない
→performBatchUpdatesをoverrideしよう

結論を先に


public class HogeHogeTableView: UITableView {
    private var onTop: Bool {
        return contentOffset.y == 0
    }

    public override func performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) {
        let bottomOffset = contentSize.height - contentOffset.y

        if !onTop {
            CATransaction.begin()
            CATransaction.setDisableActions(true)
        }
        super.performBatchUpdates(updates, completion: { finished in
            guard finished, !self.onTop else { completion?(finished); return }
            self.contentOffset = CGPoint(x: 0, y: self.contentSize.height - bottomOffset)
            completion?(finished)
            CATransaction.commit()
        })
    }
}

なぜスクロール位置がずれるのか?

TableViewに表示されている一番上のセルA(TableViewが保持している一番最初のセルであるとは限りません)のindexPath.rowをxだとすると、TableViewは何がなんでも、このxを保持しようとします。

このことを念頭に置き、実例に沿って考えてみましょう。

例えばTableViewの最上端にセルを一つinsertし、その直後にreloadしたとします。
するとreloadされた瞬間、すべてのセルのindexPathはそれぞれ+1ずつズレていきますから、セルAのindexPath.rowはx+1になる。

一方で、TableViewはxを保持しようとするので、表示されているセルがちょうど一つずつズレていく、そのようにしてスクロール位置がズレていくのです。

何が厄介なのか

厄介なのは以下2点です。

・セルの更新ロジックがすべてRxDataSources内部にかかれている
・アニメーション処理が絡んでくる

というわけでRxDataSourcesのソースから、reload/アニメーションに関するコードを探していきます。

RxDataSourcesのソースを読んでみる

どうやら、RxDataSources/RxTableViewSectionedAnimatedDataSource.swift#L100 あたりにセルの更新ロジックが記述されているようです。

よく見ると、performBatchUpdatesを使っていますね。
公式ドキュメントによると、

Animates multiple insert, delete, reload, and move operations as a group.

You can use this method in cases where you want to make multiple changes to the collection view in one single animated operation, as opposed to in several separate animations. You might use this method to insert, delete, reload, or move cells or use it to change the layout parameters associated with one or more cells.

複数のアニメーション処理をバッチとして実行するようなメソッドだと書かれています。

performBatchUpdates内でtableView.batchUpdatesを実行してますね。
試しにここを攻めていきましょう。

そこで以下のようにコードをいじってみたら、なぜかうまくいきました。

switch dataSource.decideViewTransition(dataSource, tableView, differences) {
case .animated:
    // each difference must be run in a separate 'performBatchUpdates', otherwise it crashes.
    // this is a limitation of Diff tool
    for difference in differences {
        let updateBlock = {
            // sections must be set within updateBlock in 'performBatchUpdates'
            dataSource.setSections(difference.finalSections)
            tableView.batchUpdates(difference, animationConfiguration: dataSource.animationConfiguration)
        }
        if #available(iOS 11, tvOS 11, *) {
            let bottomOffset = tableView.contentSize.height - tableView.contentOffset.y

            CATransaction.begin()
            CATransaction.setDisableActions(true)
            tableView.performBatchUpdates(updateBlock, completion: {
                guard $0 else { return }
                tableView.contentOffset = CGPoint(x:0,
                                                  y: tableView.contentSize.height - bottomOffset)
                CATransaction.commit()
            })
        } else {
            tableView.beginUpdates()
            updateBlock()
            tableView.endUpdates()
        }
    }

コードを直接いじるのもあれですし、他にperformBatchUpdatesを使う機会もなさそうなので適当にクラス作ってoverrideすれば終わりです。


public class HogeHogeTableView: UITableView {
    private var onTop: Bool {
        return contentOffset.y == 0
    }

    public override func performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) {
        let bottomOffset = contentSize.height - contentOffset.y

        if !onTop {
            CATransaction.begin()
            CATransaction.setDisableActions(true)
        }
        super.performBatchUpdates(updates, completion: { finished in
            guard finished, !self.onTop else { completion?(finished); return }
            self.contentOffset = CGPoint(x: 0, y: self.contentSize.height - bottomOffset)
            completion?(finished)
            CATransaction.commit()
        })
    }
}

なぜこれでうまくいくのか?

まず、performBatchUpdates完了時にcontentSize, contentOffsetなどの情報が正常に更新されるよう、CALayerのアニメーションを停止します。(参考)

  CATransaction.begin()
  CATransaction.setDisableActions(true)

performBatchUpdatesの処理が完了すると、TableViewにセルが追加されるので、contentSize.heightはセル分高くなりますから、どの程度の差分があるのかが気になりますね。

そこで、bottomOffsetを記憶します。

 let bottomOffset = contentSize.height - contentOffset.y

次にcontentOffsetをセル分ずらしてあげて、アニメーションを再開してあげれば完成です。

  self.contentOffset = CGPoint(x: 0, y: self.contentSize.height - bottomOffset)
  CATransaction.commit()

最後に、スクロール位置が最上端にある場合は、スクロール位置を固定しなくてもいいので、onTopで制約をつけます。

まとめ

performBatchUpdatesをoverrideしよう👍


public class HogeHogeTableView: UITableView {
    private var onTop: Bool {
        return contentOffset.y == 0
    }

    public override func performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) {
        let bottomOffset = contentSize.height - contentOffset.y

        if !onTop {
            CATransaction.begin()
            CATransaction.setDisableActions(true)
        }
        super.performBatchUpdates(updates, completion: { finished in
            guard finished, !self.onTop else { completion?(finished); return }
            self.contentOffset = CGPoint(x: 0, y: self.contentSize.height - bottomOffset)
            completion?(finished)
            CATransaction.commit()
        })
    }
}