How can I maintain display order in UITableView using Core Data?
I'm having some trouble getting my Core Data entities to play nice and order when using an UITableView.
I've been through a number of tutorials and other questions here on StackOverflow, but there doesn't seem to be a clear or elegant way to do this - I'm really hoping I'm missing something.
I have a single Core Data entity that has an int16 attribute on it called "displayOrder". I use an NSFetchRequest that has been sorted on "displayOrder" to return the data for my UITabl开发者_StackOverfloweView. Everything but reordering is being respected. Here is my (inefficient) moveRowAtIndePath method:
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath {
NSUInteger fromIndex = fromIndexPath.row;
NSUInteger toIndex = toIndexPath.row;
FFObject *affectedObject = [self.fetchedResultsController.fetchedObjects objectAtIndex:fromIndex];
affectedObject.displayOrderValue = toIndex;
[self FF_fetchResults];
for (NSUInteger i = 0; i < [self.fetchedResultsController.fetchedObjects count]; i++) {
FFObject *otherObject = [self.fetchedResultsController.fetchedObjects objectAtIndex:i];
NSLog(@"Updated %@ / %@ from %i to %i", otherObject.name, otherObject.state, otherObject.displayOrderValue, i);
otherObject.displayOrderValue = i;
}
[self FF_fetchResults];
}
Can anyone point me in the direction of a good bit of sample code, or see what I'm doing wrong? The tableview display updates OK, and I can see through my log messages that the displayOrder property is being updated. It's just not consistently saving and reloading, and something feels very "off" about this implementation (aside from the wasteful iteration of all of my FFObjects).
Thanks in advance for any advice you can lend.
I took a look at your code and this might work better:
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath {
NSUInteger fromIndex = fromIndexPath.row;
NSUInteger toIndex = toIndexPath.row;
if (fromIndex == toIndex) {
return;
}
FFObject *affectedObject = [self.fetchedResultsController.fetchedObjects objectAtIndex:fromIndex];
affectedObject.displayOrderValue = toIndex;
NSUInteger start, end;
int delta;
if (fromIndex < toIndex) {
// move was down, need to shift up
delta = -1;
start = fromIndex + 1;
end = toIndex;
} else { // fromIndex > toIndex
// move was up, need to shift down
delta = 1;
start = toIndex;
end = fromIndex - 1;
}
for (NSUInteger i = start; i <= end; i++) {
FFObject *otherObject = [self.fetchedResultsController.fetchedObjects objectAtIndex:i];
NSLog(@"Updated %@ / %@ from %i to %i", otherObject.name, otherObject.state, otherObject.displayOrderValue, otherObject.displayOrderValue + delta);
otherObject.displayOrderValue += delta;
}
[self FF_fetchResults];
}
(This is intended as as comment on gerry3's answer above, but I am not yet able to comment on other users' questions and answers.)
A small improvement for gerry3's - very elegant - solution. If I'm not mistaken, the line
otherObject.displayOrderValue += delta;
will actually perform pointer arithmetic if displayOrderValue
is not of primitive type. Which may not be what you want. Instead, to set the value of the entity, I propose:
otherObject.displayOrderValue = [NSNumber numberWithInt:[otherObject.displayOrderValue intValue] + delta];
This should update your entity property correctly and avoid any EXC_BAD_ACCESS crashes.
Here a full solution how to manage an indexed table with core data. Your attribute is called displayOrder
, I call it index
.
First of all, you better separate view controller and model. For this I use a model controller, which is the interface between the view and the model.
There are 3 cases you need to manage that the user can influence via the view controller.
- Adding a new object
- Deleting an existing object
- Reorder objects.
The first two cases Adding and Deleting are pretty straightforward. Delete calls a routine called renewObjectIndicesUpwardsFromIndex
in order to update the indices after the deleted object.
- (void)createObjectWithTitle:(NSString*)title {
FFObject* object = [FFObject insertIntoContext:self.managedObjectContext];
object.title = title;
object.index = [NSNumber numberWithInteger:[self numberTotalObjects]];
[self saveContext];
}
- (void)deleteObject:(FFObject*)anObject {
NSInteger objectIndex = [anObject.index integerValue];
[anObject deleteObject];
[self renewObjectIndicesUpwardsFromIndex:objectIndex];
[self saveContext];
}
- (void)renewObjectIndicesUpwardsFromIndex:(NSInteger)fromIndex {
NSFetchRequest* fetchRequest = [[NSFetchRequest alloc] init];
[fetchRequest setEntity:[NSEntityDescription entityForName:@"Object" inManagedObjectContext:self.managedObjectContext]];
NSPredicate* predicate = [NSPredicate predicateWithFormat:@"(index > %d)", fromIndex];
[fetchRequest setPredicate:predicate];
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"index" ascending:YES];
NSArray *sortDescriptors = [NSArray arrayWithObjects:sortDescriptor, nil];
[fetchRequest setSortDescriptors:sortDescriptors];
NSError* fetchError = nil;
NSArray* objects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&fetchError];
NSInteger index = fromIndex;
for (FFObject* object in objects) {
object.index = [NSNumber numberWithInteger:index];
index += 1;
}
[self saveContext];
}
Before I come to the controller routines for the re-order, here the part in the view controller. I use a bool isModifyingOrder
similar to this answer. Notice that the view controller calls two functions in the controller moveObjectOrderUp
and moveObjectOrderDown
. Depending on how you display the objects in the table view - newest first or newest last - you can switch them.
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath {
isModifyingOrder = YES;
NSUInteger fromIndex = sourceIndexPath.row;
NSUInteger toIndex = destinationIndexPath.row;
if (fromIndex == toIndex) {
return;
}
FFObject *affectedObject = [self.fetchedResultsController.fetchedObjects objectAtIndex:fromIndex];
NSInteger delta;
if (fromIndex < toIndex) {
delta = toIndex - fromIndex;
NSLog(@"Moved down by %lu cells", delta);
[self.objectController moveObjectOrderUp:affectedObject by:delta];
} else {
delta = fromIndex - toIndex;
NSLog(@"Moved up by %lu cells", delta);
[self.objectController moveObjectOrderDown:affectedObject by:delta];
}
isModifyingOrder = NO;
}
And here the part in the controller. This can be written nicer, but for understanding this is maybe best.
- (void)moveObjectOrderUp:(FFObject*)affectedObject by:(NSInteger)delta {
NSInteger fromIndex = [affectedObject.index integerValue] - delta;
NSInteger toIndex = [affectedObject.index integerValue];
if (fromIndex < 1) {
return;
}
NSFetchRequest* fetchRequest = [[NSFetchRequest alloc] init];
[fetchRequest setEntity:[NSEntityDescription entityForName:@"Object" inManagedObjectContext:self.managedObjectContext]];
NSPredicate* predicate = [NSPredicate predicateWithFormat:@"(index >= %d) AND (index < %d)", fromIndex, toIndex];
[fetchRequest setPredicate:predicate];
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"index" ascending:YES];
NSArray *sortDescriptors = [NSArray arrayWithObjects:sortDescriptor, nil];
[fetchRequest setSortDescriptors:sortDescriptors];
NSError* fetchError = nil;
NSArray* objects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&fetchError];
for (FFObject* object in objects) {
NSInteger newIndex = [object.index integerValue] + 1;
object.index = [NSNumber numberWithInteger:newIndex];
}
affectedObject.index = [NSNumber numberWithInteger:fromIndex];
[self saveContext];
}
- (void)moveObjectOrderDown:(FFObject*)affectedObject by:(NSInteger)delta {
NSInteger fromIndex = [affectedObject.index integerValue];
NSInteger toIndex = [affectedObject.index integerValue] + delta;
NSFetchRequest* fetchRequest = [[NSFetchRequest alloc] init];
[fetchRequest setEntity:[NSEntityDescription entityForName:@"Object" inManagedObjectContext:self.managedObjectContext]];
NSPredicate* predicate = [NSPredicate predicateWithFormat:@"(index > %d) AND (index <= %d)", fromIndex, toIndex];
[fetchRequest setPredicate:predicate];
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"index" ascending:YES];
NSArray *sortDescriptors = [NSArray arrayWithObjects:sortDescriptor, nil];
[fetchRequest setSortDescriptors:sortDescriptors];
NSError* fetchError = nil;
NSArray* objects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&fetchError];
for (FFObject* object in objects)
{
NSInteger newIndex = [object.index integerValue] - 1;
object.index = [NSNumber numberWithInteger:newIndex];
}
affectedObject.index = [NSNumber numberWithInteger:toIndex];
[self saveContext];
}
Don't forget to use a second BOOL
in your view controller for the delete action to prevent the move notification to do anything. I call it isDeleting
and put it here.
- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
newIndexPath:(NSIndexPath *)newIndexPath {
if (isModifyingOrder) return;
...
switch(type) {
...
case NSFetchedResultsChangeMove:
if (isDeleting == false) {
[self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:localIndexPath] withRowAnimation:UITableViewRowAnimationFade];
[self.tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:localNewIndexPath]withRowAnimation:UITableViewRowAnimationFade];
}
break;
...
}
}
I think that:
affectedObject.displayOrderValue = toIndex;
must be placed after:
for (NSUInteger i = start; i <= end; i++) {
FFObject *otherObject = [self.fetchedResultsController.fetchedObjects objectAtIndex:i];
NSLog(@"Updated %@ / %@ from %i to %i", otherObject.name, otherObject.state, otherObject.displayOrderValue, otherObject.displayOrderValue + delta);
otherObject.displayOrderValue += delta;
}
and before:
[self FF_fetchResults];
The answers above (as far as I can tell) only work if cell is being moved up or down in the same section. For this approach to be valid, one would have to prevent the user from moving between sections. (Using the canMoveRowAt indexPath: IndexPath -> Bool
tableView delegate method).
To maintain display order in a UITableView when moving a cell within a section, or to a different section, here is code I stole verbatim from https://github.com/MrAlek/Swift-NSFetchedResultsController-Trickery/blob/ceac7937a3b20f78d7268274b18eef4845917090/CoreDataTrickerySwift/ToDoViewController.swift
This code uses a ToDo as the NSManagedObject subclass.
Here is the meat of the logic:
func updateInternalOrderForToDo(_ toDo: ToDo, sourceIndexPath: IndexPath, destinationIndexPath: IndexPath) {
// Update internal order to reflect new position
// First get all toDos, in sorted order
var sortedToDos = fetchedResultsController.fetchedObjects!
sortedToDos = sortedToDos.filter() {$0 != toDo} // Remove current toDo
// Insert toDo at new place in array
var sortedIndex = destinationIndexPath.row
for sectionIndex in 0..<destinationIndexPath.section {
sortedIndex += toDoListController.sections[sectionIndex].numberOfObjects
if sectionIndex == sourceIndexPath.section {
sortedIndex -= 1 // Remember, controller still thinks this toDo is in the old section
}
}
sortedToDos.insert(toDo, at: sortedIndex)
// Regenerate internal order for all toDos
for (index, toDo) in sortedToDos.enumerated() {
toDo.metaData.internalOrder = sortedToDos.count-index
}
}
}
There is some tweaking of the moveRowAt depending on whether one is directly updating a cell, moving to a different section, and or using Snapshots with DiffableData source. A key concept is to temporarily disable NSFetchedResultsController delegate calls to update the table. This is covered elsewhere, including a link referenced above. The code below is not intended to by copy / pasted, but just to illustrate some of the considerations of what should be handled in moveRowAt
call
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
if sourceIndexPath == destinationIndexPath {
return
}
// Don't let fetched results controller affect table view
fetchControllerDelegate.ignoreNextUpdates = true
// Trust that we will get a toDo back
let toDo = toDoListController.toDoAtIndexPath(sourceIndexPath)!
//manually update managedObject properties as necessary to reflect inclusion in the new section.
if sourceIndexPath.section != destinationIndexPath.section {
//handle changes to managed objects propertie(s) here
}
// Table view is in inconsistent state, update the cell. If you are using a DiffableDataSource skip this and see below
if let cell = tableView.cellForRow(at: destinationIndexPath) {
self.configureCell(cell, toDo: toDo)
}
//see method implementation above
updateInternalOrderForToDo(toDo, sourceIndexPath: sourceIndexPath, destinationIndexPath: destinationIndexPath)
// Save
try! toDo.managedObjectContext!.save()
//if you are using DiffableDataSource, call apply(newSnapshot, animatingDifferences: animated) on your UIDifffableDataSource object after saving the moc. make sure ignoreNextUpdates bool is still set to true before calling save()
}
精彩评论