27

I'd like to use the NSFetchedResultsControllerRelegate in a CollectionViewController. Therefore I just changed the method for the TableViewController for the CollectionView.

(void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo
       atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type {

    switch(type) {
        case NSFetchedResultsChangeInsert:
            [self.collectionView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex]];
            break;

        case NSFetchedResultsChangeDelete:
            [self.collectionView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] ];

       break;
    }
}


(void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
   atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
  newIndexPath:(NSIndexPath *)newIndexPath {

  UICollectionView *collectionView = self.collectionView;

  switch(type) {

    case NSFetchedResultsChangeInsert:
        [collectionView insertItemsAtIndexPaths:[NSArray arrayWithObject:newIndexPath]];
        break;

    case NSFetchedResultsChangeDelete:
        [collectionView deleteItemsAtIndexPaths:[NSArray arrayWithObject:indexPath]];
        break;

    case NSFetchedResultsChangeUpdate:
        [collectionView reloadItemsAtIndexPaths:[NSArray arrayWithObject:indexPath]];
        break;

    case NSFetchedResultsChangeMove:
        [collectionView deleteItemsAtIndexPaths:[NSArray arrayWithObject:indexPath]];
        [collectionView insertItemsAtIndexPaths:[NSArray arrayWithObject:newIndexPath]];
        break;
  }
}

(void)controllerDidChangeContent:(NSFetchedResultsController *)controller {
   [self.collectionView reloadData];
}

But I do not know how to handle the WillChangeContent (beginUpdates for TableView) and DidChangeContent (endUpdates for TableView) for a CollectionView.

Everything works fine except when I move one item from one section to another section. Then I get the following error.

This is usually a bug within an observer of NSManagedObjectContextObjectsDidChangeNotification. Invalid update: invalid number of items in section 0....

Any idea how can I solve this issue?

Fattie
  • 27,874
  • 70
  • 431
  • 719
aquarius68
  • 353
  • 1
  • 5
  • 6

6 Answers6

34

Here is my implementation with Swift. First initialise an array of NSBlockOperations:

var blockOperations: [NSBlockOperation] = []

In controller will change, re-init the array:

func controllerWillChangeContent(controller: NSFetchedResultsController) {
    blockOperations.removeAll(keepCapacity: false)
}

In the did change object method:

    func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {

    if type == NSFetchedResultsChangeType.Insert {
        println("Insert Object: \(newIndexPath)")

        blockOperations.append(
            NSBlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.insertItemsAtIndexPaths([newIndexPath!])
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.Update {
        println("Update Object: \(indexPath)")
        blockOperations.append(
            NSBlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.reloadItemsAtIndexPaths([indexPath!])
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.Move {
        println("Move Object: \(indexPath)")

        blockOperations.append(
            NSBlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.moveItemAtIndexPath(indexPath!, toIndexPath: newIndexPath!)
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.Delete {
        println("Delete Object: \(indexPath)")

        blockOperations.append(
            NSBlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.deleteItemsAtIndexPaths([indexPath!])
                }
            })
        )
    }
}

In the did change section method:

func controller(controller: NSFetchedResultsController, didChangeSection sectionInfo: NSFetchedResultsSectionInfo, atIndex sectionIndex: Int, forChangeType type: NSFetchedResultsChangeType) {

    if type == NSFetchedResultsChangeType.Insert {
        println("Insert Section: \(sectionIndex)")

        blockOperations.append(
            NSBlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.insertSections(NSIndexSet(index: sectionIndex))
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.Update {
        println("Update Section: \(sectionIndex)")
        blockOperations.append(
            NSBlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.reloadSections(NSIndexSet(index: sectionIndex))
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.Delete {
        println("Delete Section: \(sectionIndex)")

        blockOperations.append(
            NSBlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.deleteSections(NSIndexSet(index: sectionIndex))
                }
            })
        )
    }
}

And finally, in the did controller did change content method:

func controllerDidChangeContent(controller: NSFetchedResultsController) {        
    collectionView!.performBatchUpdates({ () -> Void in
        for operation: NSBlockOperation in self.blockOperations {
            operation.start()
        }
    }, completion: { (finished) -> Void in
        self.blockOperations.removeAll(keepCapacity: false)
    })
}

I personally added some code in the deinit method as well, in order to cancel the operations when the ViewController is about to get deallocated:

deinit {
    // Cancel all block operations when VC deallocates
    for operation: NSBlockOperation in blockOperations {
        operation.cancel()
    }

    blockOperations.removeAll(keepCapacity: false)
}
pkamb
  • 33,281
  • 23
  • 160
  • 191
Plot
  • 898
  • 2
  • 15
  • 26
24

I made @Plot's solution it's own object and converted it to Swift 2

import Foundation
import CoreData

class CollectionViewFetchedResultsControllerDelegate: NSObject, NSFetchedResultsControllerDelegate {

    // MARK: Properties

    private let collectionView: UICollectionView
    private var blockOperations: [NSBlockOperation] = []

    // MARK: Init

    init(collectionView: UICollectionView) {
        self.collectionView = collectionView
    }

    // MARK: Deinit

    deinit {
        blockOperations.forEach { $0.cancel() }
        blockOperations.removeAll(keepCapacity: false)
    }

    // MARK: NSFetchedResultsControllerDelegate

    func controllerWillChangeContent(controller: NSFetchedResultsController) {
        blockOperations.removeAll(keepCapacity: false)
    }

    func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {

        switch type {

        case .Insert:
            guard let newIndexPath = newIndexPath else { return }
            let op = NSBlockOperation { [weak self] in self?.collectionView.insertItemsAtIndexPaths([newIndexPath]) }
            blockOperations.append(op)

        case .Update:
            guard let newIndexPath = newIndexPath else { return }
            let op = NSBlockOperation { [weak self] in self?.collectionView.reloadItemsAtIndexPaths([newIndexPath]) }
            blockOperations.append(op)

        case .Move:
            guard let indexPath = indexPath else { return }
            guard let newIndexPath = newIndexPath else { return }
            let op = NSBlockOperation { [weak self] in self?.collectionView.moveItemAtIndexPath(indexPath, toIndexPath: newIndexPath) }
            blockOperations.append(op)

        case .Delete:
            guard let indexPath = indexPath else { return }
            let op = NSBlockOperation { [weak self] in self?.collectionView.deleteItemsAtIndexPaths([indexPath]) }
            blockOperations.append(op)

        }
    }

    func controller(controller: NSFetchedResultsController, didChangeSection sectionInfo: NSFetchedResultsSectionInfo, atIndex sectionIndex: Int, forChangeType type: NSFetchedResultsChangeType) {

        switch type {

        case .Insert:
            let op = NSBlockOperation { [weak self] in self?.collectionView.insertSections(NSIndexSet(index: sectionIndex)) }
            blockOperations.append(op)

        case .Update:
            let op = NSBlockOperation { [weak self] in self?.collectionView.reloadSections(NSIndexSet(index: sectionIndex)) }
            blockOperations.append(op)

        case .Delete:
            let op = NSBlockOperation { [weak self] in self?.collectionView.deleteSections(NSIndexSet(index: sectionIndex)) }
            blockOperations.append(op)

        default: break

        }
    }

    func controllerDidChangeContent(controller: NSFetchedResultsController) {
        collectionView.performBatchUpdates({
            self.blockOperations.forEach { $0.start() }
        }, completion: { finished in
            self.blockOperations.removeAll(keepCapacity: false)
        })
    }

}

Usage:

fetchedResultsController.delegate = CollectionViewFetchedResultsControllerDelegate(collectionView)

Swift 4 version

private var blockOperations: [BlockOperation] = []

func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    blockOperations.removeAll(keepingCapacity: false)
}

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                didChange anObject: Any,
                at indexPath: IndexPath?,
                for type: NSFetchedResultsChangeType,
                newIndexPath: IndexPath?) {

    let op: BlockOperation
    switch type {
    case .insert:
        guard let newIndexPath = newIndexPath else { return }
        op = BlockOperation { self.collectionView.insertItems(at: [newIndexPath]) }

    case .delete:
        guard let indexPath = indexPath else { return }
        op = BlockOperation { self.collectionView.deleteItems(at: [indexPath]) }
    case .move:
        guard let indexPath = indexPath,  let newIndexPath = newIndexPath else { return }
        op = BlockOperation { self.collectionView.moveItem(at: indexPath, to: newIndexPath) }
    case .update:
        guard let indexPath = indexPath else { return }
        op = BlockOperation { self.collectionView.reloadItems(at: [indexPath]) }
    }

    blockOperations.append(op)
}

func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    collectionView.performBatchUpdates({
        self.blockOperations.forEach { $0.start() }
    }, completion: { finished in
        self.blockOperations.removeAll(keepingCapacity: false)
    })
}
Wajih
  • 4,227
  • 2
  • 25
  • 40
Adam Waite
  • 19,175
  • 22
  • 126
  • 148
  • it's give me error `Extensions must not contain stored properties` on `private var blockOperations: [BlockOperation] = []` I don't know why =\ – Basel Nov 24 '18 at 20:37
  • I did not try this but I wonder. Does this work: `fetchedResultsController.delegate = CollectionViewFetchedResultsControllerDelegate(collectionView)`? I assume `delegate`is `weak`. – shallowThought Dec 06 '19 at 16:25
14

Combining a fetched results controller with a collection view is a bit tricky. The problem is explained in

If you're looking for how to get around the NSInternalInconsistencyException runtime exception with UICollectionView, I have an example on GitHub detailing how to queue updates from the NSFetchedResultsControllerDelegate.

The problem is that the existing UITableView class uses beginUpdates and endUpdates to submit batches to the table view. UICollectionView has a new performBatchUpdates: method, which takes a block parameter to update the collection view. That's sexy, but it doesn't work well with the existing paradigm for NSFetchedResultsController.

Fortunately, that article also provides a sample implementation:

From the README:

This is an example of how to use the new UICollectionView with NSFetchedResultsController. The trick is to queue the updates made through the NSFetchedResultsControllerDelegate until the controller finishes its updates. UICollectionView doesn't have the same beginUpdates and endUpdates that UITableView has to let it work easily with NSFetchedResultsController, so you have to queue them or you get internal consistency runtime exceptions.

Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • Thanks, Martin. I tried this without the workaround beforehand - did not see the update for the workaround. Now with workaround of the bug in collection view it finally works. Due the fact that I have headers and footers this was a very good help. I hope nevertheless that this bug will be solved once. – aquarius68 Dec 13 '13 at 08:43
  • @aquarius68: It is not really a bug. The problem is that the FRC delegate methods and the collection view update methods do not really fit together. Fixing that would mean to change or extend one of the APIs. - But I am glad that you got it working. – Martin R Dec 13 '13 at 08:50
  • I do not get any more the error messages but it does not yet completely work; i.e. if the user adds the first element it works, but if the user adds the second element it works only if I go back to the table view which contains objects related to the objects of the collection view. – aquarius68 Dec 17 '13 at 18:01
  • @aquarius68: This seems to be a separate problem, so I would suggest to start a new question. Then more people will read it and might be able to help. – Martin R Dec 17 '13 at 18:54
  • Great stuff! Finally, problem solved ... this bug needs to be squashed already!! – DogCoffee Jan 29 '14 at 08:51
  • 2
    Repo is now empty. May you post some code? If possible, Swift? :) –  Jun 26 '18 at 15:54
  • @DaniSpringer: The repo has a link to https://github.com/jessesquires/JSQDataSourcesKit, and there are Swift solutions in the other answers, did you try one of those? – Martin R Jun 26 '18 at 16:50
  • @MartinR , another amazing answer, thanks. I put in an updated version down the bottom. But, notice the question I pose at the end - I just don't know :O – Fattie Feb 03 '20 at 17:19
7

A version for 2020:

Based on the incredible answers above and which matches the familiar Apple example for tables:

Consider the familiar Apple example for table views:

https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/nsfetchedresultscontroller.html#//apple_ref/doc/uid/TP40001075-CH8-SW1

at the heading

"Communicating Data Changes to the Table View" ...

So,

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
    switch type {
    case .insert:
        insertRows(at: [newIndexPath!], with: .fade)
    case .delete:
        deleteRows(at: [indexPath!], with: .fade)
    case .update:
        reloadRows(at: [indexPath!], with: .fade)
    case .move:
        moveRow(at: indexPath!, to: newIndexPath!)
    }
}

.

Here's the "similar pattern" to copy and paste for collection views, with current syntax etc.

var ops: [BlockOperation] = []

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
    switch type {
        case .insert:
            ops.append(BlockOperation(block: { [weak self] in
                self?.insertItems(at: [newIndexPath!])
            }))
        case .delete:
            ops.append(BlockOperation(block: { [weak self] in
                self?.deleteItems(at: [indexPath!])
            }))
        case .update:
            ops.append(BlockOperation(block: { [weak self] in
                self?.reloadItems(at: [indexPath!])
            }))
        case .move:
            ops.append(BlockOperation(block: { [weak self] in
                self?.moveItem(at: indexPath!, to: newIndexPath!)
            }))
        @unknown default:
            break
    }
}

func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    performBatchUpdates({ () -> Void in
        for op: BlockOperation in self.ops { op.start() }
    }, completion: { (finished) -> Void in self.ops.removeAll() })
}

deinit {
    for o in ops { o.cancel() }
    ops.removeAll()
}

.

(I have just left out the "sections" material, which is the same.)

Do nothing in controllerWillChangeContent?

In the magnificent answer by @PhuahYeeKeat , in controllerWillChangeContent the ops array is cleaned out. I may be wrong but there's no reason to do that; it is reliably emptied by the batch updates cycle. Simply do nothing in controllerWillChangeContent.

Is there a race?

I have a concern about what happens if a new didChange arrives while the performBatchUpdates is processing the previous batch.

I really don't know if performBatchUpdates makes a local copy or what - in which case, the global one should be deleted before doing performBatchUpdates ?

IDK.

Fattie
  • 27,874
  • 70
  • 431
  • 719
  • 2
    This works great on iOS 13, while other solutions found online (i.e. [this gist](https://gist.github.com/Sorix/987af88f82c95ff8c30b51b6a5620657), which is a popular Google result) caused my UI to freeze during significant operations such as an iCloud sync on a new device. I am not seeing those freezes here, presumably for how the BlockOperations are handled (I'm not a concurrency expert, but I will report issues if I find any, because CollectionViews with FetchedResultsController surprisingly lack good, iOS 13-updated resources, apart from this answer). Thank you @Fattie! – cdf1982 Jul 27 '20 at 04:42
  • 2
    @cdf1982 - thanks, yes you are perfectly correct. it cost us a FORTUNE in time to develop this solution ...... indeed to solve exactly the problems you outline. And yes it's a great example of where "the example code you often see online" is unfortunately completely incorrect. It's a truly bizarre corner of Apple that they did not build-in a solution, for collection views, to their premier product, CoreData. they give crappy old table views a solution, but not collection views! It's just "one of those strange things" about Apple! Hopefully they do it soon. – Fattie Jul 27 '20 at 11:51
  • 2
    I tried this code. I am getting Exception: "attempt to insert item 0 into section 0, but there are only 0 items in section 0 after the update" on the `collectionView.performBatchUpdates` inside the `didChangeContent` function – Vangola Aug 03 '20 at 20:30
  • 1
    can only proceed with standard debugging, add print statements everywhere to find the problem – Fattie Aug 04 '20 at 12:14
4

2019 Version of Plot's answer:

var blockOperations: [BlockOperation] = []

func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    blockOperations.removeAll(keepingCapacity: false)
}

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
    if type == NSFetchedResultsChangeType.insert {
        print("Insert Object: \(newIndexPath)")

        blockOperations.append(
            BlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.insertItems(at: [newIndexPath!])
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.update {
        print("Update Object: \(indexPath)")
        blockOperations.append(
            BlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.reloadItems(at: [indexPath!])
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.move {
        print("Move Object: \(indexPath)")

        blockOperations.append(
            BlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.moveItem(at: indexPath!, to: newIndexPath!)
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.delete {
        print("Delete Object: \(indexPath)")

        blockOperations.append(
            BlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.deleteItems(at: [indexPath!])
                }
            })
        )
    }
}

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
    if type == NSFetchedResultsChangeType.insert {
        print("Insert Section: \(sectionIndex)")

        blockOperations.append(
            BlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.insertSections(IndexSet(integer: sectionIndex))
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.update {
        print("Update Section: \(sectionIndex)")
        blockOperations.append(
            BlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.reloadSections(IndexSet(integer: sectionIndex))
                }
            })
        )
    }
    else if type == NSFetchedResultsChangeType.delete {
        print("Delete Section: \(sectionIndex)")

        blockOperations.append(
            BlockOperation(block: { [weak self] in
                if let this = self {
                    this.collectionView!.deleteSections(IndexSet(integer: sectionIndex))
                }
            })
        )
    }
}

func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    collectionView!.performBatchUpdates({ () -> Void in
        for operation: BlockOperation in self.blockOperations {
            operation.start()
        }
    }, completion: { (finished) -> Void in
        self.blockOperations.removeAll(keepingCapacity: false)
    })
}

deinit {
    // Cancel all block operations when VC deallocates
    for operation: BlockOperation in blockOperations {
        operation.cancel()
    }

    blockOperations.removeAll(keepingCapacity: false)
}
Phuah Yee Keat
  • 1,572
  • 1
  • 17
  • 17
2

Here's a bit of Swift that works with UICollectionViewController's installsStandardGestureForInteractiveMovement and is a somewhat DRYed up and switches on the installsStandardGestureForInteractiveMovement so that all the code paths are obvious. It's the same overall pattern as Plot's code.

var fetchedResultsProcessingOperations: [NSBlockOperation] = []

private func addFetchedResultsProcessingBlock(processingBlock:(Void)->Void) {
    fetchedResultsProcessingOperations.append(NSBlockOperation(block: processingBlock))
}

func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {

    switch type {
    case .Insert:
        addFetchedResultsProcessingBlock {self.collectionView!.insertItemsAtIndexPaths([newIndexPath!])}
    case .Update:
        addFetchedResultsProcessingBlock {self.collectionView!.reloadItemsAtIndexPaths([indexPath!])}
    case .Move:
        addFetchedResultsProcessingBlock {
            // If installsStandardGestureForInteractiveMovement is on
            // the UICollectionViewController will handle this on its own.
            guard !self.installsStandardGestureForInteractiveMovement else {
                return
            }
            self.collectionView!.moveItemAtIndexPath(indexPath!, toIndexPath: newIndexPath!)
        }
    case .Delete:
        addFetchedResultsProcessingBlock {self.collectionView!.deleteItemsAtIndexPaths([indexPath!])}
    }

}

func controller(controller: NSFetchedResultsController, didChangeSection sectionInfo: NSFetchedResultsSectionInfo, atIndex sectionIndex: Int, forChangeType type: NSFetchedResultsChangeType) {

    switch type {
    case .Insert:
        addFetchedResultsProcessingBlock {self.collectionView!.insertSections(NSIndexSet(index: sectionIndex))}
    case .Update:
        addFetchedResultsProcessingBlock {self.collectionView!.reloadSections(NSIndexSet(index: sectionIndex))}
    case .Delete:
        addFetchedResultsProcessingBlock {self.collectionView!.deleteSections(NSIndexSet(index: sectionIndex))}
    case .Move:
        // Not something I'm worrying about right now.
        break
    }

}

func controllerDidChangeContent(controller: NSFetchedResultsController) {
    collectionView!.performBatchUpdates({ () -> Void in
        for operation in self.fetchedResultsProcessingOperations {
            operation.start()
        }
        }, completion: { (finished) -> Void in
            self.fetchedResultsProcessingOperations.removeAll(keepCapacity: false)
    })
}

deinit {
    for operation in fetchedResultsProcessingOperations {
        operation.cancel()
    }

    fetchedResultsProcessingOperations.removeAll()
}
Jonathan Zhan
  • 1,883
  • 1
  • 14
  • 17