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

5

13

So here we are in WWDC 2019 video 230, and starting at about minute 14 it is claimed that NSFetchedResultsController now vends an NSDiffableDataSourceSnapshot, so we can just apply it directly to a diffable data source (UITableViewDiffableDataSource).

But this is not quite what they say, or what we get. What we get, in the delegate method controller(_:didChangeContentWith:), is an NSDiffableDataSourceReference. How do we get from this to an actual snapshot, and what should my diffable data source generic types be?

Dessert answered 20/10, 2019 at 17:27 Comment(0)
D
11

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
    }
}()
Dessert answered 20/10, 2019 at 17:27 Comment(22)
Seems like this method is called when an object is inserted, deleted, moved or changed; but that applying the snapshot when the object is just changed, and not inserted, deleted or moved, does not cause the cell to be reloadedTaryntaryne
@Taryntaryne From my experience the cell gets reloaded when the object is just changed if animatingDifferences is set to false.Browbeat
@Browbeat This is crazy. I just came back to this and confirmed what you have said. When dealing with a NSFetchedResultsController, I could be updating, inserting, deleting or moving at any given time. If I want to the snapshot updates to be applied, I need to set animatingDifferences to false - doesn't that just completely defeat the point of the diffable datasource? How have you handled this?Taryntaryne
@Taryntaryne I'm using a compromise. I added a property var animatingDifferences = false in the controller because I don't want to animate the initial loading of the data. In the didChangeContentWith snapshot delegate method I set animatingDifferences:animatingDifferences and set animatingDifferences afterwards to true. Whenever a row is edited I set the property explicitly to false before saving the context. So the table view is basically animated except the first reload and a reload after editing a row.Browbeat
Thanks. I have a super-complicated app, so it seems like it will take a lot of manual work like you have done, plus more, to get this to work. Which renders it basically useless to me. I believe diffable datasources are still not ready for primetime, especially as comes to NSFetchedResultsControllers. There is not even one working example online that I can find that has multiple sections in the FRC. I wish they had taken the time to get this right before releasing it.Taryntaryne
@Taryntaryne I've updated two projects with DiffableDataSource, one of them with Core Data and multiple sections and custom header views. It works great however to get finer control you have to subclass UITableViewDiffableDataSource and animating the initial loading of the table view causes problems. I even wrote a protocol extension to include didChangeContentWith snapshot and to infer the generic type of the fetch results controller.Browbeat
@Browbeat I was able to handle the question of keeping the initial load non-animated. It’s that I basically want all updates/moves/inserts/deletes that come from the FRC to be animated (understanding that updates generally aren’t animated - so they would appear as reloads). The issue is now I have to figure out what exactly has changed each time the FRC delegate methods are called - is this an update? Use false for animatingDifferences. Is this a delete/insert/move? Use true. This is absolutely ridiculous and a HUGE bug if you ask me.Taryntaryne
I totally agree, it's absolutely ridiculous but at the moment it's the only way to get the desired behavior. Feel free to file a bug, so will I.Browbeat
This article addresses some of these issues and I think it may end up being very helpful. I would like to hear if @Dessert has any thoughts on using NSManagedObject as ItemIdentifier, as opposed to using NSManagedObjectID, for the reasons stated in the article (I have experienced problems because of this temporary ID myself, so I switched to using objects). Seems like using objects and abandoning the didChangeContentWith snapshot method altogether might end up being the most stable and comprehensive solution for integrating NSFetchedResultsController with diffable datasources.Taryntaryne
alexj.org/01/nsfetchedresultscontroller-diffable-datasourceTaryntaryne
@Taryntaryne There is certainly no requirement that your diffable data source be of type <String,NSManagedObjectID>. You can use some other type as long as you're willing to translate to that type from what the didChangeContentWith method gives you. It's your data source and you are free to store the data in any form you like. Of course you could also abandon didChangeContentWith altogether but that would be outside the scope of this question; all I was trying to do here was figure out how to do what the WWDC video said to do.Dessert
JFYI Calling apply(...) with animatingDifferences: false simply calls reloadData() under the hood. So you can't rely on "update" logic in this case.Stove
@Taryntaryne use obtainPermanentIDsForObjects before the save. Verify that the snapshot only has permanent IDs before applying.Unstick
@Unstick that could be such an important thing to know that it deserves its own answer!Dessert
@Dessert ok I added an answer with everything I know so farUnstick
@Unstick thanks, I upvoted it to give it some traction, let's see what happens over timeDessert
Is there a way to access the NSDiffableDataSourceSnapshot<String,NSManagedObjectID> directly from the fetched results controller? (At the moment I'm using the hack of calling performFetch(), which behind the scenes apparently generates and applies a new snapshot.Asante
@Dessert How can I get access to the Item section in the DataSource cell provider closure ? As I want to configure different cell based on the type of the section it belongs to. Accessing the FRC property in the cell provider closure causes crash sometimes when animating the changesHeteroousian
@NikhilMuskur: You have to obtain the managed object like guard let aweNamino = try? managedObjectContext.existingObject(with: objectID) as? SomeManagedObject else { fatalError("Managed object should be available") }Veii
Hi matt, may I know, by using NSManagedObjectID as data source's ItemIdentifierType, how does the diffable framework able to detect content change? By using NSManagedObjectID, I can understand how diffable framework able to detect add/ delete/ move operation. But I can't visualise how it detect "content change". Thanks.Dallas
Its better to keep ItemIdentifierType as simple as possible. Ref donnywals.com/modern-table-views-with-diffable-data-sourcesKimble
@SAHM: The best Solution is in the last post. It also detects changes. https://mcmap.net/q/846440/-how-to-get-a-diffable-snapshot-from-an-nsfetchresultscontroller-in-ios-13Veii
H
16

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.

Howey answered 18/8, 2020 at 22:16 Comment(9)
Thoughtful, fine presentation. Thank you for putting so much work into this. I wonder whether we can make this safer by eliminating the exclamation marks, but that's just a matter of style.Dessert
@Dessert Thanks matt. I've updated the delegate method example with some comments and improved layout. The documentation recommends casting the NSDiffableDataSourceSnapshotReference to NSDiffableDataSourceSnapshot and doing that eliminated a couple of the force casts. One force cast remains and personally I'm comfortable with that one as it is tied to the type of the fetch request. Others may prefer to eliminate it.Howey
Thanks for the demonstration. Tried it and it did work. The row refreshed itself when property changed. But the animation was jarring. I think it's better to manipulate the row for better user experience.Kiki
@francisfeng: I generally set the default row animation to fade, e.g. dataSource.defaultRowAnimation = .fade in viewDidLoad.Howey
@Howey How can I get access to the Item section in the DataSource cell provider closure ? As I want to configure different cell based on the type of the section it belongs to. Accessing the FRC property in the cell provider closure causes crash sometimes when animating the changesHeteroousian
You could put it into the value object. That might mean adding a simple section number property, or a property with an enum type that indicates the type of cell you want to configure. You might also want to look at section snapshots, new in iOS13, and the associated "modern" cell configuration. developer.apple.com/videos/all-videos/?q=collectionHowey
@francisfeng: I think there is a solution with working animations. I describe the solution in this post. https://mcmap.net/q/846440/-how-to-get-a-diffable-snapshot-from-an-nsfetchresultscontroller-in-ios-13Veii
While this is is the first solution that correctly deals with updates in NSManagedObjects that I have seen, I suspect this result in bad performance due to de-faulting ALL objects.Uneven
I've just updated the struct for better performance and less loops at least for my work. Use NSManagedObjectID as id parameter in a struct. Like this let itemIdentifiers = snapshot.itemIdentifiers(inSection: section).map {ProjectIdentifier(id: "\($0)")}Footing
D
11

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
    }
}()
Dessert answered 20/10, 2019 at 17:27 Comment(22)
Seems like this method is called when an object is inserted, deleted, moved or changed; but that applying the snapshot when the object is just changed, and not inserted, deleted or moved, does not cause the cell to be reloadedTaryntaryne
@Taryntaryne From my experience the cell gets reloaded when the object is just changed if animatingDifferences is set to false.Browbeat
@Browbeat This is crazy. I just came back to this and confirmed what you have said. When dealing with a NSFetchedResultsController, I could be updating, inserting, deleting or moving at any given time. If I want to the snapshot updates to be applied, I need to set animatingDifferences to false - doesn't that just completely defeat the point of the diffable datasource? How have you handled this?Taryntaryne
@Taryntaryne I'm using a compromise. I added a property var animatingDifferences = false in the controller because I don't want to animate the initial loading of the data. In the didChangeContentWith snapshot delegate method I set animatingDifferences:animatingDifferences and set animatingDifferences afterwards to true. Whenever a row is edited I set the property explicitly to false before saving the context. So the table view is basically animated except the first reload and a reload after editing a row.Browbeat
Thanks. I have a super-complicated app, so it seems like it will take a lot of manual work like you have done, plus more, to get this to work. Which renders it basically useless to me. I believe diffable datasources are still not ready for primetime, especially as comes to NSFetchedResultsControllers. There is not even one working example online that I can find that has multiple sections in the FRC. I wish they had taken the time to get this right before releasing it.Taryntaryne
@Taryntaryne I've updated two projects with DiffableDataSource, one of them with Core Data and multiple sections and custom header views. It works great however to get finer control you have to subclass UITableViewDiffableDataSource and animating the initial loading of the table view causes problems. I even wrote a protocol extension to include didChangeContentWith snapshot and to infer the generic type of the fetch results controller.Browbeat
@Browbeat I was able to handle the question of keeping the initial load non-animated. It’s that I basically want all updates/moves/inserts/deletes that come from the FRC to be animated (understanding that updates generally aren’t animated - so they would appear as reloads). The issue is now I have to figure out what exactly has changed each time the FRC delegate methods are called - is this an update? Use false for animatingDifferences. Is this a delete/insert/move? Use true. This is absolutely ridiculous and a HUGE bug if you ask me.Taryntaryne
I totally agree, it's absolutely ridiculous but at the moment it's the only way to get the desired behavior. Feel free to file a bug, so will I.Browbeat
This article addresses some of these issues and I think it may end up being very helpful. I would like to hear if @Dessert has any thoughts on using NSManagedObject as ItemIdentifier, as opposed to using NSManagedObjectID, for the reasons stated in the article (I have experienced problems because of this temporary ID myself, so I switched to using objects). Seems like using objects and abandoning the didChangeContentWith snapshot method altogether might end up being the most stable and comprehensive solution for integrating NSFetchedResultsController with diffable datasources.Taryntaryne
alexj.org/01/nsfetchedresultscontroller-diffable-datasourceTaryntaryne
@Taryntaryne There is certainly no requirement that your diffable data source be of type <String,NSManagedObjectID>. You can use some other type as long as you're willing to translate to that type from what the didChangeContentWith method gives you. It's your data source and you are free to store the data in any form you like. Of course you could also abandon didChangeContentWith altogether but that would be outside the scope of this question; all I was trying to do here was figure out how to do what the WWDC video said to do.Dessert
JFYI Calling apply(...) with animatingDifferences: false simply calls reloadData() under the hood. So you can't rely on "update" logic in this case.Stove
@Taryntaryne use obtainPermanentIDsForObjects before the save. Verify that the snapshot only has permanent IDs before applying.Unstick
@Unstick that could be such an important thing to know that it deserves its own answer!Dessert
@Dessert ok I added an answer with everything I know so farUnstick
@Unstick thanks, I upvoted it to give it some traction, let's see what happens over timeDessert
Is there a way to access the NSDiffableDataSourceSnapshot<String,NSManagedObjectID> directly from the fetched results controller? (At the moment I'm using the hack of calling performFetch(), which behind the scenes apparently generates and applies a new snapshot.Asante
@Dessert How can I get access to the Item section in the DataSource cell provider closure ? As I want to configure different cell based on the type of the section it belongs to. Accessing the FRC property in the cell provider closure causes crash sometimes when animating the changesHeteroousian
@NikhilMuskur: You have to obtain the managed object like guard let aweNamino = try? managedObjectContext.existingObject(with: objectID) as? SomeManagedObject else { fatalError("Managed object should be available") }Veii
Hi matt, may I know, by using NSManagedObjectID as data source's ItemIdentifierType, how does the diffable framework able to detect content change? By using NSManagedObjectID, I can understand how diffable framework able to detect add/ delete/ move operation. But I can't visualise how it detect "content change". Thanks.Dallas
Its better to keep ItemIdentifierType as simple as possible. Ref donnywals.com/modern-table-views-with-diffable-data-sourcesKimble
@SAHM: The best Solution is in the last post. It also detects changes. https://mcmap.net/q/846440/-how-to-get-a-diffable-snapshot-from-an-nsfetchresultscontroller-in-ios-13Veii
U
6

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.

Unstick answered 26/5, 2020 at 14:46 Comment(2)
I have been having this same problem exactly, thanks for confirming that something like KVO would be used to solve. I'm not saying that KVO is the best solution but helps explain a lot of the "odd" api works.Cowboy
I was getting crashes because of temporary IDs being passed to the snapshot. Thanks for clarifying that the snapshot shouldn't have temporary IDs. I didn't know about managedObjectContext.obtainPermanentIDs(for:) (the Swift equivalent), which I'm using now prior to saving and it solved my issues.Ranita
A
2

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.


                
            }
Asante answered 28/5, 2020 at 11:14 Comment(0)
V
2

I have a solution, if you want to have nice animations for insert, delete, move and don't want flashing for update!

Here it is:

First create a struct like this:

struct SomeManageObjectContainer: Hashable {
    var objectID: NSManagedObjectID
    var objectHash: Int
    
    init(objectID: NSManagedObjectID, objectHash: Int) {
        self.objectID = objectID
        self.objectHash = objectHash
    }
    
    init(objectID: NSManagedObjectID, someManagedObject: SomeManagedObject) {
        var hasher = Hasher()
        //Add here all the Values of the ManagedObject that can change and are displayed in the cell
        hasher.combine(someManagedObject.someValue)
        hasher.combine(someManagedObject.someOtherValue)
        let hashValue = hasher.finalize()
        
        self.init(objectID: objectID, objectHash: hashValue)
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(objectID)
    }
    
    static func == (lhs: SomeManageObjectContainer, rhs: SomeManageObjectContainer) -> Bool {
        return (lhs.objectID == rhs.objectID)
    }
}

Then I use these two helper methods:

func someManagedObjectContainers(itemIdentifiers: [NSManagedObjectID]) -> [SomeManageObjectContainer] {
    var container = [SomeManageObjectContainer]()
    for objectID in itemIdentifiers {
        container.append(someManagedObjectContainer(objectID: objectID))
    }
    return container
}

func someManagedObjectContainer(objectID: NSManagedObjectID) -> SomeManageObjectContainer {
    guard let someManagedObject = try? managedObjectContext.existingObject(with: objectID) as? SomeManagedObject else {
        fatalError("Managed object should be available")
    }
    
    let container = SomeManageObjectContainer(objectID: objectID, someManagedObject: someManagedObject)
    return container
}

And finally the NSFetchedResultsController Delegate implementation:

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
    guard let dataSource = collectionView.dataSource as? UICollectionViewDiffableDataSource<String, SomeManageObjectContainer> else {
        assertionFailure("The data source has not implemented snapshot support while it should")
        return
    }
    let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>

    var mySnapshot = NSDiffableDataSourceSnapshot<String, SomeManageObjectContainer>()
    
    mySnapshot.appendSections(snapshot.sectionIdentifiers)
    mySnapshot.sectionIdentifiers.forEach { (section) in
        let itemIdentifiers = snapshot.itemIdentifiers(inSection: section)
        mySnapshot.appendItems(someManagedObjectContainers(itemIdentifiers: itemIdentifiers), toSection: section)
    }
    
    //Here we find the updated Objects an put them in reloadItems
    let currentSnapshot = dataSource.snapshot() as NSDiffableDataSourceSnapshot<String, SomeManageObjectContainer>
    let reloadIdentifiers: [SomeManageObjectContainer] = mySnapshot.itemIdentifiers.compactMap { container in
        
        let currentContainer = currentSnapshot.itemIdentifiers.first { (currentContainer) -> Bool in
            if currentContainer == container {
                return true
            }
            return false
        }
        
        if let currentContainer = currentContainer {
            if currentContainer.objectHash != container.objectHash {
                return container
            }
        }
        
        return nil
    }
    mySnapshot.reloadItems(reloadIdentifiers)

    var shouldAnimate = collectionView?.numberOfSections != 0
    if collectionView?.window == nil {
        shouldAnimate = false
    }
    
    dataSource.apply(mySnapshot, animatingDifferences: shouldAnimate)
}

I'm looking forward to here your feedback for this solution.

Veii answered 14/12, 2020 at 17:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.