UICollectionView locks main thread for ~10 seconds on performBatchUpdates - objective-c

I have a collectionview with 300 cells, driven by an NSFetchedResultsController. Every so often, all of the objects update, so I receive delegate messages telling me so, and I let the collection view handle the updates as I would a tableview. Unfortunately it locks the main thread for a few seconds every time this happens... I'm not sure why. Here's my code:
-(void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
[self.cascadeViewController.collectionView performBatchUpdates:^{
NSLog(#"performingBatchUpdates");
for (NSDictionary *change in self.changes) {
[change enumerateKeysAndObjectsUsingBlock:^(NSNumber *key, id obj, BOOL *stop) {
NSFetchedResultsChangeType type = [key unsignedIntegerValue];
if (type == NSFetchedResultsChangeInsert) {
[self.cascadeViewController.collectionView insertItemsAtIndexPaths:#[obj]];
} else if (type == NSFetchedResultsChangeDelete) {
[self.cascadeViewController.collectionView deleteItemsAtIndexPaths:#[obj]];
} else if (type == NSFetchedResultsChangeUpdate) {
[self.cascadeViewController.collectionView reloadItemsAtIndexPaths:#[obj]];
} else if (type == NSFetchedResultsChangeMove) {
[self.cascadeViewController.collectionView moveItemAtIndexPath:obj[0] toIndexPath:obj[1]];
}
}];
}
NSLog(#"performingBatchUpdates end");
} completion:^(BOOL finished) {
NSLog(#"completion");
// TODO: implement
// [self configureMessageAndFooterView];
}];
NSLog(#"end of method");
[self.changes removeAllObjects];
}
What's going on here? All 300 objects updating at once is not going to happen constantly in my app's real-life execution but enough that I need to worry about it. I'm using a stock UICollectionViewFlowLayout - do I need to do something more custom?

Had the same issue with performBatchUpdates:completion: locking the main thread for seconds in a collection view of just ~100 elements.
After spending way too much time on the issue I found a solution: ensure the cell's size (as returned in -collectionView:layout:sizeForItemAtIndexPath: or defined via the itemSize property of your layout) has not a fractional value. I solved my performance issues by applying floor on the computed height of my cells.
That being said, I have no idea why this happens. By looking at the stack trace of our profiled runs, a lot of time is spent in -[UIViewCollectionViewUpdate _computeGaps], which in turns invokes -[NSArray sortedArrayUsingSelector:] hundreds or even thousands of times (as well as CFSortIndexes, __CFSimpleMergeSort…). By just using an integer value for the height of our cells, sortedArrayUsingSelector is invoked less than 10 times and the whole process completes in a fraction of a second.

I vaguely recall seeing behavior like this before, but I don't have a solution for the NSFetchedResultsController + UICollectionViewFlowLayout combo because we stopped using both of those classes due to a multitude of issues. You might consider checking out the alternatives we open sourced:
TLIndexPathTools as a replacement for NSFetchedResultsController. It provides a TLIndexPathController class that is very similar to NSFetchedResultsController except it also works with plain arrays and it can do animated sorting an filtering (unlike NSFetchedResultsController. There are numerous sample projects, including a Core Data one.
VCollectionViewGridLayout as a replacement for UICollectionViewFlowLayout. It is a uniform, vertical scrolling grid, so it isn't as flexible as UICollectionViewFlowLayout, but the animations are generally much better in most cases it does sticky headers (like UITableView headers). There are a couple of sample projects that let you toggle between UICollectionViewFlowLayout and VCollectionViewGridLayout to see the improvement.
We have an iPad app with a grid-like collection view containing around 1000 items and the above gives us great performance and nice smooth animation as our Core Data database updates in the background.

Related

Concurrent drawRect:

I have a large array of objects (typically 500 - 2000) that render to the screen. Unfortunately, rendering is not exactly snappy at the moment.
Each object needs to perform some calculations which take up most of the time and finally draw itself to the screen, i.e. currently my drawRect: method looks essentially like this:
(I've left out trivial optimizations like checking bounding rects vs. dirtyRect for the sake of readability)
- (void)drawRect:(NSRect)dirtyRect
{
for (Thing *thing in [self getThings])
{
[thing prepareForDrawing];
[thing draw];
}
}
An obvious candidate for concurrent processing, right?
I couldn't come up with a good approach to decouple preparation from the actual drawing operations, i.e. perform the pre-processing in parallel and somehow queue the drawing commands until all processing is done, then render all in one go.
However, thinking of the goodness that is GCD I came up with the following scheme.
It kind of sounds OK to me but being new to GCD and before running into weird multi-threading issues four weeks after a public release or just using a bad GCD design pattern in general I thought I'd ask for feedback.
Can anybody see a problem with this approach - potential issues, or a better solution?
- (void)drawRect:(NSRect)dirtyRect
{
[[self getThings] enumerateObjectsWithOptions:NSEnumerationConcurrent
usingBlock:^(id obj, NSUInteger idx, BOOL *stop)
{
// prepare concurrently
Thing *thing = (Thing*)obj;
[thing prepareForDrawing];
// always draw in main thread
dispatch_async(dispatch_get_main_queue(), ^{
[thing draw];
});
}
}
That won't work because the invocations of [thing draw] will happen outside of -drawRect: after it has completed. The graphics context will no longer be valid for drawing into that view.
Why are the "things" not prepared in advance? -drawRect: is for drawing, not computation. Any necessary expensive computation should have been done in advance.

Assertion Failure in UICollectionViewData indexPathForItemAtGlobalIndex

I am using performBatchUpdates() to update my collection view, where I am doing a complete refresh, i.e. delete whatever was in it and re-insert everything. The batch updates are done as part of an Observer which is attached to a NSMutableArray (bingDataItems).
cellItems is the array containing items that are or will be inserted into the collection view.
Here is the code:
- (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
cultARunner *_cultARunner = [cultARunner getInstance];
if ( [[_cultARunner bingDataItems] count] ) {
[self.collectionView reloadData];
[[self collectionView] performBatchUpdates: ^{
int itemSize = [cellItems count];
NSMutableArray *arrayWithIndexPaths = [NSMutableArray array];
// first delete the old stuff
if (itemSize == 0) {
[arrayWithIndexPaths addObject: [NSIndexPath indexPathForRow: 0 inSection: 0]];
}
else {
for( int i = 0; i < cellItems.count; i++ ) {
[arrayWithIndexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
}
}
[cellItems removeAllObjects];
if(itemSize) {
[self.collectionView deleteItemsAtIndexPaths:arrayWithIndexPaths];
}
// insert the new stuff
arrayWithIndexPaths = [NSMutableArray array];
cellItems = [_cultARunner bingDataItems];
if ([cellItems count] == 0) {
[arrayWithIndexPaths addObject: [NSIndexPath indexPathForRow: 0 inSection: 0]];
}
else {
for( int i = 0; i < [cellItems count]; i++ ) {
[arrayWithIndexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
}
}
[self.collectionView insertItemsAtIndexPaths:arrayWithIndexPaths];
}
completion:nil];
}
}
I get this error, but not all of the times (why ?)
2012-12-16 13:17:59.789 [16807:19703] *** Assertion failure in -[UICollectionViewData indexPathForItemAtGlobalIndex:], /SourceCache/UIKit_Sim/UIKit-2372/UICollectionViewData.m:442
2012-12-16 13:17:59.790 [16807:19703] DEBUG: request for index path for global index 1342177227 when there are only 53 items in the collection view
I checked the only thread that mentioned the same problem here: UICollectionView Assertion failure, but it is not very clear i.e. doing [collectionview reloadData] is not advisable in the performBatchUpdates() block.
Any suggestions on what might be going wrong here ?
Finally! Ok, here's what was causing this crash for me.
As previously noted, I was creating supplementary views in order to provide custom-styled section headers for my collection view.
The problem is this: it appears that the indexPath of a supplementary view MUST correspond to the indexPath of an extant cell in the collection. If the supplementary view's index path has no corresponding ordinary cell, the application will crash. I believe that the collection view attempts to retrieve information for a supplementary view's cell for some reason during the update procedure. It crashes when it cannot find one.
Hopefully this will solve your problem too!
This is the proper workaround to this crash:
Each of your supplementary views are associated with a certain index path. If you don't have a cell at that index path (initial load, you've deleted the row, etc), return a height of 0 for your supplementary view via your layout's delegate.
So, for a flow layout, implement UICollectionViewDelegateFlowLayout's
(CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout referenceSizeForHeaderInSection:(NSInteger)section
method (and the corresponding footer method, if you're using footers) with the following logic
if ( you-have-a-cell-at-the-row-for-this-section )
return myNormalHeaderSize;
else return CGSizeMake( 0,0 );
Hope this helps!
reloadData doesn't work for me, because the whole purpose of using performBatchUpdates is to get the changes animated. If you use reloadData you only refresh the data, but without animations.
So suggestions of "replace performBatchUpdates with reloadData" is pretty much saying "give up on what you're trying to do."
I'm sorry, I'm just frustrated because this error keeps coming up for me while I'm trying to do some great animated updates and my model is 100 % correct, it's some iOS magic inside getting broken and forcing me to change my solutions completely.
My opinion is that Collection Views are still buggy and can't do complicated animated refreshes, even though they should be able to. Because this used to be the same thing for Table Views but those are now pretty stable (it took time, though).
//Edit (Sep 1, 2013)
The reported bug is closed now so this issues seems to have been resolved by Apple already.
I have been having the same problem.
I have tried a number of variations, but the final one that seems to work is [self.collectionView reloadData], where "self.collectionView"is the name of your collection view.
I have tried the following methods, straight from the "UICollectionView Class Reference": inserting, moving, and deleting items.
These were used at first, to "move" the item from one section to another.
deleteItemsAtIndexPaths:
insertItemsAtIndexPaths:
Next, I tried moveItemAtIndexPath:toIndexPath:.
They all produced the following error:
Assertion failure in -[UICollectionViewData indexPathForItemAtGlobalIndex:], /SourceCache/UIKit_Sim/UIKit-2372/UICollectionViewData.m:442
So, try the "reloadData" method.
If you remove the last cell from a section containing header/footer the bug appears.
I tried to return nil for header/footer size/element at that time and this sometimes fixes the issue.
Options:
Reload the whole table view instead of animating the removal of the last item.
Add an additional invisible, basic cell with a size less than 1.
A cheeseball mistake that can lead to this error is reusing the same UICollectionViewFlowLayout on multiple collectionViews on the same viewcontroller! Just init different flowLayouts for each collectionview and you'll be good to go!
I ran into this problem when I delete one of the cells from my collection view.
The problem was that I use a custom layout, and the call layoutAttributesForElementsInRect was returning more than the number of cells in the collection view after the delete.
Apparently UICollectionView just iterates through the array returned by the method without checking the number of cells.
Modifying the method to return the same number of layout attributes solved the crash.
I still couldn't figure out how the global index was incremented so much, but I solved my problem by inserting a temporary item in the underlying datasource array i.e. cellItems and calling [self.collectionview reloadData] in viewDidLoad().
This inserts a placeholder cell temporarily in the collection view until I trigger the actual process using performBatchUpdates().

Is it more efficient to schedule a method to spawn enemies or use the update method of an Enemy cache?

I am using Cocos2d for iPhone and I am wondering if it is more efficient to structure the logic of my code to spawn enemies using this method:
-(void) schedule:(SEL)selector interval:(ccTime)interval
or using the update in an EnemyCache class and verify each time if the time interval is met. Here is the code snippet that is called in the update method of the EnemyCache class (the relative time is an integer value that is updated by the GameScene at each update in the GameScene class - the GameScene update method call is scheduled with an interval of 1 second):
-(void) checkForPlayerCollisionsAndSpwanTime
{
int count = [elements count];
//CCLOG(#"count %i", count);
Element* element;
for(int i=0; i<count;i++){
element = [elements objectAtIndex:i];
NSAssert(element!=nil, #"Nil enemy");
if (element.visible)
{
[element justComeDown];
ShipEntity * ship = [[GameScene sharedGameScene]defaultShip];
CGRect rect = [ship boundingBox];
if (CGRectIntersectsRect([element boundingBox], rect)){
[element doWhatever];
element.visible=FALSE;
[element stopAllActions];
}
}
else{
if(element.spawnTime == relativeTime) {
[self addChild:element];
element.visible=TRUE;
}
}
}
}
The difference is that in this way at each update the checkForPlayerCollisionsAndSpwanTime method goes through the array of enemies. In the first way, via scheduling a selector to call a similar method, I could reduce the time spent by the CPU to look through the array and conditions.
I am not sure how costly is this call:
[self schedule:selector interval:interval repeat:kCCRepeatForever delay:0];
Looking through I see that calls this method (See below) but I wanted to ask in general what is your approach for this problem and whether I should keep using the EnemyCache update method or use the scheduleSelector methods.
-(void) scheduleSelector:(SEL)selector forTarget:(id)target interval:(ccTime)interval paused:(BOOL)paused repeat:(uint) repeat delay:(ccTime) delay
{
NSAssert( selector != nil, #"Argument selector must be non-nil");
NSAssert( target != nil, #"Argument target must be non-nil");
tHashSelectorEntry *element = NULL;
HASH_FIND_INT(hashForSelectors, &target, element);
if( ! element ) {
element = calloc( sizeof( *element ), 1 );
element->target = [target retain];
HASH_ADD_INT( hashForSelectors, target, element );
// Is this the 1st element ? Then set the pause level to all the selectors of this target
element->paused = paused;
} else
NSAssert( element->paused == paused, #"CCScheduler. Trying to schedule a selector with a pause value different than the target");
if( element->timers == nil )
element->timers = ccArrayNew(10);
else
{
for( unsigned int i=0; i< element->timers->num; i++ ) {
CCTimer *timer = element->timers->arr[i];
if( selector == timer->selector ) {
CCLOG(#"CCScheduler#scheduleSelector. Selector already scheduled. Updating interval from: %.4f to %.4f", timer->interval, interval);
timer->interval = interval;
return;
}
}
ccArrayEnsureExtraCapacity(element->timers, 1);
}
CCTimer *timer = [[CCTimer alloc] initWithTarget:target selector:selector interval:interval repeat:repeat delay:delay];
ccArrayAppendObject(element->timers, timer);
[timer release];
}
Do you have a performance problem in your app? If not, the answer is: it doesn't matter. If you do, did you measure it and did the issue come from the method in question? If not, the answer is: you're looking in the wrong place.
In other words: premature optimization is the root of all evil.
If you still want to know, there's just one way to find out: measure both variants of the code and pick the one that's faster. If the speed difference is minimal (which I suspect it will be), favor the version that's easier for you to work with. There's a different kind of performance you should consider: you, as a human being, reading, understanding, changing code. Code readability and maintainability is way more important than performance in almost all situations.
No one can (or will) look at this amount of code and conclude "Yes, A is definitely about 30-40% faster, use A". If you are concerned about the speed of the method, don't let anyone tell you which is faster. Measure it. It's the only way you can be sure.
The reason is this: programmer's are notorious about making assumptions about code performance. Many times they're wrong, because the language or hardware or understanding of the topic have made big leaps the last time they measured it. But more likely they're going to remember what they've learned because once they've asked a question just like yours, and someone else gave them an answer which they accepted as fact from then on.
But coming back to your specific example: it really doesn't matter. You're much, much, much, much, much more likely to run into performance issues due to rendering too many enemies than the code that determines when to spawn one. And then it really, really, really, really, really doesn't matter if that code is run in a scheduled selector or a scheduled update method that increases a counter every frame. This boils down to being a subjective coding style preference issue a lot more than it is a decision about performance.

Opening a gap in NSTableView during drag and drop

I've got a simple, single-column, view-based NSTableView with items in it that can be dragged to reorder them. During drag and drop, I'd like to make it so that a gap for the item-to-be-dropped opens up at the location under the mouse. GarageBand does something like this when you drag to reorder tracks (video here: http://www.screencast.com/t/OmUVHcCNSl). As far as I can tell, there's no built in support for this in NSTableView.
Has anyone else tried to add this behavior to NSTableView and found a good solution? I've thought of and tried a couple approaches without much success. My first thought was to double the height of the row under the mouse during a drag by sending -noteHeightOfRowsWithIndexesChanged: in my data source's -tableView:validateDrop:... method, then returning twice the normal height in -tableView:heightOfRow:. Unfortunately, best I can tell, NSTableView doesn't update its layout during drag and drop, so despite calling noteHeightOfRowsWithIndexesChanged:, the row height isn't actually updated.
Note that I'm using a view-based NSTableView, but my rows are not so complex that I couldn't move to a cell-based table view if doing so helped accomplish this. I'm aware of the easy, built-in ability to animate a gap for the dropped item after a drag is complete. I'm looking for a way to open a gap while the drag is in progress. Also, this is for an app to be sold in the Mac App Store, so it must not use private API.
EDIT: I've just filed an enhancement request with Apple requesting built in support for this behavior: http://openradar.appspot.com/12662624. Dupe if you'd like to see it too. Update: The enhancement I requested was implemented in OS X 10.9 Mavericks, and this behavior is now available using NSTableView API. See NSTableViewDraggingDestinationFeedbackStyleGap.
I feel bizarre for doing this, but there's an extremely thorough answer in the queue here that appears to have been deleted by its author. In it, they provided the correct links to a working solution, which I feel need to be presented as an answer for someone else to take and run with, inclusive of them if they desire to do so.
From the documentation for NSTableView, the following caveats are tucked away for row animation effects:
Row Animation Effects
Optional constant that specifies that the tableview will use a fade for row or column removal. The effect can be combined with any NSTableViewAnimationOptions constant.
enum {
NSTableViewAnimationEffectFade = 0x1,
NSTableViewAnimationEffectGap = 0x2,
};
Constants:
...
NSTableViewAnimationEffectGap
Creates a gap for newly inserted rows. This is useful for drag and drop animations that animate to a newly opened gap and should be used in the delegate method tableView:acceptDrop:row:dropOperation:.
Going through the example code from Apple, I find this:
- (void)_performInsertWithDragInfo:(id <NSDraggingInfo>)info parentNode:(NSTreeNode *)parentNode childIndex:(NSInteger)childIndex {
// NSOutlineView's root is nil
id outlineParentItem = parentNode == _rootTreeNode ? nil : parentNode;
NSMutableArray *childNodeArray = [parentNode mutableChildNodes];
NSInteger outlineColumnIndex = [[_outlineView tableColumns] indexOfObject:[_outlineView outlineTableColumn]];
// Enumerate all items dropped on us and create new model objects for them
NSArray *classes = [NSArray arrayWithObject:[SimpleNodeData class]];
__block NSInteger insertionIndex = childIndex;
[info enumerateDraggingItemsWithOptions:0 forView:_outlineView classes:classes searchOptions:nil usingBlock:^(NSDraggingItem *draggingItem, NSInteger index, BOOL *stop) {
SimpleNodeData *newNodeData = (SimpleNodeData *)draggingItem.item;
// Wrap the model object in a tree node
NSTreeNode *treeNode = [NSTreeNode treeNodeWithRepresentedObject:newNodeData];
// Add it to the model
[childNodeArray insertObject:treeNode atIndex:insertionIndex];
[_outlineView insertItemsAtIndexes:[NSIndexSet indexSetWithIndex:insertionIndex] inParent:outlineParentItem withAnimation:NSTableViewAnimationEffectGap];
// Update the final frame of the dragging item
NSInteger row = [_outlineView rowForItem:treeNode];
draggingItem.draggingFrame = [_outlineView frameOfCellAtColumn:outlineColumnIndex row:row];
// Insert all children one after another
insertionIndex++;
}];
}
I'm unsure if it's really this simple, but it's at least worth inspection and outright refutal if it doesn't meet your needs.
Edit: see this answer's comments for the steps followed to the right solution. The OP has posted a more complete answer, which should be referred to by anyone looking for solutions to the same problem.
Note: The behavior this question and answer describes are now available using built in API in NSTableView on OS X 10.9 Mavericks and later. See NSTableViewDraggingDestinationFeedbackStyleGap.
This answer may still be useful if this behavior is needed in an app targeting OS X 10.8 or earlier.
Original answer below:
I've implemented this now. My basic approach looks like this:
#interface ORSGapOpeningTableView : NSTableView
#property (nonatomic) NSInteger dropTargetRow;
#property (nonatomic) CGFloat heightOfDraggedRows;
#end
#implementation ORSGapOpeningTableView
#pragma mark - Dragging
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender
{
NSInteger oldDropTargetRow = self.dropTargetRow;
NSDragOperation result = [super draggingUpdated:sender];
CGFloat imageHeight = [[sender draggedImage] size].height;
self.heightOfDraggedRows = imageHeight;
NSMutableIndexSet *changedRows = [NSMutableIndexSet indexSet];
if (oldDropTargetRow > 0) [changedRows addIndex:oldDropTargetRow-1];
if (self.dropTargetRow > 0) [changedRows addIndex:self.dropTargetRow-1];
[self noteHeightOfRowsWithIndexesChanged:changedRows];
return result;
}
- (void)draggingExited:(id<NSDraggingInfo>)sender
{
self.dropTargetRow = -1;
[self noteHeightOfRowsWithIndexesChanged:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [self numberOfRows])]];
[super draggingExited:sender];
}
- (void)draggingEnded:(id<NSDraggingInfo>)sender
{
self.dropTargetRow = -1;
self.heightOfDraggedRows = 0.0;
self.draggedRows = nil;
[self noteHeightOfRowsWithIndexesChanged:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [self numberOfRows])]];
}
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender
{
self.dropTargetRow = -1;
self.heightOfDraggedRows = 0.0;
self.draggedRows = nil;
[self noteHeightOfRowsWithIndexesChanged:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [self numberOfRows])]];
return [super performDragOperation:sender];
}
// In my delegate and data source:
- (NSDragOperation)tableView:(NSTableView *)tableView validateDrop:(id<NSDraggingInfo>)info proposedRow:(NSInteger)row proposedDropOperation:(NSTableViewDropOperation)dropOperation
{
if (dropOperation == NSTableViewDropOn)
{
dropOperation = NSTableViewDropAbove;
[self.tableView setDropRow:++row dropOperation:dropOperation];
}
NSDragOperation result = [self.realDataSource tableView:tableView validateDrop:info proposedRow:row proposedDropOperation:dropOperation];
if (result != NSDragOperationNone)
{
self.tableView.dropTargetRow = row;
}
else
{
self.tableView.dropTargetRow = -1; // Don't open a gap
}
return result;
}
- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row
{
CGFloat result = [tableView rowHeight];
if (row == self.tableView.dropTargetRow - 1 && row > -1)
{
result += self.tableView.heightOfDraggedRows;
}
return result;
}
Note that this is simplified code, not a verbatim copy/paste from my program. I actually ended up making this all contained within an NSTableView subclass that uses proxy delegate and data source objects so the code in data source/delegate methods above is actually inside the proxies' intercept of the calls to the real delegate and data source. That way, the real data source and delegate don't have to do anything special to get the gap opening behavior. Also, there's sometimes a little flakiness with the table view animations, and this doesn't work for drags above the first row (no gap is opened since there's no row to make taller). All in all, despite the room for further improvement, this approach works reasonably well.
I'd still like to try a similar approach, but insert a blank row (as Caleb suggested) instead of changing the row height.
As of Mac OS X 10.9 (Mavericks), there's a much easier solution to animating drag & drop in a NSTableView:
[aTableView setDraggingDestinationFeedbackStyle:NSTableViewDraggingDestinationFeedbackStyleGap];
The table view will automatically insert gaps with animation as a row is dragged which is much nicer than the old blue line insertion point method.
One way to accomplish what you're asking is to insert an empty row at the proposed drop point (that is, between the two nearest rows). It sounds like you've been looking at using NSTableViewAnimationEffectGap, which as you note is really meant for animating the insertion when the drop is accepted in -tableView:acceptDrop:row:dropOperation:.
Since you want to open up the gap before the user releases the mouse button to actually do the drop, you could instead insert a blank row using -insertRowsAtIndexes:withAnimation: from your table's -draggingUpdate: method and at the same time delete any blank row you previously inserted for this drag using -removeRowsAtIndexes:withAnimation:. Use NSTableViewAnimationSlideUp and NSTableViewAnimationSlideDown as the animations for these operations, as appropriate.

Can’t undo more than one operation

When I call undo on the context following deletion of a single object, all works as expected. But if user deletes an object, then deletes another object, undo will work only to restore the second object, no matter how many times user requests undo, as though undoLevels were set to 1. This happens whether undoLevels is at the default of 0 (unlimited) or is explicitly set to 6 as a test.
Furthermore, if a single action deletes multiple objects, calling undo afterward has no effect; none of the objects is restored. I tried explicitly bracketing the deletion loop with begin/endUndoGrouping, to no avail. The undoManager’s groupsByEvent is YES (by default), but it makes no difference whether I call a straight undo or undoNestedGroup.
Is the context somehow being saved after each operation? No, because if I quit and relaunch the app after running these tests, all objects are still present in the database.
What am I missing?
OK, you want code. Here’s what I imagine is most relevant:
Context getter:
- (NSManagedObjectContext *) managedObjectContextMain {
if (managedObjectContextMain) return managedObjectContextMain;
NSPersistentStoreCoordinator *coordinatorMain = [self persistentStoreCoordinatorMain];
if (!coordinatorMain) {
// present error...
return nil;
}
managedObjectContextMain = [[NSManagedObjectContext alloc] init];
[managedObjectContextMain setPersistentStoreCoordinator: coordinatorMain];
// Add undo support. (Default methods don't include this.)
NSUndoManager *undoManager = [[NSUndoManager alloc] init];
// [undoManager setUndoLevels:6]; // makes no difference
[managedObjectContextMain setUndoManager:undoManager];
[undoManager release];
// ...
return managedObjectContextMain;
}
Multiple-object deletion method (called by a button on a modal panel):
/*
NOTE FOR SO:
SpecialObject has a to-one relationship to Series.
Series has a to-many relationship to SpecialObject.
The deletion rule for both is Nullify.
Series’ specialObject members need to be kept in a given order. So Series has a transformable attribute, an array of objectIDs, used to prepare a transient attribute, an array of specialObjects, in the same order as their objectIDs.
*/
- (void) deleteMultiple {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
NSUndoManager *undoMgr = [contextMain undoManager];
[undoMgr beginUndoGrouping];
// Before performing the actual deletion, drop the seln in the locator table.
[appDelegate.objLocatorController.tvObjsFound deselectAll:self];
// Get the indices of the selected objects and enumerate through them.
NSIndexSet *selectedIndices = [appDelegate.objLocatorController.tvObjsFound selectedRowIndexes];
NSUInteger index = [selectedIndices firstIndex];
while (index != NSNotFound) {
// Get the obj to be deleted and its series.
SpecialObject *sobj = [appDelegate.objLocatorController.emarrObjsLoaded objectAtIndex:index];
Series *series = nil;
series = sobj.series;
// Just in case...
if (!series) {
printf("\nCESeries' deleteMultiple was called when Locator seln included objs that are not a part of a series. The deletion loop has therefore aborted.");
break;
}
// Get the obj's series index and delete it from the series.
// (Series has its own method that takes care of both relnshp and cache.)
NSUInteger uiIndexInSeries = [series getSeriesIndexOfObj:sobj];
[series deleteObj:sobj fromSeriesIndex:uiIndexInSeries];
// Mark the special object for Core Data deletion; it will still be a non-null object in emarrObjsLoaded (objLocatorController’s cache).
[contextMain deleteObject:sobj];
// Get the next index in the set.
index = [selectedIndices indexGreaterThanIndex:index];
}
[undoMgr endUndoGrouping];
// Purge the deleted objs from loaded, which will also reload table data.
[appDelegate.objLocatorController purgeDeletedObjsFromLoaded];
// Locator table data source has changed, so reload. But end with no selection. (SeriesBox label will have been cleared when Locator seln was dropped.)
[appDelegate.objLocatorController.tvObjsFound reloadData];
// Close the confirm panel and stop its modal session.
[[NSApplication sharedApplication] stopModal];
[self.panelForInput close];
}
Here’s the Series method that removes the object from its relationship and ordered cache:
/**
Removes a special object from the index sent in.
(The obj is removed from objMembers relationship and from the transient ordered obj cache, but it is NOT removed from the transformable array of objectIDrepns.)
*/
- (void) deleteObj:(SpecialObject *)sobj fromSeriesIndex:(NSUInteger)uiIndexForDeletion {
// Don't proceed if the obj is null or the series index is invalid.
if (!sobj)
return;
if (uiIndexForDeletion >= [self.emarrObjs count])
return;
// Use the safe Core Data method for removing the obj from the relationship set.
// (To keep it private, it has not been declared in h file. PerformSelector syntax here prevents compiler warning.)
[self performSelector:#selector(removeObjMembersObject:) withObject:sobj];
// Remove the obj from the transient ordered cache at the index given.
[self.emarrObjs removeObjectAtIndex:uiIndexForDeletion];
// But do NOT remove the obj’s objectID from the transformable dataObjIDsOrdered array. That doesn't happen until contextSave. In the meantime, undo/cancel can use dataObjIDsOrdered to restore this obj.
}
Here’s the method, and its follow-up, called by comm-z undo:
- (void) undoLastChange {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
// Perform the undo. (Core Data has integrated this functionality so that you can call undo directly on the context, as long as it has been assigned an undo manager.)
// [contextMain undo];
printf("\ncalling undo, with %lu levels.", [contextMain.undoManager levelsOfUndo]);
[contextMain.undoManager undoNestedGroup];
// Do cleanup.
[self cleanupFllwgUndoRedo];
}
- (void) cleanupFllwgUndoRedo {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
DataSourceCoordinator *dataSrc = appDelegate.dataSourceCoordinator;
// ...
// Rebuild caches of special managed objects.
// (Some managed objects have their own caches, i.e. Series' emarrObjs. These need to be refreshed if their membership has changed. There's no need to use special trackers; the context keeps track of these.)
for (NSManagedObject *obj in [contextMain updatedObjects]) {
if ([obj isKindOfClass:[Series class]] && ![obj isDeleted])
[((Series *)obj) rebuildSeriesCaches];
}
// ...
// Regenerate locator's caches.
[appDelegate.objLocatorController regenerateObjCachesFromMuddies]; // also reloads table
}
Here’s the series method that regenerates its caches following undo/awake:
- (void) rebuildSeriesCaches {
// Don't proceed if there are no stored IDs.
if (!self.dataObjIDsOrdered || [self.dataObjIDsOrdered count] < 1) {
// printf to alert me, because this shouldn’t happen (and so far it doesn’t)
return;
}
NSMutableArray *imarrRefreshedObjIdsOrdered = [NSMutableArray arrayWithCapacity:[self.objMembers count]];
NSMutableArray *emarrRefreshedObjs = [NSMutableArray arrayWithCapacity:[self.objMembers count]];
// Loop through objectIDs (their URIRepns) that were stored in transformable dataObjIDsOrdered.
for (NSURL *objectIDurl in self.dataObjIDsOrdered) {
// For each objectID repn, loop through the objMembers relationship, looking for a match.
for (SpecialObject *sobj in self.objMembers) {
// When a match is found, add the objectID repn and its obj to their respective replacement arrays.
if ([[sobj.objectID URIRepresentation] isEqualTo:objectIDurl]) {
[imarrRefreshedObjIdsOrdered addObject:objectIDurl];
[emarrRefreshedObjs addObject:sobj];
break;
}
// If no match is found, the obj must have been deleted; the objectID repn doesn't get added to the replacement array, so it is effectively dropped.
}
}
// Assign their replacement arrays to the transformable and transient attrs.
self.dataObjIDsOrdered = imarrRefreshedObjIdsOrdered;
self.emarrObjs = emarrRefreshedObjs;
}
(I’ve omitted the Locator’s regenerateObjCachesFromMuddies because, although I am using its table to view the results of the deletion and undo, I can reload the table with a new fetch, completely regenerating the table’s caches, and this test still shows that the undo isn’t working.)
As usual, just the task of putting together a SO question helps solve the problem, and I realize now that undo works fine as long as I’m working with simple objects that don’t involve the reciprocal SpecialObject-Series relationship. I’m doing something wrong there...
I think you're getting into a fight with custom undo stuff and Core Data's automagic support.
In normal undo/redo code, you have undoable funnel points. Usually an undoable add and its inverse undoable remove. Calling one registers the other as the inverse action and vice-versa. User undo/redo then just goes back and forth between them. You separate your "user created a new Foo" code from your "now add this foo to the collection undoably" code (that way "remove Foo" and "add Foo" work independently of supplying a newly-created Foo).
With Core Data, add and remove means "insert into the context and remove from the context". Also, you still need custom funnel methods because (in your case), you're doing some additional stuff (updating a cache). This is easy enough to do with a Foo, but what happens when you want to manipulate the relationship between a Foo/Bar assembly that gets created in one action?
If creating a Foo created a few Bars with it, it'd be one thing (-awakeFromInsert and the like) since you'd only have to deal with updating your caching (which you could do, by the way, through key/value observing the context for changes). Since creating a Foo seems to establish relationships with existing Bars (which are already in the context), you run into a difficult wall when trying to cooperate with CD's built-in undo support.
There is no easy solution in this case if you're using the built-in Core Data undo/redo support. In this case, you can do as this post suggests and turn it off. You can then handle undo/redo entirely yourself ... but you'll have a lot of code to write to observe your objects for changes to interesting attributes, registering the inverse action for each.
While it isn't a solution to your problem, I hope it at least points out the complexity of what you're trying to do and gives you a possible path forward. Without knowing a LOT more about your model (at the conceptual level at least) and how your UI presents it to the user, it's hard to give specific architectural advice.
I hope I'm wrong about this case - maybe someone else can give you a better answer. :-)
It turns out that you can have Foo creation that involves changing relationships with pre-existing Bars, and custom caches, and NSUndoManager can still handle it all — but with a kink: You have to save the context after each such change; otherwise the undo manager will cease to function.
Since undo can actually reach back to states before the save, this is not such a bad thing. It does complicate matters if you want the user to be able to revert to the state when they last chose to save, but that can be handled by making a copy of the database whenever the user chooses to save.
So in the deleteMultiple method, following the while deletion loop, I added a call to save the context.
There’s another error in my scheme, which is that I erroneously thought NSUndoManager would ignore transformable attributes. Well, obviously, since transformable attrs are persisted, they are tracked by the persistentStoreCoordinator and are therefore included in undo operations. So when I failed to update the xformable attr array upon deletion, thinking I would need its info for restoration in the event of undo, I was ruining the action/inverse-action symmetry.
So in the deleteObject:fromSeriesIndex method, the Series method that handles the caches, I added this code, updating the transformable ObjectID array:
NSMutableArray *emarrRemoveID = [self.dataObjIDsOrdered mutableCopy];
[emarrRemoveID removeObjectAtIndex:uiIndexForDeletion];
self.dataObjIDsOrdered = emarrRemoveID;
[emarrRemoveID release];
(My assumption that the NSUndoManager would ignore the transient cache was correct. The call to rebuildSeriesCaches in cleanupFllwgUndoRedo takes care of that.)
Undo now works, both for simple objects and for objects in SpecialObject-Series relationships. The only remaining problem is that it takes more than one command-Z to happen. I’ll have to experiment more with the groupings…
EDIT: It isn’t necessary to save the context post-deletion if the managed object’s custom caches are handled correctly:
1) The caches should NOT be rebuilt following undo. The undo manager will take care of this on its own, even for the transient cache, as long as the transient property is included in the managed object model.
2) When changing the NSMutableArray cache (emarrObjs), using removeObjectAtIndex alone will confuse the undo manager. The entire cache must be replaced, the same way it is with the NSArray cache dataObjIDsOrdered.