Fetched Results Controller & Diffable Data Source Snapshot Issues
In iOS 13, NSFetchedResultsController
gained support for delivering changes to
its content as a NSDiffableDataSourceSnapshot
via. its delegate. The snapshot
contains the contents of the controller and can be used to populate a table or
collection view complete with animations for content updates.
Unfortunately the API has a few issues. The way it’s bridged from Objective-C to Swift makes using it a bit confusing and it doesn’t match all the functionality of the older delegate methods. In this post I’ll document a couple of the issues I’ve run into and how I’ve worked around them.
Using Snapshots in Swift
The first issue I encountered using the new API is accessing the snapshot from Swift. The documentation declares the signature of the snapshot delegate method as
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshot)
but this isn’t correct as NSDiffableDataSourceSnapshot
has two generic
parameters for the SectionIdentifier
and ObjectIdentifier
that are
missing. The delegate method’s signature actually bridges from Objective-C as:
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference)
The snapshot is an instance of NSDiffableDataSourceSnapshotReference
, a class
that lacks the generic type parameters found on the struct version. By itself
this class seems useless as there aren’t any other APIs that use it and it seems
to only exist to bridge this delegate method. To use the snapshot, you must cast
it to a NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
:
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
// Do something with the snapshot
}
Note that you can put any type for the SectionIdentifier
and ItemIdentifier
parameters and it’ll compile but only String
and NSManagedObjectID
are valid
as can be seen in the declaration of the Objective-C protocol:
- (void)controller:(NSFetchedResultsController *)controller didChangeContentWithSnapshot:(NSDiffableDataSourceSnapshot<NSString *, NSManagedObjectID *> *)snapshot
At this point you’ve got a normal NSDiffableDataSourceSnapshot
that can be
used with a table or collection view. I don’t quite understand why the delegate
method gets bridged this way though since the Objective-C declaration is using
lightweight generics that should bridge across to Swift generics.
Tracking Object Updates
The traditional delegate methods for NSFetchedResultsController
updates
provide notifications of object insertions, updates, moves and deletes. Although
NSDiffableDataSourceSnapshot
supports tracking all these events, the snapshot
provided to NSFetchedResultsController
’s delegate only tracks insertions,
deletions and moves.
If you need to track object updates via. a data source snapshot, you’ll need to
use the traditional delegate methods to build and maintain your own
snapshot. Using Swift’s new CollectionDifference
type this isn’t too hard,
particularly if you only need to deal with one section. Note that you won’t be
able to use the NSManagedObjectID
as an ItemIdentifier
in the snapshot
though for reasons I’ll explain below.
In my situation when dealing with a single section I was able to get away with an implementation like this:
import UIKit
import CoreData
import Combine
/// A singleton section for the snapshot
struct Section: Hashable {
private init() { }
static let main = Section()
}
final class SingleSectionController<Object: NSManagedObject>: NSObject, NSFetchedResultsControllerDelegate {
@Published
var results = NSDiffableDataSourceSnapshot<Section, Object>()
private let resultsController: NSFetchedResultsController<Object>
init(context: NSManagedObjectContext, request: NSFetchRequest<Object>) {
self.resultsController = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context,
sectionNameKeyPath: nil, cacheName: nil)
super.init()
resultsController.delegate = self
}
func fetchData() {
// Handle errors gracefully in production
try! resultsController.performFetch()
generateNewSnapshot()
}
private func generateNewSnapshot() {
var initialSnapshot = NSDiffableDataSourceSnapshot<Section, Object>()
initialSnapshot.appendSections([.main])
if let objects = resultsController.fetchedObjects {
initialSnapshot.appendItems(objects, toSection: .main)
}
self.results = initialSnapshot
}
// MARK: - Fetched Results Controller Delegate
private var transientChanges: [CollectionDifference<Object>.Change] = []
private var updatedObjects: Set<Object> = []
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
transientChanges.removeAll()
updatedObjects.removeAll()
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
guard let object = anObject as? Object else { return }
switch type {
case .insert:
let insertionIndex = newIndexPath!
transientChanges.append(.insert(offset: insertionIndex.row, element: object, associatedWith: nil))
case .update:
updatedObjects.insert(object)
case .move:
let sourceIndex = indexPath!.row
let destinationIndex = newIndexPath!.row
updatedObjects.insert(object)
transientChanges.append(.insert(offset: destinationIndex, element: object, associatedWith: sourceIndex))
transientChanges.append(.remove(offset: sourceIndex, element: object, associatedWith: destinationIndex))
case .delete:
let deletedIndex = indexPath!.row
transientChanges.append(.remove(offset: deletedIndex, element: object, associatedWith: nil))
@unknown default:
fatalError("Unhandled \(NSFetchedResultsChangeType.self) \(type)")
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
guard let collectionDifference = CollectionDifference(transientChanges) else {
// In theory, NSFetchedResultsController should deliver valid changes. In practice, I don't trust it so fall
// back to generating a new snapshot if the changes can't be used as a diff.
assertionFailure("Unable to create a collection difference from the changes \(transientChanges)")
generateNewSnapshot()
return
}
var newSnapshot = self.results
for change in collectionDifference {
switch change {
case .insert(0, let object, _) where newSnapshot.numberOfItems(inSection: .main) == 0:
newSnapshot.appendItems([object], toSection: .main)
case .insert(0, let object, _):
newSnapshot.insertItems([object], beforeItem: newSnapshot.itemIdentifiers(inSection: .main).first!)
case .insert(newSnapshot.itemIdentifiers(inSection: .main).count, let object, _):
newSnapshot.appendItems([object], toSection: .main)
case .insert(let index, let object, _):
let existingItem = newSnapshot.itemIdentifiers(inSection: .main)[index]
newSnapshot.insertItems([object], beforeItem: existingItem)
case .remove(_, let object, _):
newSnapshot.deleteItems([object])
}
}
newSnapshot.reloadItems(Array(updatedObjects))
assert(newSnapshot.itemIdentifiers == resultsController.fetchedObjects ?? [],
"Final snapshots items do not match the FRC's fetched objects")
self.results = newSnapshot
}
}
This tracks insertions, updates, moves and deletions using Swift’s
CollectionDifference
type to handle applying the updates in the correct
order. Compared to the controller(_:didChangeContentWith:)
method, this
implementation does have one downside. As the snapshot’s ItemIdentifier
are
instances of NSManagedObject
, you can’t apply the snapshot from a background
queue (or at least, a different queue to the controller’s context’s queue)
whereas you can when using NSManagedObjectID
s as identifiers.
The Problem with Object IDs
The reason for using NSManagedObject
as the ItemIdentifier
instead of
NSManagedObjectID
is due to how NSFetchedResultsController
handles temporary
object IDs. As soon as an object matching the controller’s fetch request is
inserted into the context, the controller will notify the delegate of an
insertion. At this point the object only has a temporary ID as it hasn’t been
saved to the context’s persistent store yet. If we were using an
NSManagedObjectID
as the snapshot’s ItemIdentifier
, we’d add a temporary ID
to the snapshot at this point.
When the context is saved, the inserted object is given a (different) permanent
object ID. Although we can continue to use the temporary ID to retrieve the
object from its context, the NSManagedObjectID
returned by the object’s
objectID
property will be the new, permanent ID.
Any subsequent updates to the object will be delivered to the results
controller’s delegate as updates or moves. When we try to update the data source
snapshot using the updated object’s NSManagedObjectID
, the data source will
throw an exception because it only contains the temporary object ID, not the
permanent ID.
If you must use NSManagedObjectID
as the data source’s ItemIdentifier
, you
can (kind of) exclude objects with a temporary ID from the results controller by
creating a private, read-only child context of the main context for the results
controller. The child context (and by extension the results controller) will not
know about newly inserted objects until the parent context is saved at which
point the objects will have a permanent ID (assuming the parent context is a
root context).
This setup creates some new problems though. If you need to merge changes into
the parent context from another context (say a background context), the merged
changes won’t propagate to the child context automatically, even if
automaticallyMergesChangesFromParent
is true
for the child context.