iOS, swift: Invalid update: invalid number of rows in section 0

Viewed 867

I am animating a UITable with a toggle effect. To be able to reach that I am using a delta like this:

fileprivate func onDataUpdatedClosure() {
    deltaProcessingQueue.async {
        let oldList = self.rows
        let newList = self.getCells()

        let deltaAdded = newList.filter { !oldList.contains($0) }.compactMap { newList.firstIndex(of: $0) }
        let deltaRemoved = oldList.filter { !newList.contains($0) }.compactMap { oldList.firstIndex(of: $0) }
        let deltaReload = deltaAdded.filter { deltaRemoved.contains($0) }

        let mapRowsAdded = deltaAdded.filter { !deltaReload.contains($0) }
            .map { DeltaOvm.insert(path: IndexPath(item: $0, section: 0)) }

        let mapRowsRemoved = deltaRemoved.filter { !deltaReload.contains($0) }
            .map { DeltaOvm.delete(path: IndexPath(item: $0, section: 0)) }

        let mapRowsReloaded = deltaReload.map { DeltaOvm.update(path: IndexPath(item: $0, section: 0)) }

        let delta: [DeltaOvm] = mapRowsAdded + mapRowsReloaded + mapRowsRemoved

        if !delta.isEmpty {
            DispatchQueue.main.async { [weak self] in
                self?.rows = newList
                self?.onDataUpdated?(delta)
            }
        }
    }
}

which gets the difference between how table looked before the update and after. It all works fine, until I get to insert 4 cells on one of the objects. Exception happens with the following message:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', 
reason: 'Invalid update: invalid number of rows in section 0. The number of rows 
contained in an existing section after the update (9) must be equal to the number 
of rows contained in that section before the update (5), plus or minus the number 
of rows inserted or deleted from that section (4 inserted, 1 deleted) and plus or 
minus the number of rows moved into or out of that section (0 moved in, 0 moved out).

What's strange is that I'm not deleting any rows, as here you can see the delta content at the breakpoint just at the self?.onDataUpdated?(delta):

enter image description here

The amount of cells before and after update matches with the 4 inserts and 1 update. enter image description here The last update delta cell is causing it. What's interesting is that if I make a number of cells smaller - this works just fine. It starts to fail if there are more that 4 cells being added.

Here is the DeltaOvm enum for reference:

enum DeltaOvm: Equatable {
case insert(path: IndexPath)
case update(path: IndexPath)
case delete(path: IndexPath)

static func == (lhs: DeltaOvm, rhs: DeltaOvm) -> Bool {
    switch (lhs, rhs) {
    case let (.delete(index1), .delete(index2)):
        return index1 == index2
    case let (.update(index1), .update(index2)):
        return index1 == index2
    case let (.insert(index1), .insert(index2)):
        return index1 == index2
    default: return false
    }
  }
}

This is the way table is being updated in the view controller:

fileprivate func setupOnDataUpdated() {
    viewModel.onDataUpdated = { [weak self] (deltas) in

        var insertIndexes = [IndexPath]()
        var updateIndexes = [IndexPath]()
        var deleteIndexes = [IndexPath]()

        for deltaItem in deltas {
            switch deltaItem {
            case .insert(let index):
                insertIndexes.append(index)
            case .update(let index):
                updateIndexes.append(index)
            case .delete(let index):
                deleteIndexes.append(index)
            }
        }

        self?.tableView.performBatchUpdates({
            self?.tableView.insertRows(at: insertIndexes, with: .top)
            self?.tableView.reloadRows(at: updateIndexes, with: .none)
            self?.tableView.deleteRows(at: deleteIndexes, with: .bottom)
        }, completion: nil)
    }
}

This is the row:

enum RowViewModel: Equatable {
    case collectedCellParent(CollectedCellParentViewModel)
    case collectedCellChild(CollectedCellChildViewModel)

    func associatedValue() -> Any {
        switch self {
            case .collectedCellParent(let value): return value
            case .collectedCellChild(let value): return value
        }
    }
}

View model of the cell in question:

class CollectedCellParentViewModel: Equatable, ViewModelPressible {

    var title: String
    var imageName: String
    var cellPosition: CellPosition
    var cellPressed: (() -> Void)?

    init(title: String,
         imageName: String,
         cellPosition: CellPosition,
         cellPressed: (() -> Void)?) {

        self.title = title
        self.imageName = imageName
        self.cellPosition = cellPosition
        self.cellPressed = cellPressed
    }

    static func == (lhs: CollectedCellParentViewModel, rhs: CollectedCellParentViewModel) -> Bool {
        return lhs.title == rhs.title &&
               lhs.cellPosition == rhs.cellPosition
        }
    }
1 Answers

I had a similar issue with UICollectionView and solved it by updating the data source within the performBatchUpdates' update block.

From the UICollectionView.performBatchUpdates(_:completion:) method documentation:

If the collection view's layout is not up to date before you call this method, a reload may occur. To avoid problems, you should update your data model inside the updates block or ensure the layout is updated before you call performBatchUpdates(_:completion:).

I would suggest doing the same with the UITableView. The documentation does not explicitly say it but the updates block documentation from UITableView.performBatchUpdates(_:completion:) could indicate it might be true:

The block that performs the relevant insert, delete, reload, or move operations. In addition to modifying the table's rows, update your table's data source to reflect your changes.

Related