I have a UITabbar with multiple controllers in it. One of the controllers is used to add Events to Core Data, while another controller is used to display events as in a UITableView using NSFetchedResultsController.
Here's the behaviour that I would like to achieve:
Upon disappearing, the UITableView stops updating, and when the user comes back, the entire table view is reloaded. Otherwise, inserting events from the other controller takes longer, as new rows are created in the UITableView, even thought it is not visible.
I'm wondering how I can achieve this behavior, as it doesn't seem to work as I expect it would be:
I have set the delegate of the NSFetchedResultsController to nil in viewWillDisappear, and restore it in viewWillAppear, along with a call to [UITableView reloadData];
Somehow, I do not see the new data, and suspect this is due to the way NSFetchedResultsController stops fetching if it does not have a delegate.
How can I properly "suspend" updates to UITableView when it disappears, but still able to see the entire dataset when the controller reappears?
Try sending performFetch: to the NSFetchedResultsController in viewWillAppear: after you have set its delegate back to self.
I think you do not have to "suspend" the table view updates. A UITableView will anyway only request data from the NSFetchedResultsController for visible cells. If the the table view is not visible, no updates will be fired.
Did you test if inserting events from another controller really takes longer? I doubt it. What does Instruments say?
If your delegate methods are fired, you could still check if the table view is visible before doing any updates.
After that, you do exactly as suggested by rob: do a performFetch: in viewWillAppear:.
Instead of setting the delegate of the NSFetchedResultsController to nil in viewWillDisappear, try setting an object of NSFetchedResultsController to nil
What about this crude approach? Not tested it.
#property (nonatomic) BOOL bruteForceReload;
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
self.bruteForceReload = NO;
}
-(void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
self.bruteForceReload = YES;
}
-(void)setBruteForceReload:(BOOL)bruteForceReload {
_bruteForceReload = bruteForceReload;
if (_bruteForceReload) {
[self.tableView reloadData];
}
}
- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller
{
if (!self.bruteForceReload) {
[self.tableView beginUpdates];
}
}
- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo
atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type
{
if (!self.bruteForceReload) {
switch(type) {
case NSFetchedResultsChangeInsert:
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
default:
return;
}
}
}
- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
newIndexPath:(NSIndexPath *)newIndexPath
{
if (!self.bruteForceReload) {
UITableView *tableView = self.tableView;
switch(type) {
case NSFetchedResultsChangeInsert:
[tableView insertRowsAtIndexPaths:#[newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[tableView deleteRowsAtIndexPaths:#[indexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeUpdate:
[self configureCell:[tableView cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:#[indexPath] withRowAnimation:UITableViewRowAnimationFade];
[tableView insertRowsAtIndexPaths:#[newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
}
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
if (!self.bruteForceReload) {
[self.tableView endUpdates];
} else {
[self.tableView reloadData];
}
}
Related
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.
I am adding a new item to the bottom of a UITableView and after inserting the item, I want the UITableView to scroll to the very bottom to display the newly inserted item. New items are saved to Core Data and the UITableView is automatically updated using NSFetchedResultsController.
- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
atIndexPath:(NSIndexPath *)indexPath
forChangeType:(NSFetchedResultsChangeType)type
newIndexPath:(NSIndexPath *)newIndexPath
{
switch (type) {
case NSFetchedResultsChangeInsert:
NSLog(#"*** controllerDidChangeObject - NSFetchedResultsChangeInsert");
[self.tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
//THIS IS THE CODE THAT DOESN'T WORK
[self.tableView scrollToRowAtIndexPath:newIndexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
break;
....
}
This leads to an out of bounds error, I can't seem to make it work. I can scroll to the second to last comment by adjusting the row of the index path, but I can't get to the very last item.
Basically, I'm adding a comment to a table of comments and after a comment is added, I want the table to scroll to the most recent comment.
You need to call endUpdates so that the tableView can calculate its new sections and rows. A simple case would look like this:
[self.tableView beginUpdates];
[self.tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:insertedIndexPath] withRowAnimation:UITableViewRowAnimationFade];
[self.tableView endUpdates];
[self.tableView scrollToRowAtIndexPath:insertedIndexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
As you use NSFetchedResultsController, it is a bit more complicated, as the calls do beginUpdates, insertRowsAtIndexPaths:withRowAnimation:, and endUpdates are typically in different delegate methods. What you could do then is
add a property insertedIndexPath to store the inserted index path
after the -insertRowsAtIndexPaths:withRowAnimation: call in -controller:didChangeObject:atIndexPath:, add
self.insertedIndexPath = insertedIndexPath;
after [self.tableView endUpdates] in -controllerDidChangeContent:, add
if (self.insertedIndexPath) {
[self.tableView scrollToRowAtIndexPath:self.insertedIndexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
self.insertedIndexPath = nil;
}
See if this helps...
[self.tableView beginUpdates];
[self.tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
[self.tableView endUpdates];
[self.tableView scrollToRowAtIndexPath:newIndexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
In my iPhone app, I have a (non-grouped) UITableView which uses all the "bells and whistles," including (a) NSFetchedResultsController to keep it updated, (b) multiple sections, (c) the ability to add and delete items, and (d) the UISearchDisplayController to allow the user to search through the list, which can get to be very long.
I am running into issues when the user tries to delete items while they are in the process of searching. I am getting this error: Serious application error. An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:. Invalid update: invalid number of sections. The number of sections contained in the table view after the update (1) must be equal to the number of sections contained in the table view before the update (21), plus or minus the number of sections inserted or deleted (0 inserted, 0 deleted). with userInfo (null)
It seems that the problem arises from the fact that when the app switches to search mode, instead of the usual number of sections (which are alphabetical, A-Z), it switches to a single section ("Search Results"). So, when it tries to delete the item, it is thinking that the proper number of sections is the larger number when it is simply in a single section (Search Results).
Here's my code for managing the fetched results controller. Do you know what is the proper way to handle this type of action?
- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller {
// if (!self.searchDisplayController.isActive) {
[self.tableView beginUpdates];
// }
}
- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo
atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type {
switch(type) {
case NSFetchedResultsChangeInsert:
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
- (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:
if (!self.searchDisplayController.isActive) {
[tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
} else {
// I currently don't do anything if the search display controller is active, because it is throwing similar errors.
}
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath]withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
// if (!self.searchDisplayController.isActive) {
[self.tableView endUpdates];
// }
}
If you have an NSFetchedResultsController, why don't you just create a predicate and set it on the controller as a filter?
Something like this:
NSPredicate *predicate =[NSPredicate predicateWithFormat:#"name contains[cd] %#", searchText];
[fetchedResultsController.fetchRequest setPredicate:predicate];
NSError *error = nil;
if (![[self fetchedResultsController] performFetch:&error]) {
// Handle error
NSLog(#"Unresolved error %#, %#", error, [error userInfo]);
// Fail
}
[self.tableView reloadData];
I'm currently developing a holiday journal like application which stores each place you have visited by its type. For instance, a restaurant name should be stored under 'food' section. I have managed to work with core data and create the table with no problem. However, the problem is whenever I try to change the type of a place (thus tableview need to reorder), I would get an error message:
2009-07-22 21:04:58.150 HolidayTest[8662:20b] Serious application error. Exception was caught during Core Data change processing: *** -[NSCFArray removeObjectAtIndex:]: index (4) beyond bounds (4) with userInfo (null)
2009-07-22 21:04:58.151 HolidayTest[8662:20b] *** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[NSCFArray removeObjectAtIndex:]: index (4) beyond bounds (4)'
I know the fetch results controller's delegate has updated the table. But the problem is the fetch controller has not updated its own section and row data. The easy way is to tell the fetch results controller to refetch its data if user changes its type. However, it's not an efficient way to manage the data.
Thank you for your answer TahoeWolverine. Here is the code where i change the type of a place where user selects a row on a tableview to change its type.
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
NSIndexPath *selectionIndexPath = [NSIndexPath indexPathForRow:selected inSection:0];
UITableViewCell *checkedCell = [tableView cellForRowAtIndexPath:selectionIndexPath];
checkedCell.accessoryType = UITableViewCellAccessoryNone;
[[tableView cellForRowAtIndexPath:indexPath] setAccessoryType:UITableViewCellAccessoryCheckmark];
selected = indexPath.row;
NSManagedObject *aType = [siteType objectAtIndex:indexPath.row];
self.site.type = [aType valueForKey:#"name"];
[tableView deselectRowAtIndexPath:indexPath animated:YES];
[self.delegate TypeSelectionController:self didChangeType:YES];
}
this code corresponds to the fetch results controller's delegate which runs the following code
- (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];
break;
case NSFetchedResultsChangeDelete:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeUpdate:
[self configureCell:[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;
}
}
- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id) <NSFetchedResultsSectionInfo>)sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type
{
switch(type)
{
case NSFetchedResultsChangeInsert:
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
[self.tableView endUpdates];
}
then i will the get the error message.......
It is difficult to tell exactly what you mean here, but it seems as though you're attempting to remove beyond the end of your array. It's hard to know what you would be doing that is causing that.
If I were to implement this, I would
A) Remove the entry from the table:
[tableView deleteRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationFade];
B) Remove the entry from the data structure.
C) Modify the data (title and whatever else).
D) Add the entry where it needs to be in the data structure.
E) Add the entry where it should be in the table:
[tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationFade];
Hope that helped. I'll keep an eye if you update the question.
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;
...