Как получить доступный снимок из NSFetchResultsController в iOS 13?
Итак, мы находимся в видео 230 WWDC 2019, и примерно с 14-й минуты утверждается, что NSFetchedResultsController теперь продает NSDiffableDataSourceSnapshot, поэтому мы можем просто применить его непосредственно к источнику данных, доступному для различий (UITableViewDiffableDataSource).
Но это не совсем то, что они говорят или что мы получаем. Что мы получаем в методе делегатаcontroller(_:didChangeContentWith:)
, является NSDiffableDataSourceReference. Как перейти от этого к реальному моментальному снимку и какими должны быть общие типы моих источников данных для различий?
5 ответов
Источник различимых данных должен быть объявлен с помощью универсальных типов String и NSManagedObjectID. Теперь вы можете привести ссылку на снимок:
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String,NSManagedObjectID>
self.ds.apply(snapshot, animatingDifferences: false)
}
Это оставляет открытым вопрос о том, как вы собираетесь заполнить ячейку. В доступном источнике данных (self.ds
в моем примере), когда вы заполняете ячейку, вернитесь к контроллеру выбранных результатов и получите фактический объект данных.
Например, в моем табличном представлении я показываю name
группы в каждой ячейке:
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
}
}()
Видео WWDC подразумевает, что мы должны объявить источник данных с универсальными типами String
а также NSManagedObjectID
. Это не работает для меня; единственный способ добиться разумного поведения с анимацией и обновлением строк - использовать объект настраиваемого значения в качестве идентификатора строки для источника данных.
Проблема со снимком с использованием NSManagedObjectID
поскольку идентификатор элемента состоит в том, что, хотя делегат извлеченных результатов уведомлен об изменениях в управляемом объекте, связанном с этим идентификатором, снимок, который он продает, может не отличаться от предыдущего, который мы могли применить к источнику данных. Сопоставление этого снимка с одним с использованием объекта значения в качестве идентификатора создает другой хэш при изменении базовых данных и решает проблему обновления ячейки.
Рассмотрим источник данных для приложения со списком задач, в котором есть табличное представление со списком задач. Каждая ячейка показывает заголовок и некоторую информацию о том, выполнена ли задача. Объект значения может выглядеть так:
struct TaskItem: Hashable {
var title: String
var isComplete: Bool
}
Источник данных отображает снимок этих элементов:
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
}
Предполагая, что полученный контроллер результатов может быть сгруппирован, делегату передается моментальный снимок с типами String
а также NSManagedObjectID
. Это можно преобразовать в снимокString
а также TaskItem
(объект значения, используемый как идентификатор строки) для применения к источнику данных:
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)
}
Начальный performFetch
в viewDidLoad
обновляет представление таблицы без анимации. Все последующие обновления, включая обновления, которые просто обновляют ячейку, работают с анимацией.
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.
Как указывали другие, UITableView будет загружаться как пустой, если animatingDifferences: true
используется при первой загрузке таблицы.
А также animatingDifferences: true
не приведет к принудительной перезагрузке ячейки при изменении базовых данных модели.
Такое поведение похоже на ошибку.
Еще хуже полный сбой приложения, когда uitableview находится в режиме редактирования, а пользователь пытается удалить запись, используя trailingSwipeActionsConfigurationForRowAt
Мой обходной путь - просто установить для animatingDifferences значение false во всех случаях. Облом конечно то, что все анимации теряются. Я отправил отчет об ошибке в Apple по этой проблеме.
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.
}
У меня есть решение, если вы хотите иметь красивую анимацию для вставки, удаления, перемещения и не хотите мигать для обновления!
Вот:
Сначала создайте такую структуру:
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)
}
}
Затем я использую эти два вспомогательных метода:
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
}
И, наконец, реализация делегата NSFetchedResultsController:
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)
}
Я с нетерпением жду ваших отзывов об этом решении.