How to get a diffable snapshot from an NSFetchResultsController in iOS 13?

Update 2: iOS 14b2 an object delete appears in the snapshot as a delete and insert and the cellProvider block is called 3 times! (Xcode 12b2).

Update 1: animatingDifferences:self.view.window != nil seems a good trick to fix first time vs other times animation problem.

Switching to the fetch controller snapshot API requires many things but to answer your question first, the delegate method is simply implemented as:

- (void)controller:(NSFetchedResultsController *)controller didChangeContentWithSnapshot:(NSDiffableDataSourceSnapshot<NSString *,NSManagedObjectID *> *)snapshot{
    [self.dataSource applySnapshot:snapshot animatingDifferences:!self.performingFetch];
}

As for the other changes, the snapshot must not contain temporary object IDs. So before you save a new object you must make it have a permanent ID:

- (void)insertNewObject:(id)sender {
    NSManagedObjectContext *context = [self.fetchedResultsController managedObjectContext];
    Event *newEvent = [[Event alloc] initWithContext:context];//
        
    // If appropriate, configure the new managed object.
    newEvent.timestamp = [NSDate date];
    
    NSError *error = nil;
    if(![context obtainPermanentIDsForObjects:@[newEvent] error:&error]){
        NSLog(@"Unresolved error %@, %@", error, error.userInfo);
         abort();
    }
    
    if (![context save:&error]) {
        // Replace this implementation with code to handle the error appropriately.
        // abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
        NSLog(@"Unresolved error %@, %@", error, error.userInfo);
        abort();
    }
}

You can verify this worked by putting a breakpoint in the snapshot delegate and inspect the snapshot object to make sure it has no temporary IDs in it.

The next issue is that this API is very odd in that it is not possible to get the initial snapshot from the fetch controller to use to fill the table. The call to performFetch calls the delegate inline with the first snapshot. We are not used to our method calls resulting in delegate calls and this is a real pain because in our delegate we would like to animate the updates not the initial load, and if we do animate the initial load then we see a warning that the table is being updated without being in a window. The workaround is to set a flag performingFetch, make it true before performFetch for the initial snapshot delegate call and then set it false after.

Lastly, and this is by far the most annoying change because we no longer can update the cells in the table view controller, we need to break MVC slightly and set our object as a property on a cell subclass. The fetch controller snapshot is only the state of the sections and rows using arrays of object IDs. The snapshot has no concept of versions of the objects thus it cannot be used for updating current cells. Thus in the cellProvider block we do not update the cell's views only set the object. And in that subclass we either use KVO to monitor the keys of the object that the cell is displaying, or we could also subscribe to the NSManagedObjectContext objectsDidChange notification and examine for changedValues. But essentially it is now the cell class's responsibility to now update the subviews from the object. Here is an example of what is involved for KVO:

#import "MMSObjectTableViewCell.h"

static void * const kMMSObjectTableViewCellKVOContext = (void *)&kMMSObjectTableViewCellKVOContext;

@interface MMSObjectTableViewCell()

@property (assign, nonatomic) BOOL needsToUpdateViews;

@end

@implementation MMSObjectTableViewCell

- (instancetype)initWithCoder:(NSCoder *)coder
{
    self = [super initWithCoder:coder];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier
{
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (void)commonInit{
    _needsToUpdateViews = YES;
}

- (void)awakeFromNib {
    [super awakeFromNib];
    // Initialization code
}

- (void)setSelected:(BOOL)selected animated:(BOOL)animated {
    [super setSelected:selected animated:animated];

    // Configure the view for the selected state
}

- (void)setCellObject:(id<MMSCellObject>)cellObject{
    if(cellObject == _cellObject){
        return;
    }
    else if(_cellObject){
        [self removeCellObjectObservers];
    }
    MMSProtocolAssert(cellObject, @protocol(MMSCellObject));
    _cellObject = cellObject;
    if(cellObject){
        [self addCellObjectObservers];
        [self updateViewsForCurrentFolderIfNecessary];
    }
}

- (void)addCellObjectObservers{
    // can't addObserver to id
    [self.cellObject addObserver:self forKeyPath:@"title" options:0 context:kMMSObjectTableViewCellKVOContext];
    // ok that its optional
    [self.cellObject addObserver:self forKeyPath:@"subtitle" options:0 context:kMMSObjectTableViewCellKVOContext];
}

- (void)removeCellObjectObservers{
    [self.cellObject removeObserver:self forKeyPath:@"title" context:kMMSObjectTableViewCellKVOContext];
    [self.cellObject removeObserver:self forKeyPath:@"subtitle" context:kMMSObjectTableViewCellKVOContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if (context == kMMSObjectTableViewCellKVOContext) {
        [self updateViewsForCurrentFolderIfNecessary];
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (void)updateViewsForCurrentFolderIfNecessary{
    if(!self.window){
        self.needsToUpdateViews = YES;
        return;
    }
    [self updateViewsForCurrentObject];
}

- (void)updateViewsForCurrentObject{
    self.textLabel.text = self.cellObject.title;
    if([self.cellObject respondsToSelector:@selector(subtitle)]){
        self.detailTextLabel.text = self.cellObject.subtitle;
    }
}

- (void)willMoveToWindow:(UIWindow *)newWindow{
    if(newWindow && self.needsToUpdateViews){
        [self updateViewsForCurrentObject];
    }
}

- (void)prepareForReuse{
    [super prepareForReuse];
    self.needsToUpdateViews = YES;
}

- (void)dealloc
{
    if(_cellObject){
        [self removeCellObjectObservers];
    }
}

@end

And my protocol that I use on my NSManagedObjects:

@protocol MMSTableViewCellObject <NSObject>

- (NSString *)titleForTableViewCell;
@optional
- (NSString *)subtitleForTableViewCell;

@end

Note I implement keyPathsForValuesAffectingValueForKey in the managed object class to trigger the change when a key used in the string changes.


As others have pointed out, a UITableView will load as blank if animatingDifferences: true is used when table is first loaded.

And animatingDifferences: true will not force a reload of a cell if underlying model data changes.

This behavior seems like a bug.

Even worse is a full app crash when uitableview is in editMode and user attemps to delete a record using trailingSwipeActionsConfigurationForRowAt

My workaround is simply to set animatingDifferences to 'false' in all cases. The bummer of course is that all animations are lost. I filed a bug report with Apple for this issue.

 func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
                let newSnapshot = snapshot as NSDiffableDataSourceSnapshot<String,NSManagedObjectID>
                
   self.apply(newSnapshot, animatingDifferences: false)} //setting animatingDifferences to 'false' is the only work around I've found for table cells not appearing on load, and other bugs, including crash if user tries to delete a record.


                
            }

The diffable data source should be declared with generic types String and NSManagedObjectID. Now you can cast the reference to a snapshot:

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
    let snapshot = snapshot as NSDiffableDataSourceSnapshot<String,NSManagedObjectID>
    self.ds.apply(snapshot, animatingDifferences: false)
}

This leaves open the question of how you're going to populate the cell. In the diffable data source (self.ds in my example), when you populate the cell, return to the fetched results controller and fetch the actual data object.

For example, in my table view I am displaying the name of a Group in each cell:

lazy var ds : UITableViewDiffableDataSource<String,NSManagedObjectID> = {
    UITableViewDiffableDataSource(tableView: self.tableView) {
        tv,ip,id in
        let cell = tv.dequeueReusableCell(withIdentifier: self.cellID, for: ip)
        cell.accessoryType = .disclosureIndicator
        let group = self.frc.object(at: ip)
        cell.textLabel!.text = group.name
        return cell
    }
}()

The WWDC video implies that we should declare the data source with generic types of String and NSManagedObjectID. That is not working for me; the only way I can get sensible behaviour with animations and row updates is by using a custom value object as the row identifier for the data source.

The problem with a snapshot using NSManagedObjectID as the item identifier is that, although the fetched results delegate is notified of changes to the managed object associated with that identifier, the snapshot that it vends may be no different from the previous one that we might have applied to the data source. Mapping this snapshot onto one using a value object as the identifier produces a different hash when underlying data changes and solves the cell update problem.

Consider a data source for a todo list application where there is a table view with a list of tasks. Each cell shows a title and some indication of whether the task is complete. The value object might look like this:

struct TaskItem: Hashable {
    var title: String
    var isComplete: Bool
}

The data source renders a snapshot of these items:

typealias DataSource = UITableViewDiffableDataSource<String, TaskItem>

lazy var dataSource = DataSource(tableView: tableView) { tableView, indexPath, item in {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    cell.textLabel?.text = item.title
    cell.accessoryType = item.isComplete ? .checkmark : .none
    return cell
}

Assuming a fetched results controller, which may be grouped, the delegate is passed a snapshot with types of String and NSManagedObjectID. This can be manipulated into a snapshot of String and TaskItem (the value object used as row identifier) to apply to the data source:

func controller(
    _ controller: NSFetchedResultsController<NSFetchRequestResult>,
    didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
    // Cast the snapshot reference to a snapshot
    let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>

    // Create a new snapshot with the value object as item identifier
    var mySnapshot = NSDiffableDataSourceSnapshot<String, TaskItem>()

    // Copy the sections from the fetched results controller's snapshot
    mySnapshot.appendSections(snapshot.sectionIdentifiers)

    // For each section, map the item identifiers (NSManagedObjectID) from the
    // fetched result controller's snapshot to managed objects (Task) and
    // then to value objects (TaskItem), before adding to the new snapshot
    mySnapshot.sectionIdentifiers.forEach { section in
        let itemIdentifiers = snapshot.itemIdentifiers(inSection: section)
            .map {context.object(with: $0) as! Task}
            .map {TaskItem(title: $0.title, isComplete: $0.isComplete)}
        mySnapshot.appendItems(itemIdentifiers, toSection: section)
    }

    // Apply the snapshot, animating differences unless not in a window
    dataSource.apply(mySnapshot, animatingDifferences: view.window != nil)
}

The initial performFetch in viewDidLoad updates the table view with no animation. All updates thereafter, including updates that just refresh a cell, work with animation.