Wrong delegate called for NSFetchedResultsController with iOS10

Hello,


I've an NSFetchedResultscontroller in my app without problems under iOS9.

But with iOS 10 (Beta 7) I've some serious trouble with it:


If a cell is programatically moved to a new section (so moving a cell to existing sections does work as expected) in the database. Under iOS 9 the didChangeObject delegate is called with the correct type NSFetchedResultsChangeMove, and the cell is moved to the new section.


But if I do the same move of a cell to a new section under iOS10 Beta 7 the didChangeObject delegate is called but with the wrong type NSFetchedResultsChangeUpdate. Afterwards the controllerDidChangeContent delegate is called and the following serious arre is logged (causing either a crash of the app or running the NSFetchedResultsController out of synch with the UITableView):


*** Assertion failure in -[UITableView _endCellAnimationsWithContext:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit/UIKit-3599.6/UITableView.m:1610
[error] error: Serious application error.  An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:.  Invalid update: invalid number of rows in section 0.  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 (2), plus or minus the number of rows inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out). with userInfo (null)


This is the explanation in code in the NSFetchedResultsController Delegates:


- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller {
    //STEP 1: Called from both iOS9 + iOS10 after a cell is moved to a
    [self.tableView beginUpdates];
}

- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id )sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type {
    switch(type) {
        case NSFetchedResultsChangeInsert:
            //STEP 2: Called from both iOS9 + iOS10 
            [self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade]
            break;
        case NSFetchedResultsChangeDelete:
            [self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
            break;
    }
}

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath {
    UITableView *tableView = self.tableView;
    switch(type) {
        case NSFetchedResultsChangeInsert:
            [tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
            break;
        case NSFetchedResultsChangeDelete:
            [tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
            break;
        case NSFetchedResultsChangeUpdate:
            //STEP 3: Called from iOS 10
            [[tableView cellForRowAtIndexPath:indexPath] layoutSubviews];
            break;
        case NSFetchedResultsChangeMove:
            //STEP 3: Called from iOS 9
            [tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
            [tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
            break;
    }
}

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
    //STEP 4: Called from both iOS9 + iOS10
    [self.tableView endUpdates];
}


So does anyone know if this is a known issue with iOS10 Beta 6 and will be solved, or is there something wrong with my code?

Replies

I too am seeing this issue, and it seems to conflict what the documentation says (in NSFetchedResultsController.h):


"Move is reported when an object changes in a manner that affects its position in the results. An update of the object is assumed in this case, no separate update message is sent to the delegate.

Update is reported when an object's state changes, and the changes do not affect the object's position in the results."


Hopefully this is just a bug.

Have the same issue. Actually it happened last year:


http://stackoverflow.com/questions/31383760/ios-9-attempt-to-delete-and-reload-the-same-index-path

https://forums.developer.apple.com/thread/4999


It still exists on iOS 10 beta 8.


Hope it's just a bug and will be fixed on final release.

Running into the same issue. It looks like it occurs if the first object of a section is moved so that a new section is created as a result of the change. The delegate method gets called with a .Update type and, for obvious reasons, the table is out of sync because at no point was a cell moved to the new section.


I worked around it for now by just reloading the table on controller changes on iOS 10. My NSFetchedResultsControllerDelegate callbacks are now littered with hacky `respondsToSelector` in order to handle the issues that have come up between iOS 8, 9, and 10. Really frustrating.

Been debugging iOS10 issues and just ran into this. I'm treating all updates as moves on iOS10 for now and that seems to be a decent workaround.

It seems that the issue is still there on iOS 10 GM

I posted a similar bug over at https://forums.developer.apple.com/message/176574#176574


In my case I can do a simple delete to cause an update (vs. iOS 9's behavior of a move).

Has anyone found a workaround ?

How do you handle cross section moves ?

FRC doesnt call controller:didChangeSection:atIndex:forChangeType: when this bug appears.

This isn't a contrived example, but it's certainly abstracted because our FRC is decoupled from the table view. Our problem was that once I "fixed" the issue for iOS 10 it ended up breaking iOS 9 with basically the issue inverted... I've seen 3 or 4 different kinds of "Serious problems" from UITableView and CoreData that needed to be dealt with individually. I racked my brain trying to figure out what the behavioral differences were - and I'm not sure I understand them all.

For example, another issue I saw was calling indexOfObject on the FRC's fetchedObjects gave a different index depending on iOS 9 or 10. On iOS 9 it was always "correct" and on iOS 10 it was often reflecting the index of one operation prior... I'm using the default MagicalRecord setup with a root saving context. So, maybe there's a default property/beavhior that's changed that's making the merging of changes behave differently.

So, what I've done is check for iOS 10 using this line:


        let isRunningiOS10 = NSProcessInfo().isOperatingSystemAtLeastVersion(NSOperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0))


Then, something like this:


            case .Move(_, let fromIndexPath, let toIndexPath):               
                if isRunningiOS10 {
                    endUpdatesIfNeeded()
                }
            
                beginUpdatesIfNeeded()
                tableView?.moveRowAtIndexPath(fromIndexPath, toIndexPath: toIndexPath)
                if isRunningiOS10 {
                    endUpdatesIfNeeded()
                }
            }

This little bit of code allows moves to exist inside of their own table view update. This then aligns the updates with the correct indexes as far as the table view is concerned. Your mileage may vary.

begin/endUpdatesIfNeeded is simple:

    private func beginUpdatesIfNeeded() {
        if !tableViewIsUpdating {
            tableView?.beginUpdates()
            tableViewIsUpdating = true
        }
    }
   
    private func endUpdatesIfNeeded() {
        if tableViewIsUpdating {
            tableView?.endUpdates()
            tableViewIsUpdating = false
        }
    }

This problem has actually been around since Xcode 7; if you search the forums for "NSFetchedResultsController" you'll find related threads.

Each iOS versions behave differently, but all broken. I'm actually surprised you haven't had problems in iOS 9.


If anyone's interested, CoreStore handles this mess with an intermediate delegate that audits and corrects NSFetchedResultController's events: https://github.com/JohnEstropia/CoreStore/blob/swift3_develop/Sources/Internal/FetchedResultsControllerDelegate.swift#L104

I am also experiencing this issue. Not sure if I should fix it in my code or wait for Apple to fix it.

Simple fix: I simply disabled all beginUpdate, insertRowsAtIndexPaths... methods and called [self.tableView reloadData]; in controllerDidChangeContent. Lose the animation, but I can live with it for now.

Not sure if you managed to fix this, but I found a simple solution that works `so far` on iOS 9 and 10 which is to do something like this in the delegate:


func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
     switch type {
     case .Insert:
          tableView.insertRowsAtIndexPaths([newIndexPath!], withRowAnimation: .Fade)
     case .Delete:
          tableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: .Fade)
     case .Move, .Update:
          tableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: .Fade)
          tableView.insertRowsAtIndexPaths([newIndexPath!], withRowAnimation: .Fade)
     }
}


Notice how I do the same thing for Move and Update. This also assumes you've setup the other delegate methods for inserting and removing sections and beginning and ending updates.

Really elegant, the best solution so far, thanks for sharing.

Good solution for ios 10!

And for ios < 10 we are needed to add some assignment: newIndexPath = indexPath