This question already has answers here:
Closed 10 years ago.
Possible Duplicate:
How to implement re-ordering of CoreData records?
I'm trying to find a code sample that shows how to handle moving/rearranging cells in a tableView when the cell uses a fetchedResultsController (i.e. in conjunction with Core Data). I'm getting the moveRowAtIndexPath: call to my data source, but I can't find the right combination of black magic to get the table/data to recognize the change properly.
For example, when I move row 0 to row 2 and then let go, it "looks" correct. Then I click "Done". The row (1) that had slid up to fill row 0 still has it's editing mode appearance (minus and move icons), while the other rows below slide back to normal appearance. If I then scroll down, as row 2 (originally 0, remember?) nears the top, it completely disappears.
WTF. Do I need to somehow invalidate the fetchedResultsController? Whenever I set it to nil, I get crashes. Should I release it instead? Am I in the weeds?
Here's what I've currently got in there...
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath {
NSManagedObjectContext *context = [fetchedResultsController managedObjectContext];
/*
Update the links data in response to the move.
Update the display order indexes within the range of the move.
*/
if (fromIndexPath.section == toIndexPath.section) {
NSInteger start = fromIndexPath.row;
NSInteger end = toIndexPath.row;
NSInteger i = 0;
if (toIndexPath.row < start)
start = toIndexPath.row;
if (fromIndexPath.row > end)
end = fromIndexPath.row;
for (i = start; i <= end; i++) {
NSIndexPath *tempPath = [NSIndexPath indexPathForRow:i inSection:toIndexPath.section];
LinkObj *link = [fetchedResultsController objectAtIndexPath:tempPath];
//[managedObjectContext deleteObject:[fetchedResultsController objectAtIndexPath:tempPath]];
link.order = [NSNumber numberWithInteger:i];
[managedObjectContext refreshObject:link mergeChanges:YES];
//[managedObjectContext insertObject:link];
}
}
// Save the context.
NSError *error;
if (![context save:&error]) {
// Handle the error...
}
}
- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller {
// The fetch controller is about to start sending change notifications, so prepare the table view for updates.
if (self.theTableView != nil)
[self.theTableView beginUpdates];
}
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
// The fetch controller has sent all current change notifications, so tell the table view to process all updates.
if (self.theTableView != nil) {
[self.theTableView endUpdates];
}
}
Usually when you see artifacts like that what is going on is the UI has animated to a new position and told you about it, then the updates you have done to your model don't correctly reflect the state which results in glitches the next time the view has to refer to the model for an update.
I think you don't exactly understand what you are supposed to do in the method. It is called because the UI has changed and it needs to let the model to change accordingly. The code below presumes the results are already in the new order and you just need to reset the order field for some reason:
for (i = start; i <= end; i++) {
NSIndexPath *tempPath = [NSIndexPath indexPathForRow:i inSection:toIndexPath.section];
LinkObj *link = [fetchedResultsController objectAtIndexPath:tempPath];
//[managedObjectContext deleteObject:[fetchedResultsController objectAtIndexPath:tempPath]];
link.order = [NSNumber numberWithInteger:i];
[managedObjectContext refreshObject:link mergeChanges:YES];
//[managedObjectContext insertObject:link];
}
The catch is that you are not actually changing the order in the underlying model. Those indexPaths are from UITableViewController, it is telling you that the user dragged between those to spots and you need to update the underlying data according. But the fetchedResultsController is always in sort order, so until you have changed those properties nothing has moved.
The thing is, they have not been moved, you are being called to tell you that you need to move them around (by adjusting the sortable property). You really need to something more like:
NSNumber *targetOrder = [fetchedResultsController objectAtIndexPath:toIndexPath];
LinkObj *link = [fetchedResultsController objectAtIndexPath:FromPath];
link.order = targetOrder;
Which will cause the objects to reorder, then go through and clean up any of the order numbers of other objects that should have shifted up, being aware the indexes may have moved.
Here's what's officially working now, with deletes, moves, and inserts. I "validate" the order any time there's an edit action affecting the order.
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.section != kHeaderSection) {
if (editingStyle == UITableViewCellEditingStyleDelete) {
#try {
LinkObj * link = [self.fetchedResultsController objectAtIndexPath:indexPath];
debug_NSLog(#"Deleting at indexPath %#", [indexPath description]);
//debug_NSLog(#"Deleting object %#", [link description]);
if ([self numberOfBodyLinks] > 1)
[self.managedObjectContext deleteObject:link];
}
#catch (NSException * e) {
debug_NSLog(#"Failure in commitEditingStyle, name=%# reason=%#", e.name, e.reason);
}
}
else if (editingStyle == UITableViewCellEditingStyleInsert) {
// we need this for when they click the "+" icon; just select the row
[theTableView.delegate tableView:tableView didSelectRowAtIndexPath:indexPath];
}
}
}
- (BOOL)validateLinkOrders {
NSUInteger index = 0;
#try {
NSArray * fetchedObjects = [self.fetchedResultsController fetchedObjects];
if (fetchedObjects == nil)
return NO;
LinkObj * link = nil;
for (link in fetchedObjects) {
if (link.section.intValue == kBodySection) {
if (link.order.intValue != index) {
debug_NSLog(#"Info: Order out of sync, order=%# expected=%d", link.order, index);
link.order = [NSNumber numberWithInt:index];
}
index++;
}
}
}
#catch (NSException * e) {
debug_NSLog(#"Failure in validateLinkOrders, name=%# reason=%#", e.name, e.reason);
}
return (index > 0 ? YES : NO);
}
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath {
NSArray * fetchedObjects = [self.fetchedResultsController fetchedObjects];
if (fetchedObjects == nil)
return;
NSUInteger fromRow = fromIndexPath.row + NUM_HEADER_SECTION_ROWS;
NSUInteger toRow = toIndexPath.row + NUM_HEADER_SECTION_ROWS;
NSInteger start = fromRow;
NSInteger end = toRow;
NSInteger i = 0;
LinkObj *link = nil;
if (toRow < start)
start = toRow;
if (fromRow > end)
end = fromRow;
#try {
for (i = start; i <= end; i++) {
link = [fetchedObjects objectAtIndex:i]; //
//debug_NSLog(#"Before: %#", link);
if (i == fromRow) // it's our initial cell, just set it to our final destination
link.order = [NSNumber numberWithInt:(toRow-NUM_HEADER_SECTION_ROWS)];
else if (fromRow < toRow)
link.order = [NSNumber numberWithInt:(i-1-NUM_HEADER_SECTION_ROWS)]; // it moved forward, shift back
else // if (fromIndexPath.row > toIndexPath.row)
link.order = [NSNumber numberWithInt:(i+1-NUM_HEADER_SECTION_ROWS)]; // it moved backward, shift forward
//debug_NSLog(#"After: %#", link);
}
}
#catch (NSException * e) {
debug_NSLog(#"Failure in moveRowAtIndexPath, name=%# reason=%#", e.name, e.reason);
}
}
- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath {
#try {
switch (type) {
case NSFetchedResultsChangeInsert:
[theTableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
[self validateLinkOrders];
break;
case NSFetchedResultsChangeUpdate:
break;
case NSFetchedResultsChangeMove:
self.moving = YES;
[self validateLinkOrders];
break;
case NSFetchedResultsChangeDelete:
[theTableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
[self validateLinkOrders];
break;
default:
break;
}
}
#catch (NSException * e) {
debug_NSLog(#"Failure in didChangeObject, name=%# reason=%#", e.name, e.reason);
}
}
- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type {
switch(type) {
case NSFetchedResultsChangeInsert:
[self.theTableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[self.theTableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
// The fetch controller has sent all current change notifications, so tell the table view to process all updates.
#try {
if (self.theTableView != nil) {
//[self.theTableView endUpdates];
if (self.moving) {
self.moving = NO;
[self.theTableView reloadData];
//[self performSelector:#selector(reloadData) withObject:nil afterDelay:0.02];
}
[self performSelector:#selector(save) withObject:nil afterDelay:0.02];
}
}
#catch (NSException * e) {
debug_NSLog(#"Failure in controllerDidChangeContent, name=%# reason=%#", e.name, e.reason);
}
}
The best answer is actually in Clint Harris's comment on the question:
http://www.cimgf.com/2010/06/05/re-ordering-nsfetchedresultscontroller
To quickly summarise, the essential part is to have a displayOrder property on the objects you are trying to rearrange with the sort description for the fetched results controller ordering on that field. The code for moveRowAtIndexPath:toIndexPath: then looks like this:
- (void)tableView:(UITableView *)tableView
moveRowAtIndexPath:(NSIndexPath *)sourceIndexPath
toIndexPath:(NSIndexPath *)destinationIndexPath;
{
NSMutableArray *things = [[fetchedResultsController fetchedObjects] mutableCopy];
// Grab the item we're moving.
NSManagedObject *thing = [[self fetchedResultsController] objectAtIndexPath:sourceIndexPath];
// Remove the object we're moving from the array.
[things removeObject:thing];
// Now re-insert it at the destination.
[things insertObject:thing atIndex:[destinationIndexPath row]];
// All of the objects are now in their correct order. Update each
// object's displayOrder field by iterating through the array.
int i = 0;
for (NSManagedObject *mo in things)
{
[mo setValue:[NSNumber numberWithInt:i++] forKey:#"displayOrder"];
}
[things release], things = nil;
[managedObjectContext save:nil];
}
The Apple documentation also contains important hints:
https://developer.apple.com/library/ios/#documentation/CoreData/Reference/NSFetchedResultsControllerDelegate_Protocol/Reference/Reference.html
This is also mentioned in How to implement re-ordering of CoreData records?
To quote the Apple documentation:
User-Driven Updates
In general, NSFetchedResultsController is designed to respond to
changes at the model layer. If you allow a user to reorder table rows,
then your implementation of the delegate methods must take this into
account.
Typically, if you allow the user to reorder table rows, your model
object has an attribute that specifies its index. When the user moves
a row, you update this attribute accordingly. This, however, has the
side effect of causing the controller to notice the change, and so
inform its delegate of the update (using
controller:didChangeObject:atIndexPath:forChangeType:newIndexPath:).
If you simply use the implementation of this method shown in “Typical
Use,” then the delegate attempts to update the table view. The table
view, however, is already in the appropriate state because of the
user’s action.
In general, therefore, if you support user-driven updates, you should
set a flag if a move is initiated by the user. In the implementation
of your delegate methods, if the flag is set, you bypass main method
implementations; for example:
- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
newIndexPath:(NSIndexPath *)newIndexPath {
if (!changeIsUserDriven) {
UITableView *tableView = self.tableView;
// Implementation continues...
When you move a row in the table view, you actually move a block of other rows (consisting of at least one row) into the other direction at the same time. The trick is to only update the displayOrder property of this block and of the moved item.
First, ensure that the displayOrder property of all rows is set according to the tables current display order. We don't have to save the context here, we will save it later when the actual move operation finished:
- (void)setEditing:(BOOL)editing animated:(BOOL)animated {
[super setEditing:editing animated:animated];
[_tableView setEditing:editing animated:animated];
if(editing) {
NSInteger rowsInSection = [self tableView:_tableView numberOfRowsInSection:0];
// Update the position of all items
for (NSInteger i=0; i<rowsInSection; i++) {
NSIndexPath *curIndexPath = [NSIndexPath indexPathForRow:i inSection:0];
SomeManagedObject *curObj = [_fetchedResultsController objectAtIndexPath:curIndexPath];
NSNumber *newPosition = [NSNumber numberWithInteger:i];
if (![curObj.displayOrder isEqualToNumber:newPosition]) {
curObj.displayOrder = newPosition;
}
}
}
}
Then the only thing you have to do is to update the position of the moved item and of all items between fromIndexPath and toIndexPath:
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath {
NSInteger moveDirection = 1;
NSIndexPath *lowerIndexPath = toIndexPath;
NSIndexPath *higherIndexPath = fromIndexPath;
if (fromIndexPath.row < toIndexPath.row) {
// Move items one position upwards
moveDirection = -1;
lowerIndexPath = fromIndexPath;
higherIndexPath = toIndexPath;
}
// Move all items between fromIndexPath and toIndexPath upwards or downwards by one position
for (NSInteger i=lowerIndexPath.row; i<=higherIndexPath.row; i++) {
NSIndexPath *curIndexPath = [NSIndexPath indexPathForRow:i inSection:fromIndexPath.section];
SomeManagedObject *curObj = [_fetchedResultsController objectAtIndexPath:curIndexPath];
NSNumber *newPosition = [NSNumber numberWithInteger:i+moveDirection];
curObj.displayOrder = newPosition;
}
SomeManagedObject *movedObj = [_fetchedResultsController objectAtIndexPath:fromIndexPath];
movedObj.displayOrder = [NSNumber numberWithInteger:toIndexPath.row];
NSError *error;
if (![_fetchedResultsController.managedObjectContext save:&error]) {
NSLog(#"Could not save context: %#", error);
}
}
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath{
[self.pairs exchangeObjectAtIndex:sourceIndexPath.row withObjectAtIndex:destinationIndexPath.row];
[self performSelector:#selector(reloadData) withObject:nil afterDelay:0.02];
}
- (void)reloadData{
[table reloadData];
}
The table can not reload while it is moving, reload after a delay and you will be fine.
Sorry Greg, I am sure I am doing something wrong, but you answer doesn't work for me.
Although all my objects validate properly, when I exit edit mode, one of the rows freezes (the editing controls don't disappear) and the cells don't respond properly afterwards.
Maybe my problem is that I don't know how to use the moving property that you set (self.moving = YES). Could you please clarify this? Thank you very much.
Jorge
This is very difficult way to implement such functionality.
Much easier and elegant way can be found here:
UITableView Core Data reordering
[<> isEditing] can be used to determine whether editing of the table is enabled or not. Rather than delaying it as suggested by using the following statement
[table performSelector:#selector(reloadData) withObject:nil afterDelay:0.02];
Related
I have a multi-section tableview. In edit mode I allow rows to be moved from one section to another. Once the final row is removed from one section I delete that section. So I am using deleteSection inside moveRowAtIndexPath.
When the final item is moved from the section, the section header disappears as planned. But there is a very strange animation bug, where the moved row seems to 'merge' with the row it is dropped above, and an empty row is displayed at the bottom of the 'to' section (probably because the numberOfRows for that section is correct, but 2 rows are in the same position). Even stranger, when I click the reorder control for this row (not moving the item, simply touching and releasing), the two items 'unmerge'.
I have posted a video demonstrating this.
I have tried wrapping my data changes and view changes in begin/end updates, but to no avail.
I have uploaded a test project here, and I will also post the code below. A couple of points:
I have tried to replicate my data source's format in the demo project, in case this is where the problem originates. The key thing is that my source is a composite array of two other arrays (though I can't see why this would be an issue).
To see the behavior in question, move the two rows in the bottom section, up into the top section. Don't drop them in the last row on the top section though, since this seems to work ok.
Moving rows the other way, from the top section to the bottom section, is buggy in this demo project.
Code (all of this is in the demo project):
I set up my arrays in loadView:
- (void)loadView{
array1 = [NSMutableArray array];
[array1 addObject:#"test 0"];
[array1 addObject:#"test 1"];
[array1 addObject:#"test 2"];
array2 = [NSMutableArray array];
[array2 addObject:#"test a"];
[array2 addObject:#"test b"];
[super loadView];
}
I also have a method that returns a combination of these arrays - this is used as the data source:
- (NSMutableArray *)sourceArray{
NSMutableArray *result = [NSMutableArray array];
if (array1.count > 0) {
[result addObject:array1];
}
if (array2.count >0) {
[result addObject:array2];
}
return result;
}
Which allows for very simple number of rows/sections:
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
// Return the number of sections.
return self.sourceArray.count;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
// Return the number of rows in the section.
return [[self.sourceArray objectAtIndex:section] count];
}
Standard Cell/Header formatting:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = #"Cell";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
cell.textLabel.text = [[self.sourceArray objectAtIndex:indexPath.section] objectAtIndex:indexPath.row];
return cell;
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
return [NSString stringWithFormat:#"Section %i", section];
}
This is where I do the magic
// Override to support rearranging the table view.
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath
{
NSMutableArray *fromArray = [self.sourceArray objectAtIndex:fromIndexPath.section];
NSMutableArray *toArray = [self.sourceArray objectAtIndex:toIndexPath.section];
NSString *movedObject = [[self.sourceArray objectAtIndex:fromIndexPath.section] objectAtIndex:fromIndexPath.row];
[fromArray removeObject:movedObject];
[toArray insertObject:movedObject atIndex:toIndexPath.row];
if ([self.tableView numberOfRowsInSection: fromIndexPath.section] == 0) {
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:fromIndexPath.section] withRowAnimation:UITableViewRowAnimationFade];
}
}
I notice that the row that comes from the to-be-deleted section is the one that disappears until you retouch the order control.
I suspect that when this datasource method is called by the tableview, its state is still in the middle of performing the move, so calling 'deleteSections' will make the table try and delete the row you're moving. It's not so much of a merge as the fact that it's fading away at the same rate as the section header, and the one below it is just scooting back up to fill the space.
Tapping the control causes the table view to rejigger itself and realize that the row isn't actually gone.
to try and work around this, try running the deletion in the next runloop, via a dispatch call, like:
if ([self.tableView numberOfRowsInSection: fromIndexPath.section] == 0) {
dispatch_async(dispatch_get_main_queue(), ^() {
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:fromIndexPath.section] withRowAnimation:UITableViewRowAnimationFade];
});
}
this will cause the deletion to run on the main thread still, but allow the 'moveRow' and whatever call stack it happens to be in finish up its logic before the deletion call
Your problem is in the animation. One is being done while another is not yet finished (moving & deleting animation) causing one cell to be drawn upon the other. You can verify this by moving the cells around again. The correct order will then be displayed. According to Apple's docs on the UITableView:
Note: The data source should not call setEditing:animated: from within its implementation of tableView:commitEditingStyle:forRowAtIndexPath:. If for some reason it must, it should invoke it after a delay by using the performSelector:withObject:afterDelay: method.
Therefore to fix this, do this to your code:
if ([self.tableView numberOfRowsInSection: fromIndexPath.section] == 0) {
[self performSelector:#selector(someMethod:) withObject:fromIndexPath afterDelay:1.0];
}
- (void) someMethod:(NSIndexPath *) fromIndexPath {
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:fromIndexPath.section] withRowAnimation:UITableViewRowAnimationFade];
}
Should work fine. Just change the delay to something shorter that suites you.
On the off chance that your rows or what's inside them can take focus, have you checked that you have called resignFirstResponder or [view endEditing:YES]? We saw this when we used text fields and (IIRC it was iOS 4 version dependent too) left the focus in one of the fields.
You have to reload the tableview after deleting the section. Try this code.
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath
{
NSMutableArray *fromArray = [self.sourceArray objectAtIndex:fromIndexPath.section];
NSMutableArray *toArray = [self.sourceArray objectAtIndex:toIndexPath.section];
NSString *movedObject = [[self.sourceArray objectAtIndex:fromIndexPath.section] objectAtIndex:fromIndexPath.row];
[fromArray removeObject:movedObject];
[toArray insertObject:movedObject atIndex:toIndexPath.row];
if ([self.tableView numberOfRowsInSection: fromIndexPath.section] == 0) {
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:fromIndexPath.section] withRowAnimation:UITableViewRowAnimationFade];
[self.tableView reloadData];
}
}
Swap the order of fromArray and toArray in your code. If the item has a retain count of 1 prior to removing it from the array, it will have a retain count of 0 before adding it to toArray.
If you swap the order, the item will go from retain count of 1 to 2 then back to 1 when the remove is complete.
I think the UITableViewRowAnimationFade animation is interfering with the UITableViewCell move animation. One thing you can try is to delay the section deletion a little bit late in order for the cell move row animation to finish.
Try replace your code with the following code.
-(void)deleteSection:(NSIndexSet*)indexSet
{
[self.tableView deleteSections:indexSet withRowAnimation:UITableViewRowAnimationFade];
}
// Override to support rearranging the table view.
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath
{
NSMutableArray *fromArray = [self.sourceArray objectAtIndex:fromIndexPath.section];
NSMutableArray *toArray = [self.sourceArray objectAtIndex:toIndexPath.section];
NSString *movedObject = [[self.sourceArray objectAtIndex:fromIndexPath.section] objectAtIndex:fromIndexPath.row];
[fromArray removeObject:movedObject];
[toArray insertObject:movedObject atIndex:toIndexPath.row];
if ([self.tableView numberOfRowsInSection: fromIndexPath.section] == 0) {
[self performSelector:#selector(deleteSection:) withObject:[NSIndexSet indexSetWithIndex:fromIndexPath.section] afterDelay:1.0];
// [self.tableView deleteSections:[NSIndexSet indexSetWithIndex:fromIndexPath.section] withRowAnimation:UITableViewRowAnimationFade];
}
}
a solution that lost animation on last row :
if([listOfItemsOnTransaction count]==indexPath.row){
[self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObjects:indexPath, nil]
withRowAnimation:UITableViewRowAnimationFade];
[self.tableView reloadData];
}else
{
[self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObjects:indexPath, nil]
withRowAnimation:UITableViewRowAnimationFade];
}
After days of research and re-coding I am pretty much stumped. My goal is to get a test app running with a single tableview populated from two separate fetchedResultControllers.
I have a series of items on a shopping list, each with a department and a boolean 'collected' flag. Uncollected items should be listed by department, followed by a single section containing all collected items (regardless of department). As a user checks off uncollected items, they should move down to the 'collected' section. If s/he un-checks a collected item, it should move back into its correct department.
To achieve the first part (uncollected items), I set up a simple fetchedResultsController that fetches all items where collected = NO, and sectioned the results by department:
- (NSFetchedResultsController *)firstFRC {
// Set up the fetched results controller if needed.
if (firstFRC == nil) {
// Create the fetch request for the entity.
NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init];
// fetch items
NSEntityDescription *entity = [NSEntityDescription entityForName:#"Item" inManagedObjectContext:managedObjectContext];
[fetchRequest setEntity:entity];
// only if uncollected
NSPredicate *predicate = [NSPredicate predicateWithFormat:#"(collected = NO)"];
[fetchRequest setPredicate:predicate];
// sort by name
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:#"name" ascending:YES];
NSArray *sortDescriptors = [[NSArray alloc] initWithObjects:sortDescriptor, nil];
[fetchRequest setSortDescriptors:sortDescriptors];
// fetch results, sectioned by department
NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest managedObjectContext:managedObjectContext sectionNameKeyPath:#"department" cacheName:nil];
aFetchedResultsController.delegate = self;
self.firstFRC = aFetchedResultsController;
}
return firstFRC;
}
I set the number of rows, sections, and section headers as follows:
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
// Return the number of sections.
return firstFRC.sections.count;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
// Return the number of rows in the section.
return [[firstFRC.sections objectAtIndex:section] numberOfObjects];
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
return [[[firstFRC sections] objectAtIndex:section] name];
}
I also use the boilerplate controllerWillChangeContent, didChangeObject, and controllerDidChangeContent to add/remove cells as the FRC's change.
For brevity, I won't include the code for displaying the cell, but I essentially pull the correct item based on the cell's index path, set the text/subtitle of the cell, and attach one of two checkmark images, depending on whether the item is collected or not. I also wire up this image (which is on a button) to toggle from checked to unchecked when it is touched, and update the item accordingly.
This part all works fine. I can view my list of items by department, and when I mark one as collected, I see it drop off the list as expected.
Now I attempted to add the bottom section, containing all of the collected items (in a single section). First I set up a second fetchedResultsConroller, this time to fetch only uncollected items, and without sectioning. I also had to update the following:
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
// Return the number of sections - add one for 'collected' section
return firstFRC.sections.count + 1;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
// Return the number of rows in the section
if (section < firstFRC.sections.count) {
return [[firstFRC.sections objectAtIndex:section] numberOfObjects];
}
else {
return secondFRC.fetchedObjects.count;
}
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
if (section < firstFRC.sections.count) {
return [[[firstFRC sections] objectAtIndex:section] name];
}
else{
return #"Collected";
}
}
I then updated the cellForRowAtIndexPath in a similar fashion, so that the item I retrieve comes from the right FRC:
Item *item;
if (indexPath.section < firstFRC.sections.count) {
item = [firstFRC objectAtIndexPath:indexPath];
}
else {
item = [secondFRC objectAtIndexPath:[NSIndexPath indexPathForRow:indexPath.row inSection:0]];
}
[[cell textLabel] setText:[item name]];
…rest of cell configuration
This works great when I launch. The tableview displays exactly as anticipated:
The Problem (finally)
The (first) problem comes when I select the checkmark for an uncollected item. I expect the item to be removed from the department it is listed under, and moved to the 'collected' section. Instead, I get:
CoreData: error: Serious application error. An exception was caught
from the delegate of NSFetchedResultsController during a call to
-controllerDidChangeContent:. Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section
after the update (2) must be equal to the number of rows contained in
that section before the update (2), plus or minus the number of rows
inserted or deleted from that section (1 inserted, 0 deleted) and plus
or minus the number of rows moved into or out of that section (0 moved
in, 0 moved out). with userInfo (null)
If I attempt the opposite, then I receive a different error:
* Terminating app due to uncaught exception 'NSRangeException', reason: '* -[__NSArrayM objectAtIndex:]: index 2 beyond bounds [0 ..
1]'
I suspect that in both cases there is a problem with consistency with the number of sections/rows in the FRCs and the tableview when an item moves from one FRC to the other. Although that second error makes me think there is maybe a simpler problem related to my retrieval of items.
Any direction or ideas would be appreciated. I can provide more of my code if it would help, and have also created a small test app with a single view to demonstrate the issue. I can upload it if necessary, but mostly I wanted to test the issue in a small scale sandbox.
Update - additional code requested
As requested, this is what happens when a checkmark is touched:
- (void)checkButtonTapped:(id)sender event:(id)event
{
NSSet *touches = [event allTouches];
UITouch *touch = [touches anyObject];
CGPoint currentTouchPosition = [touch locationInView:self.tableView];
NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint: currentTouchPosition];
if (indexPath != nil)
{
[self tableView: self.tableView accessoryButtonTappedForRowWithIndexPath: indexPath];
}
}
- (void)tableView:(UITableView *)tableView accessoryButtonTappedForRowWithIndexPath:(NSIndexPath *)indexPath
{
Item *item;
if (indexPath.section < firstFRC.sections.count) {
item = [firstFRC objectAtIndexPath:indexPath];
}
else {
item = [secondFRC objectAtIndexPath:[NSIndexPath indexPathForRow:indexPath.row inSection:0]];
}
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
UIButton *button = (UIButton *)cell.accessoryView;
if (![item collected]) {
[item setCollected:YES];
[button setBackgroundImage:[UIImage imageNamed:#"checked.png"] forState:UIControlStateNormal];
}
else if ([item collected]){
[item setCollected:NO];
[button setBackgroundImage:[UIImage imageNamed:#"unchecked.png"] forState:UIControlStateNormal];
}
NSError *error = nil;
if (![item.managedObjectContext save:&error]) {
NSLog(#"Error saving collected items");
}
}
Well, I think I might have cracked it. As is often the case, stripping it down and reading just a few comments pointed me in the right direction.
When I get to the delegate method controllerDidChangeObject, I attempt to insert a row at the indexPath provided (for the 'checked' item). Except that when inserting into my additional section, this indexPath has no awareness of the fact that there are a bunch of other sections before it. So it receives section 0 and attempts to insert there. Instead, if the indexPath comes from the second FRC, I should be incrementing the section number by the number of sections in the first FRC's table. So, I replaced:
- (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;
with
- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath {
UITableView *tableView = self.tableView;
switch(type) {
case NSFetchedResultsChangeInsert:
if ([controller.sectionNameKeyPath isEqualToString:#"department"]) {
[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
}
else {
[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:newIndexPath.row inSection:firstFRC.sections.count]] withRowAnimation:UITableViewRowAnimationFade]; }
break;
I will need to do this for each of insert, delete, update etc. I will mark this as the answer after I have validated this and to allow time for other comments.
The errors you are seeing mean that your UITableView reloads itself before BOTH your NSFetchedResultsControllers do. The codes you posted are probably correct. I suspect that the problem is in one of the NSFetchedResultsControllerDelegate methods.
I am creating an "add new item" row to a section in a table view which gets dynamically added/deleted as the editing mode is entered/exited. This works fine however if the table is longer than the screen then the labels for any rows not yet displayed are not shown when you scroll down to see the new "add new item row".
The key parts of my code are:
setEditing...
-(void)setEditing:(BOOL)editing animated:(BOOL)animate
{
[super setEditing:editing animated:animate];
[self.tableView setEditing:editing animated:animate];
NSArray *paths = [NSArray arrayWithObject:
[NSIndexPath indexPathForRow:[self.location.rooms count] inSection:kSectionRooms]];
if (editing)
{
[[self tableView] insertRowsAtIndexPaths:paths
withRowAnimation:UITableViewRowAnimationTop];
}
else {
[[self tableView] deleteRowsAtIndexPaths:paths
withRowAnimation:UITableViewRowAnimationTop];
}
}
numberOfRowsInSection...
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
switch (section) {
case kSectionFields:
return NUM_SECTION_FIELDS_ROWS;
break;
case kSectionRooms:
return [location.rooms count] + ([self.tableView isEditing] ? 1 : 0);
break;
default:
return 0;
break;
}
return 0;
}
portion of cellForRowAtIndexPath
if ([self.tableView isEditing] && row == location.rooms.count)
{
roomCell.textLabel.text = #"Add new room...";
}
else
{
roomCell.textLabel.text = [[loc.rooms objectAtIndex:row] name];
}
editingStyleForRowAtIndexPath....
- (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleForRowAtIndexPath:(NSIndexPath *)indexPath {
switch ([indexPath section])
{
case kSectionFields:
return UITableViewCellEditingStyleNone;
break;
case kSectionRooms:
if ([indexPath row] == location.rooms.count && [self.tableView isEditing])
{
return UITableViewCellEditingStyleInsert;
}
else
{
return UITableViewCellEditingStyleDelete;
}
break;
}
return UITableViewCellEditingStyleNone;
}
These are all working correctly if there is no scrolling but as soon as there is scrolling then I get problems. I understand that this must be due to the fact that the TableView has not displayed the hidden rows and therefore has not gone through the lazy loading process. I've tried using reloadData but that makes no difference (unsurprisingly) but as I am new to this I am not sure of the best way of making the rows appear.
I am sure there will be a simple solution so any ideas would be most appreciated!
Cheers in advance
jez
Ok so I am working on implementing a UISearchBar on a tableView that has sections. This may be wrong, but to populate the table view the first time, I have an array with lots of entries, and then populate the sections like this:
if(indexPath.section ==0){
[cell.textLabel setText:[tableData objectAtIndex:indexPath.row]];
}else if(indexPath.section ==1){
[cell.textLabel setText:[tableData objectAtIndex:indexPath.row+4]];
}else if(indexPath.section ==2){
[cell.textLabel setText:[tableData objectAtIndex:indexPath.row+8]];
}
Which is far from elegant, but it works. Now I am trying to hookup the UISearchBar, and this is the method that I am running into issues with:
- (void)searchBar:(UISearchBar *)sBar textDidChange:(NSString *)searchText
{
[tableData removeAllObjects];// remove all data that belongs to previous search
if([searchText isEqualToString:#""] || searchText==nil){
[tableView reloadData];
return;
}
NSInteger counter = 0;
for(NSString *name in dataSource)
{
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc]init];
NSRange r = [name rangeOfString:searchText];
if(r.location != NSNotFound)
{
[tableData addObject:name];
}
[pool release];
}
[tableView reloadData];
}
So I am making an array again of entries that fit the search criteria, but then when I am trying to reload my tableView, it gets all bungled up because it is expecting sections. But all I want is the results in just a plain section-less tableView.
How can I implement this UISearchBar with a tableView with sections?
Thanks
set a BOOL when you enter search and adjust your section count accordingly
e.g.
in viewDidLoad
BOOL isSearching = NO;
set to YES when you enter the textDidChange method.
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
int t;
if (isSearching) t = 1
else {
t= array.count;
}
return t;
}
You don't need to keep another variable around; just interrogate the tableView argument to see who is asking for the number of sections. For example, suppose your data is available in a fetchedResultsController:
if (tableView == self.searchDisplayDisplayController.searchResultsTableView) {
// your table is the search results table, so just return 1
return 1;
} else {
// your table is your "own" table
return [[self.fetchedResultsController sections] count];
}
I do the same thing in many of my table view delegate and data source methods.
I have a TabBar, NavBar, SearchBar with ScopeBar on my screen. I can search data via a remote server and list the content. So I have a NSMutableArray listContent and a filteredListContent like in the example of Apple (TableSearch - http://developer.apple.com/iphone/library/samplecode/TableSearch/index.html):
Now I added in
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
these line:
testDetailViewController *testDetailViewController = [[TestDetailViewController alloc] initWithNibName:#"TestDetailView" bundle:[NSBundle mainBundle]];
testDetailViewController.title = testClass.name;
testDetailViewController.myKey = testClass.keyId;
[[self navigationController] pushViewController:testDetailViewController animated:YES];
[testDetailViewController release];
testDetailViewController = nil;
Because of the NavigationBar, there is a "back" button. If I click this button, the TableView is empty, no matches/hits.
What I have to do, so the content will still be there?
Does anyone know?
Thanks a lot in advance & Best Regards.
Source Code:
#implementation SearchViewController
#synthesize listContent, filteredListContent, savedSearchTerm, savedScopeButtonIndex, searchWasActive;
- (void)viewDidLoad {
// restore search settings if they were saved in didReceiveMemoryWarning.
if (self.savedSearchTerm) {
[self.searchDisplayController setActive:self.searchWasActive];
[self.searchDisplayController.searchBar setSelectedScopeButtonIndex:self.savedScopeButtonIndex];
[self.searchDisplayController.searchBar setText:savedSearchTerm];
self.savedSearchTerm = nil;
}
}
- (void)viewDidUnload {
// Save the state of the search UI so that it can be restored if the view is re-created.
self.searchWasActive = [self.searchDisplayController isActive];
self.savedSearchTerm = [self.searchDisplayController.searchBar text];
self.savedScopeButtonIndex = [self.searchDisplayController.searchBar selectedScopeButtonIndex];
self.filteredListContent = nil;
}
- (void)dealloc {
[listContent release];
[filteredListContent release];
[super dealloc];
}
- (void)setData {
self.listContent = [NSMutableArray arrayWithCapacity:3];
[self.listContent addObject:[SearchObjects itemWithType:#"AAA" name:#"Test1"]];
[self.listContent addObject:[SearchObjects itemWithType:#"BBB" name:#"Test2"]];
[self.listContent addObject:[SearchObjects itemWithType:#"BBB" name:#"Test3"]];
// create a filtered list
self.filteredListContent = [NSMutableArray arrayWithCapacity:[self.listContent count]];
[self.tableView reloadData];
self.tableView.scrollEnabled = YES;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
//If the requesting table view is the search display controller's table view, return the count of the filtered list, otherwise return the count of the main list.
if (tableView == self.searchDisplayController.searchResultsTableView) {
return [self.filteredListContent count];
} else {
return [self.listContent count];
}
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *kCellID = #"cellID";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kCellID];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:kCellID] autorelease];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
}
/* If the requesting table view is the search display controller's table view, configure the cell using the filtered content, otherwise use the main list. */
SearchObjects *searchObject = nil;
if (tableView == self.searchDisplayController.searchResultsTableView) {
searchObject = [self.filteredListContent objectAtIndex:indexPath.row];
} else {
searchObject = [self.listContent objectAtIndex:indexPath.row];
}
cell.textLabel.text = searchObject.name;
return cell;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
// HERE IS THE SOURCE CODE FOR PUSHING TO THE NEXT VIEW
}
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar {
// DO SOME CALCULATIONS… AND THE setData METHOD IS CALLED
}
- (void)filterContentForSearchText:(NSString*)searchText scope:(NSString*)scope {
/* Update the filtered array based on the search text and scope. */
[self.filteredListContent removeAllObjects]; // First clear the filtered array.
/* Search the main list for whose type matches the scope (if selected) and whose name matches searchText; add items that match to the filtered array. */
for (SearchObjects *searchObject in listContent) {
if ([scope isEqualToString:#"All"] || [searchObject.type isEqualToString:scope]) {
NSComparisonResult result = [searchObject.name compare:searchText options:(NSCaseInsensitiveSearch|NSDiacriticInsensitiveSearch) range:NSMakeRange(0, [searchText length])];
if (result == NSOrderedSame) {
[self.filteredListContent addObject:searchObject];
}
}
}
}
- (void)filterContentForScope:(NSString*)scope {
/* Update the filtered array based on the search text and scope. */
[self.filteredListContent removeAllObjects]; // First clear the filtered array.
/* Search the main list for whose type matches the scope (if selected); add items that match to the filtered array. */
for (SearchObjects *searchObject in listContent) {
if ([scope isEqualToString:#"All"] || [searchObject.type isEqualToString:scope]) {
[self.filteredListContent addObject:searchObject];
}
}
}
- (BOOL)searchDisplayController:(UISearchDisplayController *)controller shouldReloadTableForSearchString:(NSString *)searchString {
[self filterContentForScope:[[self.searchDisplayController.searchBar scopeButtonTitles] objectAtIndex:[self.searchDisplayController.searchBar selectedScopeButtonIndex]]];
// Return YES to cause the search result table view to be reloaded.
return YES;
}
- (BOOL)searchDisplayController:(UISearchDisplayController *)controller shouldReloadTableForSearchScope:(NSInteger)searchOption {
[self filterContentForScope:[[self.searchDisplayController.searchBar scopeButtonTitles] objectAtIndex:searchOption]];
// Return YES to cause the search result table view to be reloaded.
return YES;
}
#end
You generally don't have to do anything in this case, the data should remain in place. Is there something which is unloading the data? Do you have a viewWillDisappear function which is unloading your array? Are you doing some of the array setup in viewWillAppear.
Put a log statement at the start of your methods to find out when they are being called, it will give you a clearer picture of what's happening.
It is solved. It was a problem which is not obvious with the given source code.
There was an error in my logic.