Search code examples
iosswiftcore-datansfetchedresultscontroller

NSFetchedResultsController - User Driven Changes


Overview

  • I have an NSFetchedResultsController
  • The user would be able to add new records (table view in edit mode)
  • When user taps on add button, I am able to detect the event and I create a new Car (subclass of NSManagedObject that matches the NSFetchedResultsController's predicate)

Question:

  • How to insert a new row in the table view when the action is user initiated ?
  • Based on my current implementation, app crashes. Crash message is below.
  • How to detect exactly when the model changes take effect ? (Based on the crash message I feel I am inserting the row too early)

Note:

  • I do understand model changes are detected by NSFetchedResultsControllerDelegate but the problem is model is updated and I need the table view to match it.
  • Normally NSFetchedResultsControllerDelegate detects model changes and I can update using the delegate methods.
  • My question is, since user adds row, the model is updated first, then the table view must adjust according to that.

Refer: https://developer.apple.com/documentation/coredata/nsfetchedresultscontrollerdelegate

Creation of NSFetchedResultsController:

let fetchRequest : NSFetchRequest<Car> = Car.fetchRequest()

fetchRequest.predicate = NSPredicate(format: "color = %@", argumentArray: ["green"])

let orderIDSortDescriptor = NSSortDescriptor(keyPath: \Car.price, ascending: true)

fetchRequest.sortDescriptors = [orderIDSortDescriptor]

fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                      managedObjectContext: context,
                                                      sectionNameKeyPath: nil,
                                                      cacheName: nil)

Editing Style

override func tableView(_ tableView: UITableView,
                        editingStyleForRowAt indexPath: IndexPath) -> UITableViewCellEditingStyle {

    let newCarIndex = fetchedResultsController?.fetchedObjects?.count ?? 0

    let editingStyle : UITableViewCellEditingStyle

    switch indexPath.row {

    case newCarIndex:
        editingStyle = .insert

    default:
        break            
    }

    return editingStyle
}

Commit User actions

override func tableView(_ tableView: UITableView,
                        commit editingStyle: UITableViewCellEditingStyle,
                        forRowAt indexPath: IndexPath) {

    switch editingStyle {

    case .insert:
        createGreenCar(at: indexPath) //Creating a new Car with color = Green
        tableView.insertRows(at: [indexPath], with: .automatic) //This causes the app to crash
    default:
        break
    }
}

Crash Error message:

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


Solution

  • Thanks to @Jake and @pbasdf, their suggestions helped my identify and rectify the problem.

    I am answering for completeness.

    Root Cause:

    I had multiple sections in my table view and I was inserting row into the wrong section. As a result the table view row count in the relevant section wasn't changing when the model had changed.

    Approach User driven updates:

    1. Using Arrays

    I feel it is better to transform the results into an array and use the array as the data source instead of the NSFetchedResultsController for user driven updates.

    2. Using NSFetchedResultsController:

    When user inserts / deletes / moves rows UITableViewDataSource methods are invoked:

    • When user inserts / deletes row tableView(_:commit:forRowAt:) will be invoked
    • When user moves row tableView(_:moveRowAt:to:) would be invoked
    • Update Core data accordingly for the above methods

    Updating core data will cause NSFetchedResultsControllerDelegate to be invoked

    • In controller(_:didChange:at:for:newIndexPath:) do the following:
      • For insert - add indexPaths
      • For delete - remove indexPaths
      • For move - do nothing as the UI is already up to date (User has moved the rows), later in controllerDidChangeContent(_:) invoke tableView.reloadData() after a delay of 0.5 seconds.

    Note:

    When the user moved the row on iOS 11.2 (using NSFetchedResultsController) I did encounter the following warning:

    UITableView internal inconsistency: _visibleRows and _visibleCells must be of same length. _visibleRows
    

    I didn't know how to resolve it, so sticking with the array implementation for now.