1

I'm trying to use NSFetchController with a CollectionView. My problem is when I change a property value for my NSManagedObject, my cells are not updating correctly. Here is a link to better show what I am talking about. Okay, so if you watch the video you can see basically what the app does. It has a bunch of hero's and when you click a cell it goes to another view controller and shows a picture, name and publisher. Now you were also able to see the problem, when I changed the name too 'Amanda Doe' and saved it. It was inserted in the right place at the beginning, but the cell label wasn't updated.

When I click the cell again it shows Amanda Doe on the navigation title, but the cell label is still the same. In order for the name to show I have to restart the app or click the same cell and just click save again.

This is how my NSFetchResultController looks like. I sort by the Publisher name then by the Hero name. It also has multiple sections based on the publisher.

private var blockOperations: [BlockOperation] = []

lazy var heroController: NSFetchedResultsController<Hero> = {
    let request: NSFetchRequest<Hero> = Hero.fetchRequest()
    let nameSort = NSSortDescriptor(key: "name", ascending: true)
    let publisherSort = NSSortDescriptor(key: "publisher.name", ascending: true)
    request.sortDescriptors = [publisherSort, nameSort]

    let context = CoreDataManager.shared.persistentContainer.viewContext
    let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: "publisher.name", cacheName: nil)
    controller.delegate = self

    do {
        try controller.performFetch()
    } catch let err {
        print("Unable to fetch heros from controller.", err)
    }

    return controller
}()

This code here is basically the boilerplate code that I found that most people use to get the NSFetchResultController to work with the CollectionView.

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

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {

    let op: BlockOperation

    switch type {
    case .delete:
        op = BlockOperation { self.collectionView.deleteSections(IndexSet(integer: sectionIndex))}
        blockOperations.append(op)
    case .insert:
        op = BlockOperation { self.collectionView.insertSections(IndexSet(integer: sectionIndex))}
        blockOperations.append(op)
    case .move:
        break
    case .update:
        op = BlockOperation { self.collectionView.reloadSections(IndexSet(integer: sectionIndex))}
        blockOperations.append(op)
    @unknown default:
        fatalError()
    }
}

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]) }
    @unknown default:
        fatalError()
    }

    blockOperations.append(op)
}

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

So am I missing something, is it because I am missing .move case on the section? I was playing around with the app and found out if the cell doesn't move or have to move to a new index path it works fine. Why is that? Would really appreciate any help. Thanks.

EDIT 1

After playing around with the code some more, I came across another problem. Problem 1 is as you seen in the video. Cell is not getting updated correctly and I don't know why. I tried to debug it as @pbasdf suggested and found out this. First when I change the name to 'Amanda Doe' it triggers the didChangeAnObject: .move case twice but never the didChangeAnObject: .update. When I click on the same cell again and change the name to 'Amanda Doee' it calls didChangeAnObject: .update and updates the cell with the correct name. So basically if the didChangeAnObject: .move case is called the cell doesn't update and if the didChangeAnObject: .update case is called it updates the cell with the correct name.

Problem 2 is when I change the publisher name. So for now the name is 'Amanda Doee' and publisher name 'ABC Studios'. Now if I just change the publisher name to 'ABC Studioss' it should move the cell to a new section called 'ABC Studioss' because of my NSFetchResultController. The thing is it doesn't do that it stays in the same position and it calls the didChangeAnObject: .update case.

Now I found a way that it works. In order for it to work I need to change the Name & Publisher. It won't work if I just change the publisher. So I would have to change 'Amanda Doee' to 'Amanda Doeee' and 'ABC Studios' to 'ABC Studioss'. This is how the methods get called when I change the name and publisher and save it. didChangeSectionInfo: .insert, didChangeAnObject: .move, didChangeSectionInfo: .insert, didChangeAnObject: .move. Now if I was to just change the name of the publisher this only gets called didChangeAnObject: .update. I would have to relaunch my app to see the changes with a new section called 'ABC Studioss' with 'Amanda Doee'.

So I really don't know what is going on & why it is behaving like this.

Edit 2

Here is the code I use in the other VC to save edits or create a new hero.

@objc private func handleSave() {
    let context = CoreDataManager.shared.persistentContainer.viewContext
    if let hero = hero {

        hero.name = nameTF.text ?? ""
        hero.publisher?.name = publisherTF.text

        do {
            try context.save()
        }
        catch let err {
            print("Unable to save hero edits.", err)
        }
    }
    else {
        print("Create new hero.")

        let hero = Hero(context: context)
        hero.name = nameTF.text
        let publisher = Publisher(context: context)
        publisher.name = publisherTF.text
        hero.publisher = publisher

        guard let imageData = heroImageView.image?.jpegData(compressionQuality: 1) else {
            try? context.save()
            return
        }

        let photo = Photo(context: context)
        photo.data = imageData
        hero.photo = photo

        try? context.save()
    }
    navigationController?.popViewController(animated: true)
}
Luis Ramirez
  • 966
  • 1
  • 8
  • 25
  • I suspect this is because you are using block operations in your delegate methods. Any reason why you chose to go with that approach? You shouldn't need to do that. – Rob C Feb 24 '20 at 08:55
  • Can you debug to track which changeTypes get generated by the FRC each time? I suspect that for each change it generates *either* .move (if the name/publisher.name changes) *or* .update otherwise. With the first change (.move) your code only moves the item, it doesn’t reload it. The second change triggers the .update which reloads the item. If that’s the case, either add a reload to the block operation for the .update case, or some people implement .update as a deleteItem/insertItem instead. – pbasdf Feb 24 '20 at 09:29
  • @Rob I chose this approach because I see most people use it. Its because a collectionView doesn't have beginUpdates or endUpdates. – Luis Ramirez Feb 24 '20 at 14:51
  • @pbasdf So I tried to debug and track what case is getting called by setting break points. I found out another problem, check out the edited I added to my question. – Luis Ramirez Feb 24 '20 at 16:39
  • I see from your `sectionNameKeyPath` ("publisher.name") that you have a relationship to a separate entity for the Publisher details. That explains the behaviour regarding changes to the publisher name: the FRC only observes changes to the Hero objects (see [here](https://stackoverflow.com/a/12379824/3985749) for a good explanation). But I don't know why you are getting multiple calls after a single change. Can you post your update code? – pbasdf Feb 25 '20 at 12:22
  • @pbasdf I didn't change the code. The code is the same, I put break points in all the cases to see what was getting called. – Luis Ramirez Feb 25 '20 at 15:41
  • Sorry, I meant the code that does the updates - ie in the other view controller. – pbasdf Feb 25 '20 at 16:26
  • @pbasdf Okay, I added the code that creates or save the edits of a hero. – Luis Ramirez Feb 25 '20 at 18:13
  • I can understand problem #2. When you observe changes to a set of parent objects, and update one of the child objects, the delegate will not fire with `.update`. The link that @pbasdf posted explains it beautifully. As for the first case, I don't know why it's happening, but what I would do is reconfigure your cell when a you get a `.move` notification. – Rob C Feb 26 '20 at 04:45
  • @Rob how would I reconfigure my cell when the .move gets called? Also the link that pbasdf provided does explain my second problem I'm facing. – Luis Ramirez Feb 26 '20 at 05:09
  • 1
    Dude, I finally realized your problem. Forget what I said about reconfiguring your cell. That's a hack. When you get a `.move` notification, instead of calling `collectionView.moveItem` call `collectionView.deleteItems` with `indexPath ` and then call `collectionView.insertItems` with `newIndexPath`. If you do a google search for how to handle `.move` you'll see that that is how most people implement `.move`. This one applies to table views but you get the idea: https://cocoacasts.com/exploring-the-fetched-results-controller-delegate-protocol – Rob C Feb 26 '20 at 05:26
  • @Rob I can kiss you right now. Yeah that did it. Thanks. Now I just got to re-read the link that pbasdf provided. Thanks, both of you. :) – Luis Ramirez Feb 26 '20 at 05:41
  • Cool man. I'm actually surprised you are getting `didChangeAnObject: .update` notification when you only change the name of the publisher. Typically notifications don't trigger for a parent object if you make a change to the child object in the relationship. Perhaps because of `sectionNameKeyPath`. In any case, the most common workaround I know is to add (or simply modify an existing property) on the parent object when you modify a property on the child object. What I would do if I were you is add a `publisherName` property to your `Hero` object and keep that in sync with the `Publisher` name. – Rob C Feb 26 '20 at 06:52

0 Answers0