I want to follow up on a post from a few weeks ago; I’d posted some code that demonstrated how an NSFetchedResultsController
could be used to automatically update a UITableView
when the data in a ManagedObjectContext
were changed. Unfortunately, that code contained a subtle bug related to table scrolling during updates. I’d like to correct the bug and discuss the underlying issue now.
Original Code
Here’s the original, problematic, code snippet. This is from a version of the Code Data “Locations” tutorial project that’s been modified to use an NSFetchedResultsController
. This code was added to that project’s RootViewController
, which served as the delegate for the NSFetchedResultsController
.
#pragma mark fetched results controller delegate methods
- (void)controllerWillChangeContent:(NSFetchedResultsController*)controller
{
[self.tableView beginUpdates];
}
- (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];
[tableView scrollToRowAtIndexPath:newIndexPath atScrollPosition:UITableViewScrollPositionTop animated:YES];
break;
case NSFetchedResultsChangeDelete:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
- (void)controllerDidChangeContent:(NSFetchedResultsController*)controller
{
[self.tableView endUpdates];
}
Problem
So, what’s the problem? The difficultly lies with the scrollToRowAtIndexPath:atScrollPosition:animated:
call. This call is executed immediately, even if the table is in the middle of an update sequence. For instance, if we’re adding a row to the end of a table, then this method will attempt to scroll to it immediately. This will cause a crash, since the row does not yet exist (it won’t be added to the table view until endUpdates
is called).
In the Locations tutorial, this problem is easy to overlook; that app always inserts rows at the head of the table, so the crash will only be triggered when inserting the first entry. If this code is added to the app after at least one row has been added to the DB, the crash may never be triggered in the course of normal development.
At any rate, to avoid this problem, we need to defer scrolling.
Revised Code
Here’s the revised code snippet. This assumes that a retained
NSIndexPath*
property has been added to RootViewController
.
#pragma mark fetched results controller delegate methods
- (void)controllerWillChangeContent:(NSFetchedResultsController*)controller
{
[self.tableView beginUpdates];
}
- (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];
self.postUpdateScrollTarget = newIndexPath;
break;
case NSFetchedResultsChangeDelete:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
- (void)controllerDidChangeContent:(NSFetchedResultsController*)controller
{
[self.tableView endUpdates];
if (self.postUpdateScrollTarget)
{
[self.tableView scrollToRowAtIndexPath:self.postUpdateScrollTarget atScrollPosition:UITableViewScrollPositionTop animated:YES];
}
self.postUpdateScrollTarget = nil;
}
Bonus Problem
As a bit of an aside, I found another, more general problem with table updates. Let’s assume that:
- A and B are rows in a table
- A precedes B
- B is selected
- A is deleted as a result of activity in a controller added atop this one on a Navigation Controller’s view controller stack
In such a case, it proved difficult to deselect B when the table became visible once again. As a precaution, I’ve found it advisable to deselect any selected row when deleting rows during updates. As an example, the primary function above could be altered to read:
- (void)controller:(NSFetchedResultsController*)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath*)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath*)newIndexPath
{
NSIndexPath* selection;
UITableView* tableView = self.tableView;
switch (type)
{
case NSFetchedResultsChangeInsert:
[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
self.postUpdateScrollTarget = newIndexPath;
break;
case NSFetchedResultsChangeDelete:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
if (selection = [tableView indexPathForSelectedRow])
{
[tableView deselectRowAtIndexPath:selection animated:NO];
}
break;
}
}