I looked at a simple implementation of Core Data's deleteObject() method recently, but for my Swift and Metal based reaction diffusion application, I want something a little more user friendly: if my user inadvertently deletes an item, I want them to be able to undo the deletion without resorting to unnecessary dialogs and stopping the proceedings with idiocy.
The first step is to replace the default 'cut, copy, paste' context menu that comes for free with the UICollectionView with my own action sheet. To that end, I'm not using any of the menu or action related collectionView() methods that are part of UICollectionViewDelegate: I'm adding my own long press gesture recogniser to the UICollectionView instance in my BrowseAndLoadController:
let longPress = UILongPressGestureRecognizer(target: self, action: "longPressHandler:")
collectionViewWidget.addGestureRecognizer(longPress)
When the user first touches one of the collection view's cells - before the cell is selected - I want to note a reference to it inside a tuple containing its instance and index path. In the collection view, this is a highlight, so I use the didHighlightCellAtIndexPath implementation of the delegate method, collectionView():
var longPressTarget: (cell: UICollectionViewCell, indexPath: NSIndexPath)?
[...]
func collectionView(collectionView: UICollectionView, didHighlightItemAtIndexPath indexPath: NSIndexPath)
{
longPressTarget = (cell: self.collectionView(collectionViewWidget, cellForItemAtIndexPath: indexPath), indexPath: indexPath)
}
When the long hold gesture begins (which after the half second press, not at the first touch), my longPressHandler() is invoked. Here, I dynamically create an action sheet that either displays a delete option or, if the item has already been marked for a delete, an undelete option. I use the frame of the highlighted cell to up the action sheet:
func longPressHandler(recognizer: UILongPressGestureRecognizer)
{
if recognizer.state == UIGestureRecognizerState.Began
{
if let _longPressTarget = longPressTarget
{
let entity = dataprovider[_longPressTarget.indexPath.item]
let contextMenuController = UIAlertController(title: nil, message: nil, preferredStyle: UIAlertControllerStyle.ActionSheet)
let deleteAction = UIAlertAction(title: entity.pendingDelete ? "Undelete" : "Delete", style: UIAlertActionStyle.Default, handler: togglePendingDelete)
contextMenuController.addAction(deleteAction)
if let popoverPresentationController = contextMenuController.popoverPresentationController
{
popoverPresentationController.permittedArrowDirections = UIPopoverArrowDirection.Down
popoverPresentationController.sourceRect = _longPressTarget.cell.frame.rectByOffsetting(dx: collectionViewWidget.frame.origin.x, dy: collectionViewWidget.frame.origin.y - collectionViewWidget.contentOffset.y)
popoverPresentationController.sourceView = view
presentViewController(contextMenuController, animated: true, completion: nil)
}
}
}
}
When the user deletes or undeletes, the action invokes togglePendingDelete() and, since the tuple, longPressTarget, holds the item of interest, I simply toggle its pendingDelete Boolean property.
The BrowseAndLoadController has a showDeleted property which indicates whether its collection view should display the items with a true pendingDelete, so togglePendingDelete() either reloads the changed item or uses the deleteItemsAtIndexPath() to animate the removal of a deleted cell:
func togglePendingDelete(value: UIAlertAction!) -> Void
{
if let _longPressTarget = longPressTarget
{
let targetEntity = dataprovider[_longPressTarget.indexPath.item]
targetEntity.pendingDelete = !targetEntity.pendingDelete
if showDeleted
{
// if we're displaying peniding deletes....
collectionViewWidget.reloadItemsAtIndexPaths([_longPressTarget.indexPath])
}
else
{
// if we're deleting
if targetEntity.pendingDelete
{
let targetEntityIndex = find(dataprovider, targetEntity)
dataprovider.removeAtIndex(targetEntityIndex!)
collectionViewWidget.deleteItemsAtIndexPaths([_longPressTarget.indexPath])
}
}
}
}
The toggle switch that shows or hides the items pending a delete toggles the showDeleted Boolean and, in the case of hiding, uses a simple filter closure to remove the unwanted items from view:
if showDeleted
{
dataprovider = fetchResults
}
else
{
dataprovider = fetchResults.filter({!$0.pendingDelete})
}
In this screen shot, you can see items pending delete being show slightly dimmed out and displaying 'undelete' on a long hold gesture:
The final piece of the puzzle is to actually delete these items. I do this in my AppDelegate class inside applicationWillTerminate(). Here, I loop over all the items inside the managed object context and invoke deleteObject() on each one with its pendingDelete set to true:
func applicationWillTerminate(application: UIApplication)
{
let fetchRequest = NSFetchRequest(entityName: "ReactionDiffusionEntity")
if let _managedObjectContext = managedObjectContext
{
if let fetchResults = _managedObjectContext.executeFetchRequest(fetchRequest, error: nil) as? [ReactionDiffusionEntity]
{
for entity in fetchResults
{
if entity.pendingDelete
{
_managedObjectContext.deleteObject(entity)
}
}
}
}
self.saveContext()
}
All the source code for this project is available at my GitHub repository here.
Addendum: looks like applicationWillTerminate doesn't always get called (see this StackOverflow post). So, I've moved the code to delete items that are pending delete into its own function that gets invoked on applicationDidEnterBackground() and applicationWillTerminate(). I'm considering timestamping the user's delete action to only properly delete items that the user marked for deletion after a period of time (such as an hour).
First, thank you for posting! There still seem to be some issues around UICollectionView and how it is not as "mature" as UITableView. I think they work fine and this was the first issue that I had with it so I don't know if that is longer an issue. Anyway, there seem to be some debate! :)
ReplyDeleteI found your blog post searching the internet for an answer why I could not get the indexPath of the selected cell in an UICollectionView using
let locationInView = gestureRecognizer.locationInView(self.collectionView)
let indexPath = self.collectionView?.indexPathForItemAtPoint(locationInView)
But locationInView() always returned (0,0) so it was impossible to get the indexPath. So, I found your blog post and tried it immediately.
No luck! The overridden didHighlightItemAtIndexPath() method was never being called so therefore I was unable to set the property longPressTarget.
After a few minutes I looked how I defined the gesture recognizer object and I saw that I had delaysTouchesBegan set to true:
longPressGestureRecognizer.delaysTouchesBegan = true
Actually not knowing what the property is doing I changed it to
longPressGestureRecognizer.delaysTouchesBegan = false
and suddenly the didHighlightItemAtIndexPath() method was being called and therefore your evil scheme worked for me.
I have also seen some implementations where the cell itselfs has views (buttons) but the action method is actually in the view controller. But since I want to keep my cells as simple as possible your method was the better one.
I should say that my view was a rather complicated one with one parent view controller,holding two container views with the two corresponding view controllers as subviewcontrollers to the parent view controller. Perhaps this is the cause of why the
locationInView() method is not working.
kind regards
Anders Ericsson
Does anyone know why I am getting an error in: func longPressHandler(recognizer: UILongPressGestureRecognizer) at line: let entity = dataprovider[_longPressTarget.indexPath.item] that states: array index out of range
ReplyDeleteHi!! I get an error at this line: let entity = data[_longPressTarget.indexPath.item] ; error: fatal error: Array index is out of range, do you know why this is happening?
ReplyDelete