UITableView and NSFetchedResultsController: Updates Done Right
While working on MoneyWell Express 1.0 I decided to finally sit down and figure out a bug that had plagued me for a long time: Periodic and seemingly random crashes when updating MoneyWell’s transaction UITableView. If you’ve spent any significant time with UITableView you’ve undoubtably seen an error similar to this one:
*** Assertion failure in -[UITableView _endCellAnimationsWithContext:], /SourceCache/UIKit_Sim/UIKit-2380.17/UITableView.m:1070
CoreData: 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 2.
The number of rows contained in an existing section after the update (2) 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 (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)
The problem with this bug for me was that it was intermittent and never reliably reproducible – until it was. One day while working on our syncing framework I had this issue start to reproduce itself every time MoneyWell Express attempted to consume some sync changes and I seized the opportunity to finally figure out what was going on.
/* Assume self has a property 'tableView' -- as is the case for an instance of a UITableViewController subclass -- and a method configureCell:atIndexPath: which updates the contents of a given cell with information from a managed object at the given index path in the fetched results controller. */-(void)controllerWillChangeContent:(NSFetchedResultsController*)controller{[self.tableViewbeginUpdates];}-(void)controller:(NSFetchedResultsController*)controllerdidChangeSection:(id)sectionInfoatIndex:(NSUInteger)sectionIndexforChangeType:(NSFetchedResultsChangeType)type{switch(type){caseNSFetchedResultsChangeInsert:[self.tableViewinsertSections:[NSIndexSetindexSetWithIndex:sectionIndex]withRowAnimation:UITableViewRowAnimationFade];break;caseNSFetchedResultsChangeDelete:[self.tableViewdeleteSections:[NSIndexSetindexSetWithIndex:sectionIndex]withRowAnimation:UITableViewRowAnimationFade];break;}}-(void)controller:(NSFetchedResultsController*)controllerdidChangeObject:(id)anObjectatIndexPath:(NSIndexPath*)indexPathforChangeType:(NSFetchedResultsChangeType)typenewIndexPath:(NSIndexPath*)newIndexPath{UITableView*tableView=self.tableView;switch(type){caseNSFetchedResultsChangeInsert:[tableViewinsertRowsAtIndexPaths:[NSArrayarrayWithObject:newIndexPath]withRowAnimation:UITableViewRowAnimationFade];break;caseNSFetchedResultsChangeDelete:[tableViewdeleteRowsAtIndexPaths:[NSArrayarrayWithObject:indexPath]withRowAnimation:UITableViewRowAnimationFade];break;caseNSFetchedResultsChangeUpdate:[selfconfigureCell:[tableViewcellForRowAtIndexPath:indexPath]atIndexPath:indexPath];break;caseNSFetchedResultsChangeMove:[tableViewdeleteRowsAtIndexPaths:[NSArrayarrayWithObject:indexPath]withRowAnimation:UITableViewRowAnimationFade];[tableViewinsertRowsAtIndexPaths:[NSArrayarrayWithObject:newIndexPath]withRowAnimation:UITableViewRowAnimationFade];break;}}-(void)controllerDidChangeContent:(NSFetchedResultsController*)controller{[self.tableViewendUpdates];}
As you can see this code starts the tableView updates in controllerWillChangeContent:, responds to each change as it happens, and then ends the tableView updates in controllerDidChangeContent:. The problem I ran into with this code is that inserting sections into the table also inserted all the rows for that new section, but since those rows were also being reported as inserted we would get twice the number of rows inserted when adding a new section to the table. The answer was to queue up all the updates that the fetchedResultsController reported and then respond to them all at once, like so:
@interfaceSomeViewController()// Declare some collection properties to hold the various updates we might get from the NSFetchedResultsControllerDelegate@property(nonatomic,strong)NSMutableIndexSet*deletedSectionIndexes;@property(nonatomic,strong)NSMutableIndexSet*insertedSectionIndexes;@property(nonatomic,strong)NSMutableArray*deletedRowIndexPaths;@property(nonatomic,strong)NSMutableArray*insertedRowIndexPaths;@property(nonatomic,strong)NSMutableArray*updatedRowIndexPaths;@end@implementationSomeViewController#pragma mark - NSFetchedResultsControllerDelegate methods-(void)controller:(NSFetchedResultsController*)controllerdidChangeObject:(id)anObjectatIndexPath:(NSIndexPath*)indexPathforChangeType:(NSFetchedResultsChangeType)typenewIndexPath:(NSIndexPath*)newIndexPath{if(type==NSFetchedResultsChangeInsert){if([self.insertedSectionIndexescontainsIndex:newIndexPath.section]){// If we've already been told that we're adding a section for this inserted row we skip it since it will handled by the section insertion.return;}[self.insertedRowIndexPathsaddObject:newIndexPath];}elseif(type==NSFetchedResultsChangeDelete){if([self.deletedSectionIndexescontainsIndex:indexPath.section]){// If we've already been told that we're deleting a section for this deleted row we skip it since it will handled by the section deletion.return;}[self.deletedRowIndexPathsaddObject:indexPath];}elseif(type==NSFetchedResultsChangeMove){if([self.insertedSectionIndexescontainsIndex:newIndexPath.section]==NO){[self.insertedRowIndexPathsaddObject:newIndexPath];}if([self.deletedSectionIndexescontainsIndex:indexPath.section]==NO){[self.deletedRowIndexPathsaddObject:indexPath];}}elseif(type==NSFetchedResultsChangeUpdate){[self.updatedRowIndexPathsaddObject:indexPath];}}-(void)controller:(NSFetchedResultsController*)controllerdidChangeSection:(id)sectionInfoatIndex:(NSUInteger)sectionIndexforChangeType:(NSFetchedResultsChangeType)type{switch(type){caseNSFetchedResultsChangeInsert:[self.insertedSectionIndexesaddIndex:sectionIndex];break;caseNSFetchedResultsChangeDelete:[self.deletedSectionIndexesaddIndex:sectionIndex];break;default:;// Shouldn't have a defaultbreak;}}-(void)controllerDidChangeContent:(NSFetchedResultsController*)controller{[self.tableViewbeginUpdates];[self.tableViewdeleteSections:self.deletedSectionIndexeswithRowAnimation:UITableViewRowAnimationAutomatic];[self.tableViewinsertSections:self.insertedSectionIndexeswithRowAnimation:UITableViewRowAnimationAutomatic];[self.tableViewdeleteRowsAtIndexPaths:self.deletedRowIndexPathswithRowAnimation:UITableViewRowAnimationLeft];[self.tableViewinsertRowsAtIndexPaths:self.insertedRowIndexPathswithRowAnimation:UITableViewRowAnimationRight];[self.tableViewreloadRowsAtIndexPaths:self.updatedRowIndexPathswithRowAnimation:UITableViewRowAnimationAutomatic];[self.tableViewendUpdates];// nil out the collections so they are ready for their next use.self.insertedSectionIndexes=nil;self.deletedSectionIndexes=nil;self.deletedRowIndexPaths=nil;self.insertedRowIndexPaths=nil;self.updatedRowIndexPaths=nil;}#pragma mark - Overridden getters/** * Lazily instantiate these collections. */-(NSMutableIndexSet*)deletedSectionIndexes{if(_deletedSectionIndexes==nil){_deletedSectionIndexes=[[NSMutableIndexSetalloc]init];}return_deletedSectionIndexes;}-(NSMutableIndexSet*)insertedSectionIndexes{if(_insertedSectionIndexes==nil){_insertedSectionIndexes=[[NSMutableIndexSetalloc]init];}return_insertedSectionIndexes;}-(NSMutableArray*)deletedRowIndexPaths{if(_deletedRowIndexPaths==nil){_deletedRowIndexPaths=[[NSMutableArrayalloc]init];}return_deletedRowIndexPaths;}-(NSMutableArray*)insertedRowIndexPaths{if(_insertedRowIndexPaths==nil){_insertedRowIndexPaths=[[NSMutableArrayalloc]init];}return_insertedRowIndexPaths;}-(NSMutableArray*)updatedRowIndexPaths{if(_updatedRowIndexPaths==nil){_updatedRowIndexPaths=[[NSMutableArrayalloc]init];}return_updatedRowIndexPaths;}@end
This implementation properly queues all the changes, makes sure not to insert or delete any rows when they are part of an inserted or deleted section, and updates the table in one nice little chunk. You don’t need to worry about implementing the willChangeContent: delegate method. It also has the benefit that, if you were so inclined, you could see how many updates you were about to perform on the tableView and just call reloadData instead, like so: