Manually Reordering Rows across Multiple Sections of an NSFetchedResultsController-backed UITableView

That title is a mouthful…sheesh.  But along with the tags on this post, I’m hoping to do some simple SEO and help other devs avoid the headache I just slogged through.

If you’re an iOS coder the title pretty much explains this post.  NSFetchedResultsController generally makes getting data into a UITableView a snap. So when I updated the app I’m working on to support multiple sections, I didn’t think it’d be this much trouble. Just update the model in tableView:moveRow..., save the context and Bjorn Stronginthearm’s your uncle.

Nope.

Started getting exceptions about index mismatches. Like the UITableView and NSFetchedResultsController had stopped talking to each other. After several hair-pulling nights of debug logging and careful testing, I figured out what’s going on, and how to fix it.

First, let’s start with the usual (single-section) implementation for the NSFetchedResultsControllerDelegate methods and UITableViewDataSource row-moving delegate. Our NSFetchedResultsController is called itemFRC. Items have a displayIndex property that indicates the order in which the user has put them. New items get (count – 1) for theirs.

@property BOOL userMovingTableRows;

- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller
{
	if ([self userMovingTableRows]) {
		return;
	}

	[[self tableView] beginUpdates];
}

- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id )sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type
{
	if ([self userMovingTableRows]) {
		return;
	}

	switch(type) {
		case NSFetchedResultsChangeInsert:
			[[self tableView] insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationAutomatic];
			break;
		case NSFetchedResultsChangeDelete:
			[[self tableView] deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationAutomatic];
			break;
	}
}

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath
{
	if ([self userMovingTableRows]) {
		return;
	}

	switch(type) {
		case NSFetchedResultsChangeInsert:
			[[self tableView] insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
			break;

		case NSFetchedResultsChangeDelete:
			[[self tableView] deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
			break;

		case NSFetchedResultsChangeUpdate:
			[self configureItemCell:(MTBItemTableViewCell *)[[self tableView] cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
			break;

		case NSFetchedResultsChangeMove:
			[[self tableView] deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
			[[self tableView] insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
			break;
	}
}

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
	if ([self userMovingTableRows]) {
		return;
	}

	[[self tableView] endUpdates];
}

- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath
{
	if ([sourceIndexPath isEqual:destinationIndexPath]) {
		return;
	}

	[self setUserMovingTableRows:YES];

	NSMutableArray *allObjects = [[[self itemFRC] fetchedObjects] mutableCopy];

	id *itemToMove = allObjects[sourceIndexPath row];
	[allObjects removeObject:itemToMove];
	[allObjects insertObject:itemToMove atIndex:[destinationIndexPath row]];

	[allObjects enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
		[obj setDisplayIndex:@(idx)];
	}];

	// Save the Core Data managed object context (I use Magical Record)
	[[NSManagedObjectContext MR_contextForCurrentThread] MR_saveToPersistentStoreAndWait];

	[self setUserMovingTableRows:NO];
}

All pretty standard. The userMovingTableRows property ensures NSFetchedResultsControllerDelegate calls don’t cause the already-up-to-date table view to go haywire when the user drags a cell around. Everything works fine with just the one section. I should mention that the reordering code is inspired by Matt Long’s Post on Cocoa is My Girlfriend.

So let’s support multiple sections. I’m going to gloss over some of the details, like providing the section and row counts to the table view. That info’s much easier to find with a little searching.

For your data objects, assume they’ve got a category relationship that resolves to another Core Data object, and that each category has a name property. Your NSFetchedResultsController should then use category.name for its section name key, and the underlying NSFetchRequest must sort by category.name and then by displayIndex. With everything wired up, the tableView:moveRow... implementation becomes:

- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath
{
	if ([sourceIndexPath isEqual:destinationIndexPath]) {
		return;
	}

	[self setUserMovingTableRows:YES];

	id *itemToMove = [[self itemFRC] objectAtIndexPath:sourceIndexPath];

	id sourceSectionInfo = [[self itemFRC] sections][[sourceIndexPath section]];
	id destSectionInfo = [[self itemFRC] sections][[destinationIndexPath section]];
	id *destCategory = [[destSectionInfo objects][0] category];

	if ([sourceIndexPath section] == [destinationIndexPath section]) {
		if ([destinationIndexPath row] < [sourceIndexPath row]) {
			[self shiftDisplayIndexesOfItemsInSection:[destinationIndexPath section] range:NSMakeRange([destinationIndexPath row], [sourceIndexPath row] - [destinationIndexPath row]) byAmount:1];
		} else {
			[self shiftDisplayIndexesOfItemsInSection:[destinationIndexPath section] range:NSMakeRange([sourceIndexPath row] + 1, [destinationIndexPath row] - [sourceIndexPath row]) byAmount:-1];
		}
	} else {
		[self shiftDisplayIndexesOfItemsInSection:[sourceIndexPath section] range:NSMakeRange([sourceIndexPath row] + 1, [sourceSectionInfo numberOfObjects] - [sourceIndexPath row] - 1) byAmount:-1];
		[self shiftDisplayIndexesOfItemsInSection:[destinationIndexPath section] range:NSMakeRange([destinationIndexPath row], [destSectionInfo numberOfObjects] - [destinationIndexPath row]) byAmount:1];
	}

	// Update the actual item the user's already moved
	[itemToMove setCategory:destCategory];
	[itemToMove setDisplayIndex:@([destinationIndexPath row])];

	// Save the Core Data managed object context (I use Magical Record)
	[[NSManagedObjectContext MR_contextForCurrentThread] MR_saveToPersistentStoreAndWait];

	[self setUserMovingTableRows:NO];
}

A bit more to this than before. First note that we’re not modifying the fetchedObjects array directly. We just update the item in question’s category and displayIndex properties, and let Core Data handle the details. But before that, we shift the displayIndex values for any rows that will be affected by this move. A bit more efficient than the previous method of touching every row in the dataset. Here’s the code for shiftDisplayIndexesOfItemsInSection:range:byAmount:

- (void)shiftDisplayIndexesOfItemsInSection:(NSInteger)section range:(NSRange)range byAmount:(NSInteger)amount
{
	for (NSUInteger shiftIndex = range.location; shiftIndex < NSMaxRange(range); shiftIndex++) {
		NSIndexPath *indexPathOfItemToShift = [NSIndexPath indexPathForRow:shiftIndex inSection:section];
		id *itemToShift = [[self itemFRC] objectAtIndexPath:indexPathOfItemToShift];
		[itemToShift setDisplayIndex:@([[itemToShift displayIndex] integerValue] + amount)];
	}
}

BUT – this still won’t work. You’ll still end up with range exceptions due to a UITableView/NSFetchedResultsController mismatch. So what gives? Well if you drop some logging into the NSFetchedResultsControllerDelegate methods, you’ll find something interesting.

Say you have two sections in your table view, with one row in each section. Drag the row in the first section anywhere into the second, and watch the delegate methods fly by. You’ll notice the FRC sends a didChangeSection message, telling the delegate that section 0 has been deleted. And that’s what we’d expect – the top section has no more rows, and so must go. But we’ve blocked the table view from receiving section and object updates during the row move. After the move, the table will show the new row in place, but still display the header for the now defunct section. So what now?

Unblocking section updates during the row move won’t help. It might in this particular case – clearing out section 0, but if you drag the last row from any other section – blammo.

You could note which section becomes defunct. Look for the only NSFetchedResultsChangeDelete while userMovingTableRows is YES, and after saving the managed object context have the tableView delete the section. That fails as well, since depending on the section the tableView’s section count mismatch vs. the FRC will cause another exception.

The solution is a variant on the second option above. Simply note when a section has become defunct during a move, and force a reload of the entire table as a result:

@property BOOL sourceSectionDefunctAfterUserMove;

- (void)viewDidLoad
{
	// ...

	[self setSourceSectionDefunctAfterUserMove:NO];

	// ...
}

- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath
{
	if ([sourceIndexPath isEqual:destinationIndexPath]) {
		return;
	}

	[self setUserMovingTableRows:YES];

	id *itemToMove = [[self itemFRC] objectAtIndexPath:sourceIndexPath];

	id sourceSectionInfo = [[self itemFRC] sections][[sourceIndexPath section]];
	id destSectionInfo = [[self itemFRC] sections][[destinationIndexPath section]];
	id *destCategory = [[destSectionInfo objects][0] category];

	if ([sourceIndexPath section] == [destinationIndexPath section]) {
		if ([destinationIndexPath row] < [sourceIndexPath row]) {
			[self shiftDisplayIndexesOfItemsInSection:[destinationIndexPath section] range:NSMakeRange([destinationIndexPath row], [sourceIndexPath row] - [destinationIndexPath row]) byAmount:1];
		} else {
			[self shiftDisplayIndexesOfItemsInSection:[destinationIndexPath section] range:NSMakeRange([sourceIndexPath row] + 1, [destinationIndexPath row] - [sourceIndexPath row]) byAmount:-1];
		}
	} else {
		[self shiftDisplayIndexesOfItemsInSection:[sourceIndexPath section] range:NSMakeRange([sourceIndexPath row] + 1, [sourceSectionInfo numberOfObjects] - [sourceIndexPath row] - 1) byAmount:-1];
		[self shiftDisplayIndexesOfItemsInSection:[destinationIndexPath section] range:NSMakeRange([destinationIndexPath row], [destSectionInfo numberOfObjects] - [destinationIndexPath row]) byAmount:1];
	}

	// Update the actual item the user's already moved
	[itemToMove setCategory:destCategory];
	[itemToMove setDisplayIndex:@([destinationIndexPath row])];

	// Save the Core Data managed object context (I use Magical Record)
	[[NSManagedObjectContext MR_contextForCurrentThread] MR_saveToPersistentStoreAndWait];

	if ([self sourceSectionDefunctAfterUserMove]) {
		[[self tableView] reloadData];
		[self setSourceSectionDefunctAfterUserMove:NO];
	}

	[self setUserMovingTableRows:NO];
}

- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id )sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type
{
	if ([self userMovingTableRows]) {
		if (type == NSFetchedResultsChangeDelete) {
			[self setSourceSectionDefunctAfterUserMove:YES];
		}
		return;
	}

	switch(type) {
		case NSFetchedResultsChangeInsert:
			[[self tableView] insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationAutomatic];
			break;
		case NSFetchedResultsChangeDelete:
			[[self tableView] deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationAutomatic];
			break;
	}
}

The new property tracks whether the source section for a row move has been deleted. We note that in the didChangeSection delegate method, and call reloadData on the tableView if it’s set at the end of a row move. Not as elegant as I’d like, but it’s a small change from the usual way these methods are implemented, and most importantly – it works.

Leave a Reply

Your email address will not be published. Required fields are marked *