How to properly scroll after cells were added/removed to UITableView with RxSwift

108 Views Asked by At

I have a view controller with tableView with one section and I am displaying data in it using RxSwift in a following way. I wanna scroll to bottom of the tableView (or to anywhere else) whenever the data change. I am using .xib files for my cells and animated data source.

The problem is, that sometimes when calling scrollToRow the cell is not yet displayed in the tableView and the call throws an error.

How to solve this?

The only thing I can think of is putting a delay between data change and scroll, but how big should the delay be? Also I would think that there is some more robust way to solve this. Somehow waiting until the cell is actually displayed.

I tried doing:

  • scrollRectToVisible(...) to the bottom of tableView.contentSize
  • checking if data.count == tableView.numberOfRows(inSection: 0) before scrolling

Either of them results in the tableView sometimes not scrolling when data was updated, which is better than error, but still not the desired outcome.

class SomeVC: UIViewController {
    private typealias DataSource = RxTableViewSectionedAnimatedDataSource<AnimatableSectionModel<..., ...>>

    private let db = DisposeBag()

    @IBOutlet weak var tableView: UITableView!

    private let data = BehaviorRelay<[...]>(value: ...)
    
    private lazy var dataSource = createDataSource()
    
    override func viewDidLoad() {
        
        tableView.register(UINib(nibName: ..., bundle: ...), forCellResuseIdentifier: ...)
        dataSource.animationConfiguration = AnimationConfiguration(insertAnimation: .bottom, reloadAnimation: .fade, deleteAnimation: .fade)

        data
            .asDriver()
            .do(afterNext: { [unowned self] data in
                DispatchQueue.main.async { [weak self] in
                    let lastIndexPath = getLastIndexPath(from: data)
                    // This does not always hold: data.count == tableView.numberOfRows(inSection: 0)
                    self?.tableView.scrollToRow(at: lastIndexPath, at: .none, animated: true)
                }
            }
            .map { [AnimatableSectionModel(...)] }
            .drive(tableView.rx.items(dataSource: dataSource))
            .disposed(by: db)
    }

    private func getLastIndexPath(from data: [...]) {
        // Something like this
        return IndexPath(row: data.count - 1, section: 0)
    }

    private func createDataSource() -> DataSource {
        return DataSource(configureCell: { dataSource, tv, indexPath, model in
            let cell = tv.dequeueReusableCell(withIdentifier: ..., for: indexPath)
            ...
            return cell
        }
    }
}
1

There are 1 best solutions below

1
Daniel T. On

The following works but it is not ideal:

source
    .map { [SectionModel(model: "", items: $0)] }
    .bind(to: tableView.rx.items(dataSource: dataSource))
    .disposed(by: disposeBag)

source
    .filter { $0.count > 0 }
    .map { IndexPath(row: $0.count - 1, section: 0) }
    .bind(onNext: { [tableView] indexPath in
        tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
    })
    .disposed(by: disposeBag)

The important point, and the big problem, here is that the two subscriptions depend on order. Since they are not independent, if you reverse them the app won't work.

This also worked for me. Maybe you are calculating the last row incorrectly?

source
    .map { [SectionModel(model: "", items: $0)] }
    .do(afterNext: { [tableView] sections in
        if sections[0].items.count > 0 {
            tableView.scrollToRow(at: IndexPath(row: sections[0].items.count - 1, section: 0), at: .bottom, animated: true)
        }
    })
    .bind(to: tableView.rx.items(dataSource: dataSource))
    .disposed(by: disposeBag)