Wednesday, November 11, 2009

I know you're tired of hearing about NSFetchedResultsController, but…

Okay, there is a known problem with NSFetchedResultsController when using section name key paths. When you make a change to an existing object that results in a new section being created, it crashes. The fetched results controller's delegate never gets notified of a section insert, nor does it get notified about the object moving between sections. It just gets notification that the object was updated.

I've been fighting with NSFetchedResultsController for quite some time now, but this afternoon, I think I made it my bitch. You can actually handle this situation, though it requires a bit of gnarley code. I'm going to update the Navigation-Based Core Data Application Xcode project template shortly with this logic, but here's how you handle this problem in the fetched results controller delegate methods. Specifically look at the NSFetchedResultsChangeUpdate case statement in controller:didChangeObject:atIndexPath:forChangeType:newIndexPath:. There, we determine if the section name key path has changed, and if it has, we determine if we need to add a new section to the table. Then, we fall through tot he NSFetchedResultsChangeMove and follow the same logic.

One caveat, though, I don't know if this will work with section name key paths that use nested keys (e.g. "foo.bar"). In fact, I'm pretty sure it won't work, but I will look at fixing that a little later. For the vast majority of situations, this should patch up the section problem in NSFetchedResultsController pretty well.

The code has been fixed to support nested keys in the section name key path.

- (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];
NSManagedObject *changedObject = [controller objectAtIndexPath:indexPath];
NSArray *keyParts = [sectionKeyPath componentsSeparatedByString:@"."];
id currentKeyValue = [changedObject valueForKeyPath:sectionKeyPath];

// If nested keys, need to get the related object to get
// the committed key value

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 insertSections:[NSIndexSet indexSetWithIndex:newSectionLocation] withRowAnimation:UITableViewRowAnimationFade];
NSUInteger indices[2] = {newSectionLocation, 0};
newIndexPath = [[[NSIndexPath alloc] initWithIndexes:indices length:2] autorelease];
}

}

case NSFetchedResultsChangeMove:
if (newIndexPath != nil) {
[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;
}

}




10 comments:

rusty said...

I tried your solution above, and my code still crashes when an object moves to a new section...

Grant said...

I am also have the same problem as rusty mentions. I having been trying to fix it to no avail.

The problem seems to be that the

case NSFetchedResultsChangeMove:

section of the didChangeObject method does not take all situations into effect. I seem to be able to fix some scenarios but then break others.

Basically, it does not like it when the object you are changing/moving goes into a new or different section than where it originally was.

If I can figure it out I will post the solution...

Grant said...

Okay, I got it to work for me with the following change below. I cannot guarantee that this will fix all issues as I am always using sectionkeypaths in my application, etc.


Change the NSFetchedResultsChangeMove section to the following:


case NSFetchedResultsChangeMove:
if (newIndexPath != nil)
{
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationNone];
[tableView insertRowsAtIndexPaths: [NSArray arrayWithObject:newIndexPath] withRowAnimation: UITableViewRowAnimationNone];

if ([self.tableView numberOfSections] > [[controller sections] count])
[tableView deleteSections:[NSIndexSet indexSetWithIndex:indexPath.section] withRowAnimation:UITableViewRowAnimationNone];
else
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:[indexPath section]] withRowAnimation:UITableViewRowAnimationNone];

if ([self.tableView numberOfSections] < [[controller sections] count])
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:newIndexPath.section] withRowAnimation:UITableViewRowAnimationNone];
else
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:[newIndexPath section]] withRowAnimation:UITableViewRowAnimationNone];

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

Grant said...

Jeff,

You should also not that your code is not working properly when the sectionKeyPath points to a transient property. Transient properties are not shown in the committedValuesForKey method and will just return nil. This will cause the code to crash or perform improperly.

I haven't been able to find a workaround yet...

I am starting to think that the NSFetchResultsController may be more trouble than it is worth. I have spent more time trying to code around CoreData nuances than the time it would have taken to just write all the code using straight SQLite.

rusty said...

In the end I gave up and went for the cheap and nasty option, just ignoring all 4 of the update methods from the controller and calling:

[self.tableview reloadData];

in my viewWillAppear method. This works for me because all my editting/deleting/adding screens are in other view controllers, so if you refresh the UITableViewController on viewWillAppear everything works hunky dory :)

an0 said...

No wonder my section headers are not updated in time.

Is it a bug Apple already knows?

To rusty:
I don't think reloadData would help, because NSFetchedResultsSectionInfos are not updated.

Edwin said...

scrub m65 kamagra attorney lawyer body scrub field jacket lovegra marijuana attorney injury lawyer

SEO Services Consultants said...

Nice information, many thanks to the author. It is incomprehensible to me now, but in general, the usefulness and significance is overwhelming. Thanks again and good luck! Web Design Company

h4ns said...

What youre saying is completely true. I know that everybody must say the same thing, but I just think that you put it in a way that everyone can understand. I also love the images you put in here. They fit so well with what youre trying to say. Im sure youll reach so many people with what youve got to say.

Arsenal vs Huddersfield Town live streaming
Arsenal vs Huddersfield Town live streaming
Wolverhampton Wanderers vs Stoke City Live Streaming
Wolverhampton Wanderers vs Stoke City Live Streaming
Notts County vs Manchester City Live Streaming
Notts County vs Manchester City Live Streaming
Bologna vs AS Roma Live Streaming
Bologna vs AS Roma Live Streaming
Juventus vs Udinese Live Streaming
Juventus vs Udinese Live Streaming
Napoli vs Sampdoria Live Streaming
Napoli vs Sampdoria Live Streaming
Fulham vs Tottenham Hotspur Live Streaming
Fulham vs Tottenham Hotspur Live Streaming
AS Monaco vs Marseille Live Streaming
AS Monaco vs Marseille Live Streaming
Alajuelense vs Perez Zeledon Live Streaming
Alajuelense vs Perez Zeledon Live Streaming
Technology News | News Today | Live Streaming TV Channels

Gary said...

Is there a definitive solution to this problem?

There seems to be a lot people experiencing the same problem but the solutions only partly fix the problem.