Is NSFetchedResultsControllerDelegate 'ChangeUpdate' behavior broken? - iphone

The docs for NSFetchedResultsControllerDelegate provide the following sample code
- (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:
[self configureCell:[tableView cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
[tableView reloadSections:[NSIndexSet indexSetWithIndex:newIndexPath.section] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
When I create a new NSManagedObject, NSFetchedResultsChangeInsert fires (great!). When I change the value of an attribute (used for the cell's title), the NSFetchedResultsChangeUpdate fires. Unfortunately, the new title doesn't automatically display unless I reload the table, section or row. Indeed, if the new name causes the result set to sort differently, then NSFetchedResultsChangeMove fires and all is well since the provided code reloads the entire section.
UITableView has a method reloadRowsAtIndexPaths:withRowAnimation so I tried using this under the NSFetchedResultsChangeUpdate code block. It does indeed work ... but the docs for this specific method read as though I don't need it (notice the last line):
Reloading a row causes the
table view to ask its data source for
a new cell for that row. The table
animates that new cell in as it
animates the old row out. Call this
method if you want to alert the user
that the value of a cell is changing.
If, however, notifying the user is not
important—that is, you just want to
change the value that a cell is
displaying—you can get the cell for a
particular row and set its new value.
And yes, if I log what is happening, when
[self configureCell:[tableView cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
gets invoked on an NSFetchedResultsChangeUpdate, it is able to retrieve the latest 'name' value and set it in the cell's textLabel. The name is just not rendering in the cell unless I reload it. Even if I simply click the cell the name shows up. Note that to recreate this behavior, you must create a new managed object and then give it a name that causes it to sort FIRST in the NSFetchedResultsController. That way, the NSFetchedResultsChangeMove doesn't fire (which does work since it reloads the section).
Am I missing something or is this expected behavior? The 'discussion' for reloadRowsAtIndexPaths leads me to believe I should be able to simply set the cell's textLabel without reloading the row, section or table.

You should call [cell setNeedsLayout] or/and [cell setNeedsDisplay] to cause the cell to get refreshed, depending on your cell's implementation.
If you compose the cell of subviews as we usually do, you rely on - layoutSubviews, so you should call [cell setNeedsLayout].
If you draw the cell directly with – drawRect:, you should call [cell setNeedsDisplay].
If you use both composition and drawing, you should call both.

While it's true that you do not need to reload the cell in order to have the changes take place, you have to remember that the iPhone caches view drawing as much as possible. Once you have newly configured your cell, you need to call setNeedsDisplay on the cell in order to trigger the redraw.

Although it's not been explicitly stated, you've actually got to make sure that you include the delegate methods for controllerWillChangeContent: and controllerDidChangeContent:
- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller {
[self.tableView beginUpdates];
}
and
- (void)controllerDidChangeContent:(BSFetchedResultsController *)controller {
#try {
[self.tableView endUpdates];
}
#catch (NSException * e) {
NSLog(#"caught exception: %#: %#", [e name], [e description]);
}
#finally { }
}
The NSFRC can fire off multiple controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: methods during any one change, so don't expect the table row to update straight after each one of those. They'll only update after you call endUpdates on the table.

Calling the UI updating logic in MAIN Thread solved my issue (both [cell setNeedsLayout] & [cell setNeedsDisplay] not works for me):
...
case NSFetchedResultsChangeUpdate:
dispatch_async(dispatch_get_main_queue(), {
[self configureCell:[tableView cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
});
break;
...
What's more (not about this issue, but will be useful), you'd better choose to use the newIndexPath if it's available:
...
case NSFetchedResultsChangeUpdate:
dispatch_async(dispatch_get_main_queue(), {
NSIndexPath * targetIndexPath = (newIndexPath ?: indexPath)
[self configureCell:[tableView cellForRowAtIndexPath:targetIndexPath]
atIndexPath:targetIndexPath];
});
break;
...

Related

NSFetchedResultsController add entity inserts blank row in tableView

In my app I have a viewcontroller which is the delegate of NSFetchedResultsController and the delegate and datasource of a UITableView.
When an add button in the navigation controller is pressed, the next view is pushed correctly and I can correctly add a new Person entity.
My issue is that when the add button is pressed, a blank row is added to the tableView as the new view is pushed, and is still there after the new entity record is correctly created
This is the target action for the add button:
- (void)addPerson:(id)sender
{
AddPersonViewController *addPersonController = [[AddPersonViewController alloc] initWithNibName:#"AddPersonViewController" bundle:nil];
[addPersonController setPerson:[NSEntityDescription insertNewObjectForEntityForName:#"Person" inManagedObjectContext:self.managedObjectContext]];
[self.navigationController pushViewController:addPersonController animated:YES];
}
The code that creates the blank row (from the apple docs for the NSFetchedResultsControllerDelegate) is here:
- (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:
[self configureCell:[tableView cellForRowAtIndexPath:indexPath]
atIndexPath:indexPath];
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath]
withRowAnimation:UITableViewRowAnimationFade];
[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath]
withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
Any ideas how to stop this blank row being created?
Calling
[NSEntityDescription insertNewObjectForEntityForName:#"Person" inManagedObjectContext:self.managedObjectContext]
in addPerson creates a new Person object and adds it to the managed object context. If the fetched results controller of the table view is configured to get all Person objects, this will result in a new (blank) table row for this new object.
I don't know how your AddPersonViewController works. If it modifies the object given via setPerson, then the table row should be updated. If it creates a new Person object, then the first (blank) entry will remain.
You should probably delay the creating of the new Person object until the AddPersonViewController has all data to actually create and populate the object. You could do this either by moving the insertNewObjectForEntityForName: call to the AddPersonViewController, or by using a delegate method in your table view controller that is called from the AddPersonViewController.

NSFetchedResultsController – getting exception when a new section is created (UPDATE: might be when all rows in a section is deleted...)

I've got a view with a TableView and an NSFetchedResultsController.
I'm using a ASINetworkQueue (subclass of NSOperationQueue) and a subclass of ASIHTTPRequest (which in turn is a subclass of NSOperation) to download a JSON feed, parse it and insert corresponding entities into Core Data.
Therefore, in the ASIHTTPRequest subclass, I've got a second NSManagedObjectContext to keep everything threadsafe and nice.
Everything is fine, my background fetching/import fires of each 10 seconds or so, new entities are created and saved into the Core Data store. The NSNotification propagates its way to the ViewController and the NSFetchedResultsController and new rows appear in the TableView.
The problem occurs when the JSON contains an entity with a new value of the section key (lets call it "sectionID") – for example sectionID == 2 instead of sectionID == 1 (you get it?).
At this point, the NSFetchedResultsController should make the table view create a new section, but instead I'm getting an exception:
Serious application error. Exception was caught during Core Data change processing. This is usually a bug within an observer of NSManagedObjectContextObjectsDidChangeNotification. *** -[NSArray initWithObjects:count:]: attempt to insert nil object at objects[0] with userInfo (null)
Assertion failed: (_Unwind_SjLj_Resume() can't return), function _Unwind_SjLj_Resume, file /SourceCache/libunwind/libunwind-24.1/src/Unwind-sjlj.c, line 326.
Here is my code for the NSFetchedResultControllers delegate methods:
-(void)controllerWillChangeContent:(NSFetchedResultsController *)controller
{
[[self eventTable] beginUpdates];
}
-(void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type {
NSIndexSet* set = [NSIndexSet indexSetWithIndex:sectionIndex];
switch (type) {
case NSFetchedResultsChangeInsert:
[[self eventTable] insertSections:set withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[[self eventTable] deleteSections:set withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
-(void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath {
UITableView* tv = [self eventTable];
switch (type) {
case NSFetchedResultsChangeInsert:
[tv insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[tv deleteRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeUpdate:
[self tableView:tv configureCell:[tv cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
break;
}
}
-(void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
[[self eventTable] endUpdates];
}
Any thoughts about whats causing the exception? Thanks in advance!
UPDATE 2011-02-03
Not really sure whether the error occurs when a new section is created, or an old one is deleted. I almost think that this occurs when all the rows in a section is deleted, but by some reason the controller:didChangeSection:atIndex:forChangeType is not being called.
Anyone with experience of something similar?
UPDATE 2011-02-08
I think I solved it. The problem was that some extra conditions had to be taken into concern when determining whether to delete the row/section or not.
When using the code supplied in the Apple documentation (along with a few adjustments to make it work in my view), it runs OK.
I think I solved it. The problem was that some extra conditions had to be taken into concern when determining whether to delete the row/section or not. When using the code supplied in the Apple documentation (along with a few adjustments to make it work in my view), it runs OK.
UPDATE 2011-03-22
Basically I used the same approach as in SafeFetchedResultsController.
I don't know if this is the reason for your error but you are missing the NSFetchedResultsChangeMove case in your delegate methods.
case NSFetchedResultsChangeMove:
[changedTableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
[changedTableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath]withRowAnimation:UITableViewRowAnimationFade];
break;

How can I get my table view to redisplay data when objects are deleted?

I have table view which is in a view controller that inherits from UITableViewController.
I use a NSFetchedResultsController that I use to fetch the table data from my core data store. I've tried it with both caching on and off.
I set the delegate of the NSFetchedResultsController to be self and I've implemented controllerDidChangeContent in my view controller.
I've always implemented the table view delegate functions including commitEditingStyle which is called when the user deletes a row.
Here is what happens. The user swipes and deletes a row and commitEditingStyle is called as expected. In that function, I modify my core data objects to so that they are effectively deleted from results. In other words, if you ran the query I passed NSFetchedResultsController again, that row would now no longer be in the results set.
This alone is not enough, so I added a called to tableView reloadData at the end of commitEditingStyle. Still no luck, so I added a refresh button that calls reloadData and that doesn't help either. Going to another view controller (by hitting "back") and returning to the page doesn't usually work, but eventually it figures it out and the row will disappear correctly. Rerunning the program from scratch always works, of course, but how can I get my tableView to update correctly? controllerDidChangeContent never gets called by the way. I thought reloadData would be enough, but it doesn't seem to be.
Have you implemented the Fetched results controller's controller didChangeObject: delegate method?
The easiest way is to create a new project using navigation based template with core-data on and compare the RootViewController with your code. RootViewController already contains the code to perform the update after tableview modification.
It is something like this:
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath {
if (editingStyle == UITableViewCellEditingStyleDelete) {
// Delete the managed object for the given index path
NSManagedObjectContext *context = [self.fetchedResultsController managedObjectContext];
[context deleteObject:[self.fetchedResultsController objectAtIndexPath:indexPath]];
// Save the context.
NSError *error = nil;
if (![context save:&error]) {
/*
Replace this implementation with code to handle the error appropriately.
abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. If it is not possible to recover from the error, display an alert panel that instructs the user to quit the application by pressing the Home button.
*/
NSLog(#"Unresolved error %#, %#", error, [error userInfo]);
abort();
}
}
}
Then use this method to update the table view
- (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:
[self configureCell:[tableView cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath]withRowAnimation:UITableViewRowAnimationFade];
break;
}

How to refresh a UITableViewController or NSFetchedResultsController?

I have a little problem with my UITableViewController or NSFetchedResultsController. I am not sure which is the problem soure but I guess its the UITableViewController.
As I said I use a NSFetchedResultsController to fill my data into a UITableViewController. The data is sorted by date and is also displayed in sections by date-year, e.g. May 2010/June 2010/ and so on. This is displayed as the section header.
Now when I add new data, it automatically uses the current date as default, but if I want to use a date, that is currently not in the section list, e.g. April 2010 (currently May and June in the list), then this is not displayed correctly. Only when I quit the app and start it again it will show the new section headers and everything is fine.
So I somehow need to be able to refresh my list or update it. By just adding it to the context and changing it does not seem to update it.
Again, I am not sure what I need to update, if it is my NSFetchedResultsController object or the UITableViewController.
UPDATE #1:
I just figured that there is an exception in this method :
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
// The fetch controller has sent all current change notifications, so tell the table view to process all updates.
[self.tableView endUpdates];
}
This is where I move the data :
- (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:
// [self configureCell:[tableView cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
// Reloading the section inserts a new row and ensures that titles are updated appropriately.
[tableView reloadSections:[NSIndexSet indexSetWithIndex:newIndexPath.section] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
This method is pretty much taken from the CoreDataBooks example from Apple. And this is the error :
Serious application error. An
exception was caught from the delegate
of NSFetchedResultsController during a
call to -controllerDidChangeContent:.
*** -[NSMutableArray removeObjectAtIndex:]: index 1 beyond
bounds [0 .. 0] with userInfo (null)
And after that, the edit mode (where the delete button appears) does not work anymore.
Thanks.
Ok, looks like I found a solution, I just skip these
[self.tableView beginUpdates];
[self.tableView endUpdates];
and always do this.
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
[self.tableView reloadData];
}
I will obviously give this some more logic, since the problem only occures when I want to move my data in a section that does not exist before. Until someone can give me a real solution, I will go with this one.
Adopting the fix by Apple in iPhoneCoreDataRecipes in version 1.2, the proper thing to do is to replace
// Reloading the section inserts a new row and ensures that titles are updated appropriately.
[tableView reloadSections:[NSIndexSet indexSetWithIndex:newIndexPath.section] withRowAnimation:UITableViewRowAnimationFade];
with
[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
(FileMerge is your friend!)
Note, there's another fix in version 1.2 that prevents erratic crashing when creating new entries. I had reported this and it was fixed within two weeks. I encourage you to file bug reports with Apple. They're far more responsive than you think, and you can expect to get the best possible fix.
You need to implement the controller:didChangeSection:atIndex:forChangeType: delegate methods. In it, send insertSections:withRowAnimation:, deleteSections:withRowAnimation:, and reloadSections:withRowAnimation: messages to your table view depending on the reported change type.

UITableView insertSections: withRowAnimation: causing all table cells to be requested

I'm working on modifying some existing heavy-handed code that simply calls [tableView reloadData] on any change, to using more specific table updates with the insert/delete methods.
However, I'm getting some really bad behavior in doing so. Previously, as one would imagine, when the table loaded, it only requested cells for the rows that were visible at the time. This was the behavior when reloadData was used.
Now that insertSections is being called, all cells are requested after that update, which can be hundreds. This results in cells being created for every row, completely ruining the reusable cell queue and just being all around wasteful. I must be doing something wrong.
The change is this simple, code that results in the tableView asking only for visible rows:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
// ... ensure it's the right key
[tableView reloadData];
}
Code that results in the tableView asking for everything:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
// ... ensure it's the right key
NSUInteger sectionCount = [self sectionCount];
NSIndexSet *indices = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, sectionCount)];
[tableView insertSections:indices withRowAnimation:UITableViewRowAnimationFade];
}
I can toggle back and forth to see the behavior change. Frustrating. Ideas?
Adding a bounty just to see if anyone has any more insight.
The beginUpdates/endUpdates doesn't affect anything, and I wouldn't expect it to, this is just one command, there's nothing extra to coalesce into a single update.
I'm thinking this is simply a side effect of desiring the animation. To have everything "slide" in, it has to have everything to render. Game over.
It appears you are telling the table to insert all of the sections, which is basically equivalent to a reload. When you tell the table it needs to load a section it needs to read all the items in that section. Also, you are doing it outside of a beginUpdates/endUpdates block, which means every time you add in the section the table has to immediately commit the changes. If you wrap everything inside of a beginUpdates/endUpdates it will suspend all the queries until it is done which will allow the system to coalesce redundant queries and eliminate queries that turn out not to be necessary.
You should only be calling insertSections with new sections that are added. If you do that then tableview does not have to query the information of all the unaltered sections, even if they move around. Also, if elements inside an section change but the section itself doesn't you should use the row version of the methods to modify those specific rows inside of the section, and only those rows will get queried.
Below is an example (from a CoreData based app) that should get the point of across. Since you have a custom model instead an NSFetchedResultsController you will have to figure out the type of action going on instead of just checking a bunch of constants handed to you by the system.
//Calculate what needs to be changed
[self.tableView beginUpdates];
for (i = 0 i < changeCount; i++) {
type = changes[i];
switch(type) {
case NSFetchedResultsChangeInsert:
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex]
withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex]
withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
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:
[self configureCell:(id)[tableView cellForRowAtIndexPath:indexPath]
atIndexPath:indexPath];
break;
case NSFetchedResultsChangeMove:
[self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath]
withRowAnimation:UITableViewRowAnimationFade];
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:newIndexPath.section]
withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
[self.tableView endUpdates];