Showing posts with label Xcode Project. Show all posts
Showing posts with label Xcode Project. Show all posts

Wednesday, January 20, 2010

Another TableView / NSFetchedResultsController Gotcha

If you've followed this blog for any length of time, you know that I've been locking horns with NSFetchedResultsController and periodically releasing updated versions of the Navigation-Based Core Data Xcode Template to address the various problems, inconsistencies, and gotchas that I've uncovered during my fight.

Since More iPhone 3 Development was released, I've been getting sporadic reports of a problem with the Chapter 4 version of the Core Data application that, until last night, I hadn't been able to reproduce. One reader was finally able to send me specific instructions, and lo and behold, I was able to reproduce the problem.

So, I started stepping through the code, and found that in certain situations (the parameters of which, I haven't fully figured out yet), my code is attempting to insert two sections in the table when only one new section is required by the update. It happens when a value used in the section key path is changed, but not always when that happens.

What happens is, in controller:didChangeSection:atIndex:forChangeType:, I get notified of a new section being inserted into the fetched results controller and insert a corresponding section at the appropriate spot in the table, like so:

    [self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];

All well and good, right? But then, in controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: which fires afterwards, I have code that checks to make sure the number of sections matches between the fetched results controller and the table view. This code is necessary because in some situations, NSFetchedResultController doesn't tell its delegate if a new section was created. It's a pretty simple check, I just find the number of sections in the fetched results controller and in the table and when they don't match, I insert a new section in the table.

    NSUInteger tableSectionCount = [self.tableView numberOfSections];
NSUInteger frcSectionCount = [[controller sections] count];
if (frcSectionCount != tableSectionCount)
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:[newIndexPath section]] withRowAnimation:UITableViewRowAnimationNone];

And this works most of the time. But, sometimes it doesn't. The sporadic nature makes it hard to debug, but I finally managed to step through the code when it was happening. In controller:didChangeSection:atIndex:forChangeType:, before the line of code that inserts a new section, I checked the number of sections in the table view. There were five.

Then, after the line of code that inserted the section, I checked again. There were still five.

Sounds like a bug in Apple's code, right? Actually, it's not. It's documented behavior.

The documentation for insertRowsAtIndexPath:withRowAnimation: on UITableView says:
UITableView defers any insertions of rows or sections until after it has handled the deletions of rows or sections. This happens regardless of ordering of the insertion and deletion method calls.
This leaves me with quite a conundrum. Since my code is not directly managing the table, but NSFetchedResultsController is deferring certain tasks to its delegate which is my code, I don't have an easy way (that I know of yet) to determine when the row insertion from the earlier code is going to be deferred hence causing my later check to fail.

One solution, which feels kludgey, would be to have a BOOL instance variable to track when an earlier delegate method call inserted a row. I don't like that solution, though, so I'm looking for a better option to incorporate into my generic delegate methods.

I'll keep you all updated on my progress, but if you have any ideas how I can determine if there is a pending insert in a table, feel free to share them in the comments.

Update 1: There is a private mutable array called _insertItems that holds the deferred insertions. Even though it's published in the header file, I think accessing this directly would technically be considered use of a private API. Instance variables with an underscore are considered private by Apple, even if published in a header file.

Update 2: I have an illicit functioning version! Unfortunately, I can't use it because it requires accessing private instance variables of UITableView. Once Apple's Bug Reporter is back up, I'm going to put in an enhancement request to have the information I need made public, but I'm probably going to have to come up with a different interim solution, and it will probably be hacky.

For the curious, what I did was to create a category on UITableView that added this method:

- (NSUInteger)numberOfPendingSectionInserts
{
NSUInteger ret = 0;
for (id /* UIUpdateItem */ oneUpdateItem in _insertItems)
{
if ([oneUpdateItem isSectionOperation])
ret++;
}

return ret;
}

Now, don't use this in your apps, as you will get rejected from the app store. UIUpdateItem is not a public class, and _insertItems is not a public instance variable (though it's contained in a public header file). Were this information to be made available, then I would be able to do a more robust consistency check that would eliminate the double insertion problem:

    NSUInteger tableSectionCount = [self.tableView numberOfSections] + [self.tableView numberOfPendingSectionInserts];
NSUInteger frcSectionCount = [[controller sections] count];
if (frcSectionCount != tableSectionCount)
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:[newIndexPath section]] withRowAnimation:UITableViewRowAnimationNone];

Tuesday, January 5, 2010

Navigation-based Core Data Application Template Reposted

Based on some feedback to an earlier post, I was able to fix another bug in my version of Apple's Navigation-based Core Data Application Xcode template. It was an off-by-one error caused by using a >= when I should have used just a >.

New version of delegate methods follow:

- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller {
[self.tableView beginUpdates];
}

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
[self.tableView endUpdates];
}

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath {
switch(type) {
case NSFetchedResultsChangeInsert:
[self.tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeUpdate: {
NSString *sectionKeyPath = [controller sectionNameKeyPath];
if (sectionKeyPath == nil)
break;
NSManagedObject *changedObject = [controller objectAtIndexPath:indexPath];
NSArray *keyParts = [sectionKeyPath componentsSeparatedByString:@"."];
id currentKeyValue = [changedObject valueForKeyPath:sectionKeyPath];
for (int i = 0; i < [keyParts count] - 1; i++) {
NSString *onePart = [keyParts objectAtIndex:i];
changedObject = [changedObject valueForKey:onePart];
}

sectionKeyPath = [keyParts lastObject];
NSDictionary *committedValues = [changedObject committedValuesForKeys:nil];

if ([[committedValues valueForKeyPath:sectionKeyPath] isEqual:currentKeyValue])
break;

NSUInteger tableSectionCount = [self.tableView numberOfSections];
NSUInteger frcSectionCount = [[controller sections] count];
if (tableSectionCount != frcSectionCount) {
// Need to insert a section
NSArray *sections = controller.sections;
NSInteger newSectionLocation = -1;
for (id oneSection in sections) {
NSString *sectionName = [oneSection name];
if ([currentKeyValue isEqual:sectionName]) {
newSectionLocation = [sections indexOfObject:oneSection];
break;
}

}

if (newSectionLocation == -1)
return; // uh oh

if (!((newSectionLocation == 0) && (tableSectionCount == 1) && ([self.tableView numberOfRowsInSection:0] == 0)))
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:newSectionLocation] withRowAnimation:UITableViewRowAnimationFade];
NSUInteger indices[2] = {newSectionLocation, 0};
newIndexPath = [[[NSIndexPath alloc] initWithIndexes:indices length:2] autorelease];
}

}

case NSFetchedResultsChangeMove:
if (newIndexPath != nil) {

NSUInteger tableSectionCount = [self.tableView numberOfSections];
NSUInteger frcSectionCount = [[controller sections] count];
if (frcSectionCount > tableSectionCount)
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:[newIndexPath section]] withRowAnimation:UITableViewRowAnimationNone];
else if (frcSectionCount < tableSectionCount && tableSectionCount > 1)
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:[indexPath section]] withRowAnimation:UITableViewRowAnimationNone];


[self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
[self.tableView insertRowsAtIndexPaths: [NSArray arrayWithObject:newIndexPath]
withRowAnimation: UITableViewRowAnimationRight
]
;

}

else {
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:[indexPath section]] withRowAnimation:UITableViewRowAnimationFade];
}

break;
default:
break;
}

}

- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type {
switch(type) {

case NSFetchedResultsChangeInsert:
if (!((sectionIndex == 0) && ([self.tableView numberOfSections] == 1)))
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
if (!((sectionIndex == 0) && ([self.tableView numberOfSections] == 1) ))
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeMove:
break;
case NSFetchedResultsChangeUpdate:
break;
default:
break;
}

}