3

As of iOS 13, the easiest way to keep a UITableView in sync with a NSFetchedResultsController seems to be with snapshots.

The NSFetchedResultsController vends a snapshot reference to its delegate whenever the managedObjectContext reports additions, deletions, or updates. When using snapshots (NSDiffableDataSourceSnapshot), there is only one FRC delegate method that needs to be implemented: controller(_:didChangeContentWith:). In order to make that delegate method work, the UITableViewDiffableDataSource and the Snapshot has to be typed <String, NSManagedObjectID>.

It works mostly.

But what if the entire table needs to be updated? Using tableView.reloadData() or frc.performFetch() seems anti-pattern.

edit

I manually built a snapshot, and call apply when necessary. But since my snapshot is based on NSFetchedResultsSectionInfo objects, it seems like I'm duplicating what the FRC already has available: Hashable section titles, and Hashable NSManagedObjectIDs

Small Talk
  • 747
  • 1
  • 6
  • 15
  • SmallTalk, I don't understand your question: UITableView and NSFetchedResultsController work absolutely flawlessly. (Of course they do, or many major apps would just collapse :-) ) I'm confused about why you are even using snapshots? Why? For what reason? Can you help me unnderstand? – Fattie Jun 15 '20 at 02:03
  • Hi Fattie, thx for upvote. My question is: what’s the best practice to generate a new ```NSDiffableDataSourceSnapshot``` if the entire UITableView needs to be updated. I was hoping I could 'grab it' from the FRC. The FRC is now able to vend a NSDiffableDataSourceSnapshotReference. But it appears I have to manually build by own snapshot. Since my snapshot is built from NSFetchedResultsSectionInfo objects, it seems like I'm duplicating what the FRC already has available: hashable section titles, and hashable NSManagedObjectIDs – Small Talk Jun 15 '20 at 17:47
  • I'm using Snapshots because I'm able to eliminate ~6 FRC delegate methods, in favor of just one. As Apple's documentation for 'controller(_:didChangeContentWith:) says: 'If this method is implemented, no other delegate methods are invoked.' So UITableViewDiffableDataSource+UITableView is super-sexy, has nice animations, and eliminates tons of boilerplate code. But I'm still trying to understand why I have to 'roll-my-own' snapshot, if the FRC can generate one all by itself. – Small Talk Jun 15 '20 at 17:55
  • 1
    smalltalk @smallTalk, thanks a lot for that info - I am going to investigate thoroughly everything you have said !! – Fattie Jun 15 '20 at 18:30

2 Answers2

2

I apologize for my previous (deleted) answer. The snapshot is irrelevant in a Core Data context.

The purpose of NSFetchedResultsController in conjunction with Core Data is to update the UI when the NSManagedObjectContext is saved.

To be able to control the animation of the diffable data source (to work around the ridiculous behavior) you have to subclass UITableViewDiffableDataSource and add a property animatingDifferences. Further adopt NSFetchedResultsControllerDelegate in the subclass (not in the view controller).

class DiffableCoreDataSource: UITableViewDiffableDataSource<String,NSManagedObjectID> {
    var animatingDifferences = false
}

extension DiffableCoreDataSource : NSFetchedResultsControllerDelegate
{
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
        apply(snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>, animatingDifferences: animatingDifferences)
        animatingDifferences = true // set it to the default
    }
}

In the view controller set the delegate of the FRC to the subclass assuming there is a dataSource property representing DiffableCoreDataSource

frc.delegate = dataSource

If a record is updated set dataSource.animatingDifferences to false right before saving the context.

To reload the entire table view call frc.performFetch(). Never call reloadData() on the table view.

vadian
  • 274,689
  • 30
  • 353
  • 361
  • Hi Vadian, Thanks for taking the time to answer. I will experiment with this. The last sentence is really what I'm after; if ```performFetch()``` is functionally equivalent to creating one's own ```NSDiffableDataSourceSnapshot``` and applying it when the entire table needs to be updated, then this will be the accepted answer. (It seems like a non-intuitive approach to managing snapshots in conjunction with CoreData and an FRC. But this may be what Apple is currently offering us.) – Small Talk Jun 21 '20 at 11:51
  • “Never call reloadData() on the table view.” I can’t agree with that. Reloading the data is the only way to make the table display changes in a cell’s content that (because of how Hashable/Equatable are defined) do not constitute a “different” cell. The solution here is not coping with the possibility that a record was changed and another was created/deleted, at the same time. See https://stackoverflow.com/questions/62267256/swift-diffabledatasource-make-insertdelete-instead-of-reload for example. – matt Jun 21 '20 at 16:54
  • @matt What does `reloadData()` in a `UITableViewDiffableDataSource` driven context? There is no `cellForRowAt` in a standard implementation – vadian Jun 21 '20 at 17:02
  • @vadian That is what my answer to the linked question demonstrates. – matt Jun 21 '20 at 17:27
  • @matt I understand but with Core Data and NSFetchedResultsControllerDelegate you usually don't `apply` the snapshot yourself. The context notifies the FRC about the changes which calls the delegate method. In my opinion it's more efficient to control the `animatingDifferences` parameter than reloading the table view. – vadian Jun 21 '20 at 17:38
  • Unrelated. The reason for calling `reloadData` is that the snapshot has already been applied but the changes are not yet showing up. — Hey, I'm not the one who misdesigned diffable data sources. I'm just looking for workarounds that make it usable, same as you. – matt Jun 21 '20 at 17:40
  • I probably agree with @matt in this discussion: my thinking is (1) I don't see any general reason one wouldn't "use reloadData()" in a coreData context; I do it all the time. After all it's just a table view. core data can reload it, or, you can reload it. Or something else can reload it! core data is the most amazing deeply flawed system in all of modern software, sometimes you have to reload (2) in the specific situation at hand you do seem to need to reloadData? (I think :O ) – Fattie Jun 21 '20 at 19:45
2

TL;DR: Even though the NSFetchedResultsController is able to furnish its delegate with an up-to-date snapshot reference when the managedObjectContext reports additions, deletions, and updates, it's not possible (for now at least) to programmatically access a snapshot directly from the FRC.

As vadian suggests above, use the FRC instance method performFetch() to apply the newest snapshot to all cells in a DiffableData-backed UITableview. This is an undocumented approach to snapshot management. But it enables the use of FRC-provided snapshots solely, instead of having to write a stand-alone snapshot. Less code, and a 'single-source-of-truth' for snapshot updates.

Based on feedback from others, the drawback is that animatingDifferences argument in apply(_: animatingDifferences:) method triggers bugs if set to true. Two reproduceable bugs are that the table fails to load at all when the snapshot is applied the first time. The other bug is app crash if attempting to delete a record using trailingSwipeActionsConfigurationForRowAt method, and perhaps others. So the only way to use this backdoor approach is to set that bool to false in all cases. Which means, well, no animations.

Small Talk
  • 747
  • 1
  • 6
  • 15
  • Small Talk Thanks for this snippet. Now, to update cell, I'm using NSManagedObjectContext. I hope this approach is correct: let model = self.viewContext.object(with: objectID) as! MyModel cell.configureCell(with: model, animated: false) – Master AgentX Jun 30 '20 at 17:23
  • Hi Master AgentX, I'd post this as separate question, if you get bogged down. 1) as I understand, objectIDs are not made permanent until the MOC ```save``` operation, so make sure save has been called before you configure any cell. Second, the ```UITableViewDiffableDataSource``` init method vends the managed object directly so I'd recommend that path. See https://wwdcbysundell.com/2019/diffable-data-sources-first-look/. (In this case, the managed object found in the init closure is from the 'secret' NSFetchedResultsController ```snapshot``` generated in ```performFetch()```, as above. – Small Talk Jun 30 '20 at 21:39