CRASH attempt to delete and reload the same index path

I was able to reproduce this today. To do this you need to:

  1. Open your app that is listening for changes
  2. Open the photos app, save a set of photos to your photo library from an iCloud shared album
  3. Go to the photos app, delete some of those photos
  4. Go again to the iCloud shared album and save again the some of the photos you deleted. You'll see this condition happen.

I found an updated code that seems to work better to handle the updating behavior here: https://developer.apple.com/library/ios/documentation/Photos/Reference/PHPhotoLibraryChangeObserver_Protocol/

But it still doesn't handle this situation nor when the indexes to be deleted are bigger (i.e. Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to delete item 9 from section 0 which only contains 9 items before the update'). I created this updated version of this code that deals with this better and hasn't crashed for me anymore so far.

func photoLibraryDidChange(changeInfo: PHChange!) {

    // Photos may call this method on a background queue;
    // switch to the main queue to update the UI.
    dispatch_async(dispatch_get_main_queue()) {


        // Check for changes to the list of assets (insertions, deletions, moves, or updates).
        if let collectionChanges = changeInfo.changeDetailsForFetchResult(self.assetsFetchResult) {

            // Get the new fetch result for future change tracking.
            self.assetsFetchResult = collectionChanges.fetchResultAfterChanges

            if collectionChanges.hasIncrementalChanges {

                // Get the changes as lists of index paths for updating the UI.
                var removedPaths: [NSIndexPath]?
                var insertedPaths: [NSIndexPath]?
                var changedPaths: [NSIndexPath]?
                if let removed = collectionChanges.removedIndexes {
                    removedPaths = self.indexPathsFromIndexSetWithSection(removed,section: 0)
                }
                if let inserted = collectionChanges.insertedIndexes {
                    insertedPaths = self.indexPathsFromIndexSetWithSection(inserted,section: 0)
                }
                if let changed = collectionChanges.changedIndexes {
                    changedPaths = self.indexPathsFromIndexSetWithSection(changed,section: 0)
                }
                var shouldReload = false
                if changedPaths != nil && removedPaths != nil{
                    for changedPath in changedPaths!{
                        if contains(removedPaths!,changedPath){
                            shouldReload = true
                            break
                        }
                    }

                }

                if removedPaths?.last?.item >= self.assetsFetchResult.count{
                    shouldReload = true
                }

                if shouldReload{
                    self.collectionView.reloadData()
                }else{
                    // Tell the collection view to animate insertions/deletions/moves
                    // and to refresh any cells that have changed content.
                    self.collectionView.performBatchUpdates(
                        {
                            if let theRemovedPaths = removedPaths {
                                self.collectionView.deleteItemsAtIndexPaths(theRemovedPaths)
                            }
                            if let theInsertedPaths = insertedPaths {
                                self.collectionView.insertItemsAtIndexPaths(theInsertedPaths)
                            }
                            if let theChangedPaths = changedPaths{
                                self.collectionView.reloadItemsAtIndexPaths(theChangedPaths)
                            }
                            if (collectionChanges.hasMoves) {
                                collectionChanges.enumerateMovesWithBlock() { fromIndex, toIndex in
                                    let fromIndexPath = NSIndexPath(forItem: fromIndex, inSection: 0)
                                    let toIndexPath = NSIndexPath(forItem: toIndex, inSection: 0)
                                    self.collectionView.moveItemAtIndexPath(fromIndexPath, toIndexPath: toIndexPath)
                                }
                            }
                        }, completion: nil)

                }

            } else {
                // Detailed change information is not available;
                // repopulate the UI from the current fetch result.
                self.collectionView.reloadData()
            }
        }
    }
}

func indexPathsFromIndexSetWithSection(indexSet:NSIndexSet?,section:Int) -> [NSIndexPath]?{
    if indexSet == nil{
        return nil
    }
    var indexPaths:[NSIndexPath] = []

    indexSet?.enumerateIndexesUsingBlock { (index, Bool) -> Void in
        indexPaths.append(NSIndexPath(forItem: index, inSection: section))
    }
    return indexPaths

}

Swift 3 / iOS 10 version:

func photoLibraryDidChange(_ changeInstance: PHChange) {
    guard let collectionView = self.collectionView else {
        return
    }

    // Photos may call this method on a background queue;
    // switch to the main queue to update the UI.
    DispatchQueue.main.async {
        guard let fetchResults = self.fetchResults else {
            collectionView.reloadData()
            return
        }

        // Check for changes to the list of assets (insertions, deletions, moves, or updates).
        if let collectionChanges = changeInstance.changeDetails(for: fetchResults) {
            // Get the new fetch result for future change tracking.
            self.fetchResults = collectionChanges.fetchResultAfterChanges

            if collectionChanges.hasIncrementalChanges {
                // Get the changes as lists of index paths for updating the UI.
                var removedPaths: [IndexPath]?
                var insertedPaths: [IndexPath]?
                var changedPaths: [IndexPath]?
                if let removed = collectionChanges.removedIndexes {
                    removedPaths = self.indexPaths(from: removed, section: 0)
                }
                if let inserted = collectionChanges.insertedIndexes {
                    insertedPaths = self.indexPaths(from:inserted, section: 0)
                }
                if let changed = collectionChanges.changedIndexes {
                    changedPaths = self.indexPaths(from: changed, section: 0)
                }
                var shouldReload = false
                if let removedPaths = removedPaths, let changedPaths = changedPaths {
                    for changedPath in changedPaths {
                        if removedPaths.contains(changedPath) {
                            shouldReload = true
                            break
                        }
                    }
                }

                if let item = removedPaths?.last?.item {
                    if item >= fetchResults.count {
                        shouldReload = true
                    }
                }

                if shouldReload {
                    collectionView.reloadData()
                } else {
                    // Tell the collection view to animate insertions/deletions/moves
                    // and to refresh any cells that have changed content.
                    collectionView.performBatchUpdates({
                        if let theRemovedPaths = removedPaths {
                            collectionView.deleteItems(at: theRemovedPaths)
                        }
                        if let theInsertedPaths = insertedPaths {
                            collectionView.insertItems(at: theInsertedPaths)
                        }
                        if let theChangedPaths = changedPaths {
                            collectionView.reloadItems(at: theChangedPaths)
                        }

                        collectionChanges.enumerateMoves { fromIndex, toIndex in
                            collectionView.moveItem(at: IndexPath(item: fromIndex, section: 0),
                                                    to: IndexPath(item: toIndex, section: 0))
                        }
                    })
                }
            } else {
                // Detailed change information is not available;
                // repopulate the UI from the current fetch result.
                collectionView.reloadData()
            }
        }
    }
}

func indexPaths(from indexSet: IndexSet?, section: Int) -> [IndexPath]? {
    guard let set = indexSet else {
        return nil
    }

    return set.map { (index) -> IndexPath in
        return IndexPath(item: index, section: section)
    }
}

I just moved the reloadItemsAtIndexPaths after the batch updates are completed to fix the crash of deleting and reloading at the same time.

From docs of changedIndexes of PHFetchResultChangeDetails:

These indexes are relative to the original fetch result (the fetchResultBeforeChanges property) after you’ve applied the changes described by the removedIndexes and insertedIndexes properties; when updating your app’s interface, apply changes after removals and insertions and before moves.

PHFetchResultChangeDetails *collectionChanges = [changeInstance changeDetailsForFetchResult:self.assetsFetchResults];
[collectionView performBatchUpdates:^{ 
        NSIndexSet *removedIndexes = [collectionChanges removedIndexes];
        if ([removedIndexes count]) {
            [collectionView deleteItemsAtIndexPaths:[self indexPathsFromIndexes:removedIndexes withSection:0]];
        }
        NSIndexSet *insertedIndexes = [collectionChanges insertedIndexes];
        if ([insertedIndexes count]) {
            [collectionView insertItemsAtIndexPaths:[self indexPathsFromIndexes:insertedIndexes withSection:0]];
        }
    } completion:^(BOOL finished) {
        if (finished) {
            // Puting this after removes and inserts indexes fixes a crash of deleting and reloading at the same time.
            // From docs: When updating your app’s interface, apply changes after removals and insertions and before moves.
            NSIndexSet *changedIndexes = [collectionChanges changedIndexes];
            if ([changedIndexes count]) {
                [collectionView reloadItemsAtIndexPaths:[self indexPathsFromIndexes:changedIndexes withSection:0]];
            }
       }
    }