NSFetchedResultsController error: 'no object at index 5 in section at index 0'

I have a UITableView which populate it cells with a NSFetchedResultsController based on CoreData attribute isForConverter which is Bool and should be true to be displayed. isForConverter state sets in another ViewController.

When I want to delete some cells from the UITableView and after access cells which wasn't deleted I receive the error:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'no object at index 5 in section at index 0'

There is a GIF with the problem: https://cln.sh/M1aI9Z

My code for deleting cells. I don't need to delete it from database, just change it isForConverter from true to false:

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            let currency = fetchedResultsController.object(at: indexPath)
            currency.isForConverter = false
            coreDataManager.save()
        }
    }

NSFetchedResultsController Setup and delegates:

func setupFetchedResultsController() {
        let predicate = NSPredicate(format: "isForConverter == YES")
        fetchedResultsController = coreDataManager.createCurrencyFetchedResultsController(with: predicate)
        fetchedResultsController.delegate = self
        try? fetchedResultsController.performFetch()
    }
    
    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.beginUpdates()
    }
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        tableView.endUpdates()
    }
    
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        switch type {
        case .update:
            if let indexPath = indexPath {
                tableView.reloadRows(at: [indexPath], with: .none)
            }
        case .move:
            if let indexPath = indexPath, let newIndexPath = newIndexPath {
                tableView.moveRow(at: indexPath, to: newIndexPath)
            }
        case .delete:
            if let indexPath = indexPath {
                tableView.deleteRows(at: [indexPath], with: .none)
            }
        case .insert:
            if let newIndexPath = newIndexPath {
                tableView.insertRows(at: [newIndexPath], with: .none)
            }
        default:
            tableView.reloadData()
        }
    }
}

I noticed that if I just add tableView.reloadData() to tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) then everything works good. But deletion animation is really fast and antsy. Also according to docs I should not use tableView.reloadData() with NSFetchedResultsController...

How to fix that behaviour?

Answered by Claude31 in 704026022

how can I define the indexPath for selected cell and avoid using tag for it in textFieldDidBeginEditing

What I would try:

  • get the parent cell of the textField (take care, cell's have an extra contentView layer);
guard let cell = textField.superview?.superview as? ConverterTableViewCell else { return }

should provide it (if textField is not embedded in other view inside cell) ; otherwise, need to loop through superviews hierarchy of textField until you reach a UITableViewCell or more precisely ConverterTableViewCell)

  • get its indexPath:
guard let indexPath = tableView.indexPath(for: cell) else { return }

Code becomes:

func textFieldDidBeginEditing(_ textField: UITextField) {
    guard let cell = textField.superview?.superview as? ConverterTableViewCell else { return }
    guard let indexPath = tableView.indexPath(for: cell) else { return }
    pickedCurrency = fetchedResultsController.object(at: indexPath)

Hope that helps.

You should update the dataSource in .move, .delete and .insert. Otherwise, number of cells do not match.

Where do you do it ?

Could you also show cellForRowAt delegate func ?

Then, after, you may call reloadData() once datasource is updated.

Where do you do it ?

Nowhere except at FRC delegate method didChange I showed in the initial post...

Could you also show cellForRowAt delegate func ?

Sure, there it is:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "converterCell", for: indexPath) as! ConverterTableViewCell
        
        let currency = fetchedResultsController.object(at: indexPath)
        cell.flag.image = currencyManager.showCurrencyFlag(currency.shortName ?? "notFound")
        cell.shortName.text = currency.shortName
        cell.fullName.text = currency.fullName
        cell.numberTextField.tag = indexPath.row
        cell.numberTextField.delegate = self
        
        if let number = numberFromTextField, let pickedCurrency = pickedCurrency {
            cell.numberTextField.text = currencyManager.performCalculation(with: number, pickedCurrency, currency)
        }
        return cell
    }

Also show you the method where I reload all rows except one in editing mode:

func textFieldDidChangeSelection(_ textField: UITextField) {
        let activeTextFieldIndexPath = IndexPath(row: textField.tag, section: 0)
        pickedCurrency = fetchedResultsController.object(at: activeTextFieldIndexPath)
        
        guard let currencyObjects = fetchedResultsController.fetchedObjects?.count else {return}
        var nonActiveIndexPaths = [IndexPath]()
        
        for object in 0..<currencyObjects where object != textField.tag  {
            nonActiveIndexPaths.append(IndexPath(row: object, section: 0))
        }
        tableView.reloadRows(at: nonActiveIndexPaths, with: .none)
    }

Then, after, you may call reloadData() once datasource is updated

I found out that if I use reloadData() + 0.2 seconds after deletion, everything works good. But FRC should call reloadData automatically, I shouldn't use it manually...

 case .delete:
 if let indexPath = indexPath {
  tableView.deleteRows(at: [indexPath], with: .none)
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
      self.tableView.reloadData()
      }
   }

I forgot to ask. Could you show the delegate func

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { }

Sure. It's just:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return fetchedResultsController.sections![section].numberOfObjects
    }

I use reloadData() + 0.2 seconds after deletion, everything works good

That shows that you call

self.tableView.reloadData()

before fetch is completed.

hence, there is a mismatch between the number of rows declared numberOfRowsInSection and the number of cells in tableView.

If you wait for 0.2, fetchedResultsController.sections![section].numberOfObjects, is updated.

Usually, this is handled by doing the call (to self.tableView.reloadData()) in a completion handler of the calling

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)

But difficult to say what to write exactly without the complete code of the class.

But difficult to say what to write exactly without the complete code of the class.

Attached is the complete class code:

Usually, this is handled by doing the call (to self.tableView.reloadData()) in a completion handler of the calling

and I can call just tableView.reloadData() in the completion hander, everything works perfect too, except the cell deletion animation lost its smoothness, it became fast and ugly. And if I use +0.2 seconds the animation saves the smoothness like it was.

Also this error happens only when I delete a lot of cells one after another (> 10 cells) like on the GIF from initial post. If I will delete 1-2 cells - there won't be any error and no need to call tableView.reloadData().

and I can call just tableView.reloadData() in the completion hander, everything works perfect too, except the cell deletion animation lost its smoothness, it became fast and ugly. And if I use +0.2 seconds the animation saves the smoothness like it was

delay is always a risk if fetch takes longer than expected.

Can't you set a var fetchedCompleted that would be set true after fetch and use it to condition call to reloadData() ? In such a case, you should timeout after 1 or 2 seconds, to avoid a deadlock.

Could you show code with reloadData() in completion handler, in case someone can find a way to improve smoothness.

Could you show code with reloadData() in completion handler, in case we can find a way to improve smoothness.

Sure. I just add reloadData() to .delete case. Check the GIF with animation in that case: https://cln.sh/F551dX

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        switch type {
        case .update:
            if let indexPath = indexPath {
                tableView.reloadRows(at: [indexPath], with: .none)
            }
        case .move:
            if let indexPath = indexPath, let newIndexPath = newIndexPath {
                tableView.moveRow(at: indexPath, to: newIndexPath)
            }
        case .delete:
            if let indexPath = indexPath {
                tableView.deleteRows(at: [indexPath], with: .none)
                tableView.reloadData()
            }
        case .insert:
            if let newIndexPath = newIndexPath {
                tableView.insertRows(at: [newIndexPath], with: .none)
            }
        default:
            tableView.reloadData()
        }
    }

Then version with asyncAfter .now + 0.5. Check the GIF with animation: https://cln.sh/WK6F8r

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        switch type {
        case .update:
            if let indexPath = indexPath {
                tableView.reloadRows(at: [indexPath], with: .none)
            }
        case .move:
            if let indexPath = indexPath, let newIndexPath = newIndexPath {
                tableView.moveRow(at: indexPath, to: newIndexPath)
            }
        case .delete:
            if let indexPath = indexPath {
                tableView.deleteRows(at: [indexPath], with: .none)
               DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.tableView.reloadData()
                }
            }
        case .insert:
            if let newIndexPath = newIndexPath {
                tableView.insertRows(at: [newIndexPath], with: .none)
            }
        default:
            tableView.reloadData()
        }
    }

Can't you set a var fetchedCompleted that would be set true after fetch

But where is the fetch ends? Where to call that var?

Really not sure. But I would try in func controllerDidChangeContent

Claude, it seems I found out what the reason of that crash was. This is what my print() tryings gave: https://cln.sh/jpB5tG

What is a pickedCurrency: this is a global variable of custom type Currency which I created to receive its attribute currentValue (Double, 87.88). I need that value only from the picked to edit cell. After I use that value for calculation in cellForRowAt() and result of the calculation fills all other cells which is not in edit now.

I define pickedCurrency in textFieldDidBeginEditing() because there I receive the exact row of Currency I picked to edit:

func textFieldDidBeginEditing(_ textField: UITextField) {
        pickedCurrency = fetchedResultsController.object(at: IndexPath(row: textField.tag, section: 0))
        numberFromTextField = 0
        textField.textColor = UIColor(named: "BlueColor")
        textField.placeholder = "0"
        textField.text = ""
    }

And then use it's value in cellForRowAt to calculate all other cells values based on pickedCell value:

 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "converterCell", for: indexPath) as! ConverterTableViewCell
        
        let currency = fetchedResultsController.object(at: indexPath)
        cell.flag.image = currencyManager.showCurrencyFlag(currency.shortName ?? "notFound")
        cell.shortName.text = currency.shortName
        cell.fullName.text = currency.fullName
        cell.numberTextField.tag = indexPath.row
        cell.numberTextField.delegate = self
        
        if let number = numberFromTextField, let pickedCurrency = pickedCurrency {
            cell.numberTextField.text = currencyManager.performCalculation(with: number, pickedCurrency, currency)
        }
        return cell
    }

It seems when I delete a lot of cells and then click on random cell to edit it's not updates its IndexPath(row: textField.tag, section: 0)...

And this is why when I call reloadData() it refreshes pickedCurrency.

Maybe there is a way to receive Currency object I picked for editing in cellForRowAt()?

Accepted Answer

how can I define the indexPath for selected cell and avoid using tag for it in textFieldDidBeginEditing

What I would try:

  • get the parent cell of the textField (take care, cell's have an extra contentView layer);
guard let cell = textField.superview?.superview as? ConverterTableViewCell else { return }

should provide it (if textField is not embedded in other view inside cell) ; otherwise, need to loop through superviews hierarchy of textField until you reach a UITableViewCell or more precisely ConverterTableViewCell)

  • get its indexPath:
guard let indexPath = tableView.indexPath(for: cell) else { return }

Code becomes:

func textFieldDidBeginEditing(_ textField: UITextField) {
    guard let cell = textField.superview?.superview as? ConverterTableViewCell else { return }
    guard let indexPath = tableView.indexPath(for: cell) else { return }
    pickedCurrency = fetchedResultsController.object(at: indexPath)

Hope that helps.

NSFetchedResultsController error: 'no object at index 5 in section at index 0'
 
 
Q