iosswiftuitableviewrx-swiftrx-cocoa

RxSwift: Remove a row in UITableView without reloading whole table


I can achieve this when not using RxSwift by doing the following. It works great, plays needed animation on the row being removed and doesn't reload the whole table:

let item = self.dataSource[(indexPath as NSIndexPath).row]
self.dataSource.remove(at: (indexPath as NSIndexPath).row)
tableView.deleteRows(at: [indexPath], with: .right)

With RxSwift I'm binding data to the tableView by doing this and when I accept new array, it reloads the whole table and the remove animation is not played:

// ViewModel:
let items: BehaviorRelay<[Item]> = BehaviorRelay(value: [])

func removeItem(_ item: Item) {
    var loadedItems = items.value
    if let removeIndex = loadedItems.firstIndex(of: item) {
        loadedItems.remove(at: removeIndex)
    }
    items.accept(loadedItems)
}

//ViewController:
viewModel.items
    .bind(to: tableView.rx.items) { [weak self] (table, index, item) in
        // Creating cell for the `item`....
        return cell   
    }
    .disposed(by: self.disposeBag)

How can I update the viewModel.item source to remove a single element and make sure that the remove animation is playing on the tableview?


Solution

  • There is a library called RxDataSources that implements this very thing. Or you can do it yourself if your needs are simple. Just implement the one required method.

    Here's an example:

    class Example: NSObject, RxTableViewDataSourceType, UITableViewDataSource {
        private var items = [Item]()
    
        func tableView(_ tableView: UITableView, observedEvent: RxSwift.Event<[Item]>) {
            switch observedEvent {
            case let .next(items):
                // figure out which items have been added and which have been removed.
                // animate the table view as usual.
                // then:
                self.items = items
            default:
                break
            }
        }
    
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            items.count
        }
    
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            // build a cell as usual.
        }
    }
    

    You would use this by simply binding with it, like this:

    func example(source: Observable<[Item]>, tableView: UITableView, disposeBag: DisposeBag) {
        source
            .bind(to: tableView.rx.items(dataSource: Example()))
            .disposed(by: disposeBag)
    }
    

    You would add/remove cells by emitting a new array on the source observable.


    Here is a complete example using RxDataSources if you choose to use it:

    typealias ItemSection = AnimatableSectionModel<String, Item>
    
    func example(source: Observable<[ItemSection]>, tableView: UITableView, disposeBag: DisposeBag) {
        let dataSource = RxTableViewSectionedAnimatedDataSource<ItemSection>(
            animationConfiguration: AnimationConfiguration(deleteAnimation: .right), // since you specified right delete in your question
            configureCell: { dataSource, tableView, indexPath, item in
                let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MyTableViewCell
                cell.configure(with: item)
                return cell
            }
        )
    
        source
            .bind(to: tableView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)
    }
    
    struct Item: IdentifiableType, Equatable {
        let identity: Int
        let name: String
    }
    
    final class MyTableViewCell: UITableViewCell {
        func configure(with item: Item) {
            self.textLabel?.text = item.name
        }
    }
    

    Note: in neither case do you need to store a property of the data source, the RxCocoa library will take care of that for you.