You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.15 08:13:27