Rearranging UITableView with Core Data

24,514

Solution 1

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);
    }
}

Solution 2

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.

Solution 3

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...

Solution 4

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);
    }
}

Solution 5

- (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.

Share:
24,514

Related videos on Youtube

Greg Combs
Author by

Greg Combs

Former Political Science Professor Currently a senior iOS engineer at Oracle America working on Oracle Tap.

Updated on July 09, 2022

Comments

  • Greg Combs
    Greg Combs almost 2 years

    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];
        }
    }
    
    • Corey Floyd
      Corey Floyd over 14 years
      I worked this out a bit differently (will post when it is tested). Whats really needed for this is a "Reverse-NSFetchedResultsController". Essentially feed an NSFetchedResultsController a UITableView, a sort order, and section headers, and have it write everything back to CoreData. I think I might try to put this together for fun.
    • Greg Combs
      Greg Combs over 14 years
      Absolutely ... something a little more elegant than my solution that will allow a bidirectional synch between the stored data and the table user interface. I'd love to see what you come up with when you're done.
    • clint
      clint almost 14 years
      For what it's worth, Matt Long recently posted a nice tutorial on re-ordering the rows in an NSFetchedResultsController-backed table: cimgf.com/2010/06/05/re-ordering-nsfetchedresultscontroller
    • iwasrobbed
      iwasrobbed about 11 years
      See this easy solution: stackoverflow.com/a/15625897/308315
  • Greg Combs
    Greg Combs almost 15 years
    This still brought exceptions, see my updated "answer" for what's working now.
  • Admin
    Admin almost 15 years
    Sorry wish I knew what was happening. I always us mutable arrays for this which seems less complicated but doesn't really serve your code sample well. sigh
  • Greg Combs
    Greg Combs over 14 years
    I've seen that exact same thing before. Unfortunately, I haven't dug into my sources in weeks and I don't keep them here at work anymore. I'll see if I can track down how I use self.moving but if I remember, it was to delay updating the core data store / fetched results until after editing/moving was complete. After that point, I make sure the "order" property is consistent with what it should be then update the core data store / fetched results, then reload/refresh the data displayed in the table AGAIN so that the visual inconsistencies are cleared up. This is just from memory, I'll check.
  • Greg Combs
    Greg Combs over 14 years
    Looking at everything now... Initialize moving to NO. Set moving to YES and validateLinkOrder when we detect didObJectChange/NSFetchedResultsChangeMove. Don't do anything in controllerWillChangeContent. But in controllerDidChangeContent, see see if moving is YES, if so, we set it to NO, tell the tableView to reloadData, and perform the "save" selector after a delay of 0.02. The "save" selector basically tells the managedObjectContext to save and log any errors, but it does this within a try/catch exception handling if things go bad.
  • Greg Combs
    Greg Combs over 14 years
    See my revised code listing in the "checked" answer above.
  • Jorge Ortiz
    Jorge Ortiz over 14 years
    Finally got it to work. My problem was that was storing an int in the order attribute instead of an NSNumber. Those Core Data crashes are quite ugly and the information is pretty unhelpful. Thanks a lot!!!
  • Gaius Parx
    Gaius Parx over 14 years
    Thanks Greg, the above code is working for me. I have to change the controllerDidChangeContent code to add in endUpdates to make delete and insert work for the ui update: if (self.moving) { self.moving = NO; [self.theTableView reloadData]; [self performSelector:@selector(save) withObject:nil afterDelay:0.02]; } else { [self.theTableView endUpdates]; }
  • Sam Soffes
    Sam Soffes over 14 years
    Why don't you do this instead [table performSelector:@selector(reloadData) withObject:nil afterDelay:0.02]; so you don't have to make another method :)
  • Greg Combs
    Greg Combs about 14 years
    Well, naturally things get a lot simpler if you don't use a fetchedResultsController with reordering. At least, simpler when it comes to the reordering part of it. I figure if using a fetchedResultsController is recommended by the AppleDev administration, then we ought to have a graceful way of rearranging. Unfortunately, it seems that although these two things are compatible, as shown above, they are certainly not graceful, as shown above.
  • iXcoder
    iXcoder almost 14 years
    this is worked but seem not good effect , whole view will be refresh. looks the choice answer in this thread is great but I think seem 'insert' and 'delete' is not necessary, I just use a field 'displayIndex' to control the order, if a row move to another position in the list, I just use bubble sort way change the field value , that is works fine no 'insert' and 'delete' is required
  • Aviel Gross
    Aviel Gross about 10 years
    this forin things setValue... is incredibly simple and saved me bunch of code that tried to be more efficient but didn't work properly... BUT - how much not efficient is it? Let's say under 200 objects do I need to care about efficiency here?
  • JosephH
    JosephH about 10 years
    @AvielGross I think updating 200 objects once in response to a user action is not going to be a noticeable overhead. The only part you may have to watch is the managedObjectContext save - test on the slowest device you target, and if you see any issues you may need to do the save on a background thread, or defer it.
  • Aviel Gross
    Aviel Gross about 10 years
    So if my moc is created with NSMainQueueConcurrencyType I can just call the save: method inside [context performBlock...]? Or performBlockAndWait...?
  • JosephH
    JosephH about 10 years
    @AvielGross I think we're drifting away from the topic of this question now; I suggest asking a new question - but to answer, neither of those approaches will help as NSMainQueueConcurrencyType means they will run the block on the main thread, and this code is already running on the main thread. If performance is a problem, you'd want to move the save to disk/flash to a background thread, but multi-threaded coredata is a large and tricky topic.