如何在RxSwift中对UITableView增删单元格后正确滚动?
问题场景
我有一个单Section的UITableView视图控制器,用RxSwift的动画数据源展示数据,单元格用.xib实现。需求是每次data更新时,自动滚动到TableView底部(或指定位置)。
遇到的核心问题:调用scrollToRow时,目标单元格有时还没完成渲染,直接触发报错。试过两种方案但效果不稳定:
- 用
scrollRectToVisible滚动到contentSize底部 - 滚动前判断
data.count == tableView.numberOfRows(inSection: 0)
这两种方式都会出现数据更新后不滚动的情况,虽然不再报错,但完全达不到预期效果。之前想过给滚动加延迟,但延迟时长没法精准控制,想要更可靠的方案——比如等单元格实际渲染完成后再执行滚动。
代码示例:
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: ...), forCellReuseIdentifier: ...) 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) // 这里的判断不总是成立: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: [...]) -> IndexPath { 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) // 配置cell内容 return cell }) } }
可靠解决方案
方案1:监听TableView动画完成事件
RxSwift的TableView绑定动画数据源后,数据更新的动画完成会触发didFinishAnimating事件,我们可以在这个时机执行滚动,确保单元格已经完成渲染。
修改后的核心代码:
class SomeVC: UIViewController { // ... 原有属性保留 private let scrollToBottomTrigger = PublishSubject<Void>() override func viewDidLoad() { super.viewDidLoad() // ... 原有注册、数据源配置代码保留 // 1. 数据绑定逻辑(移除原来的do(afterNext)滚动代码) data .asDriver() .map { [AnimatableSectionModel(...)] } .drive(tableView.rx.items(dataSource: dataSource)) .disposed(by: db) // 2. 数据更新时触发滚动标记 data .skip(1) // 跳过初始值,只处理后续数据更新 .subscribe(onNext: { [weak self] _ in self?.scrollToBottomTrigger.onNext(()) }) .disposed(by: db) // 3. 监听动画完成,执行安全滚动 tableView.rx.didFinishAnimating .filter { $0 } // 只处理动画完成的事件 .withLatestFrom(scrollToBottomTrigger) .subscribe(onNext: { [weak self] in guard let self = self, let lastIndexPath = self.getLastIndexPath(from: self.data.value) else { return } // 先验证indexPath有效性 guard lastIndexPath.row < self.tableView.numberOfRows(inSection: lastIndexPath.section) else { return } self.tableView.scrollToRow(at: lastIndexPath, at: .none, animated: true) }) .disposed(by: db) } // 修改getLastIndexPath,处理空数据情况 private func getLastIndexPath(from data: [...]) -> IndexPath? { guard !data.isEmpty else { return nil } return IndexPath(row: data.count - 1, section: 0) } // ... 其他原有方法保留 }
方案2:利用数据源的批量更新回调
Rx的动画数据源提供了didPerformBatchUpdates回调,会在所有数据更新(包括动画)完成后触发,直接在这里执行滚动即可。
修改createDataSource方法:
private func createDataSource() -> DataSource { let dataSource = DataSource(configureCell: { dataSource, tv, indexPath, model in let cell = tv.dequeueReusableCell(withIdentifier: ..., for: indexPath) // 配置cell内容 return cell }) // 批量更新完成后执行滚动 dataSource.didPerformBatchUpdates = { [weak self] _, _ in guard let self = self, let lastIndexPath = self.getLastIndexPath(from: self.data.value) else { return } guard lastIndexPath.row < self.tableView.numberOfRows(inSection: lastIndexPath.section) else { return } self.tableView.scrollToRow(at: lastIndexPath, at: .none, animated: true) } return dataSource }
同时移除原有do(afterNext)里的滚动代码,这样每次数据源完成更新后都会自动触发滚动。
方案3:封装安全滚动方法
如果不想依赖Rx的回调,可以封装一个安全滚动方法,自动验证indexPath有效性,无效则延迟重试:
extension UITableView { func safeScrollToRow(at indexPath: IndexPath, at position: UITableView.ScrollPosition, animated: Bool, retryCount: Int = 3) { // 先验证indexPath是否有效 guard indexPath.section < numberOfSections, indexPath.row < numberOfRows(inSection: indexPath.section) else { // 重试次数耗尽则停止 guard retryCount > 0 else { return } // 等当前RunLoop循环结束后重试 DispatchQueue.main.async { self.safeScrollToRow(at: indexPath, at: position, animated: animated, retryCount: retryCount - 1) } return } scrollToRow(at: indexPath, at: position, animated: animated) } }
使用时替换原来的scrollToRow:
self?.tableView.safeScrollToRow(at: lastIndexPath, at: .none, animated: true)
内容的提问来源于stack exchange,提问作者MrKew
相关产品推荐
相关产品推荐

