Reputation: 42768
So far, here's are the code snippets that almost work for NSFetchedResultsController
+ UICollectionView
, based on the information provided
Please note that, there are 2 [BlockOperation]
, as reloadItems
and moveItem
doesn't play well within single performBatchUpdates
. Based on the workaround proposed in the video, we have to call reloadItems
in a separate performBatchUpdates
.
We also do not follow 100% methods (Perform reloadItems
typed performBatchUpdates first, followed by insert/ move/ delete typed performBatchUpdates) proposed in the video.
This is because we notice that it doesn't work well even for simple case. Some strange behaviour including reloadItems
will cause duplicated cell UI to be shown on screen. The "almost" work method we found are
private var blockOperations: [BlockOperation] = []
// reloadItems and moveItem do not play well together. We are using the following workaround proposed at
// https://developer.apple.com/videos/play/wwdc2018/225/
private var blockUpdateOperations: [BlockOperation] = []
extension DashboardViewController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
if type == NSFetchedResultsChangeType.insert {
print(">> insert")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let self = self {
self.collectionView!.insertItems(at: [newIndexPath!])
}
})
)
}
else if type == NSFetchedResultsChangeType.update {
print(">> update")
blockUpdateOperations.append(
BlockOperation(block: { [weak self] in
if let self = self, let indexPath = indexPath {
self.collectionView.reloadItems(at: [indexPath])
}
})
)
}
else if type == NSFetchedResultsChangeType.move {
print(">> move")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let self = self, let newIndexPath = newIndexPath, let indexPath = indexPath {
self.collectionView.moveItem(at: indexPath, to: newIndexPath)
}
})
)
}
else if type == NSFetchedResultsChangeType.delete {
print(">> delete")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let self = self {
self.collectionView!.deleteItems(at: [indexPath!])
}
})
)
}
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
if type == NSFetchedResultsChangeType.insert {
print(">> section insert")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let self = self {
self.collectionView!.insertSections(IndexSet(integer: sectionIndex))
}
})
)
}
else if type == NSFetchedResultsChangeType.update {
print(">> section update")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let self = self {
self.collectionView!.reloadSections(IndexSet(integer: sectionIndex))
}
})
)
}
else if type == NSFetchedResultsChangeType.delete {
print(">> section delete")
blockOperations.append(
BlockOperation(block: { [weak self] in
if let self = self {
self.collectionView!.deleteSections(IndexSet(integer: sectionIndex))
}
})
)
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
if blockOperations.isEmpty {
performBatchUpdatesForUpdateOperations()
} else {
collectionView.performBatchUpdates({ [weak self] () -> Void in
guard let self = self else { return }
for operation: BlockOperation in self.blockOperations {
operation.start()
}
self.blockOperations.removeAll(keepingCapacity: false)
}, completion: { [weak self] (finished) -> Void in
print("blockOperations completed")
guard let self = self else { return }
self.performBatchUpdatesForUpdateOperations()
})
}
}
private func performBatchUpdatesForUpdateOperations() {
if blockUpdateOperations.isEmpty {
return
}
collectionView.performBatchUpdates({ [weak self] () -> Void in
guard let self = self else { return }
for operation: BlockOperation in self.blockUpdateOperations {
operation.start()
}
self.blockUpdateOperations.removeAll(keepingCapacity: false)
}, completion: { [weak self] (finished) -> Void in
print("blockUpdateOperations completed")
guard let self = self else { return }
})
}
}
The above way, works "almost" well when no "section" operations involved.
For the above animation, you will observe logging
>> move
blockOperations completed
>> move
blockOperations completed
>> move
blockOperations completed
However, when a section is being added/ removed, the completion handler of performBatchUpdates
is not being called!
For the above animation, you will observe logging
>> section delete
>> move
>> section insert
>> move
This means the completion handler block is not executed! Does anyone know why it is so, and how I can workaround with this issue?
I expect "blockOperations completed" should be printed out. The expected log should be
>> section delete
>> move
blockOperations completed
>> section insert
>> move
blockOperations completed
Thanks.
Upvotes: 5
Views: 3175
Reputation: 8517
I tested this on Xcode 12 and Xcode 13.0 beta.
On Xcode 12 I can reproduce the bug that you describe:
When changing an object so that a whole section gets removed, the completion handler is not getting called. When performing another subsequent change, I get two completion handler calls.
On Xcode 13 however, the issue is not reproducible in my tests. I get proper callbacks when a section gets cleared and is removed.
Nevertheless I still get an odd message in the console saying
[Snapshotting] Snapshotting a view (xxx, StackoverflowDemo.Cell) that has not been rendered at least once requires afterScreenUpdates:YES.
My conclusion at this point is, that this is a bug within the system, that has been fixed in iOS 15.
[Update]
Regardless I have updated your code to achieve the proper behavior on both os versions.
The key concepts are:
It may be possible to refine the last step if you store moved indexPaths and only reload these rows.
This is the code that I added to reproduce the issue.
I you want to test, please do the following steps:
import UIKit
import CoreData
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
self.window = UIWindow(frame: UIScreen.main.bounds)
let layout = UICollectionViewFlowLayout()
layout.headerReferenceSize = CGSize(width: 30,height: 30)
layout.sectionInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
self.window?.rootViewController = UINavigationController.init(rootViewController: DashboardViewController(collectionViewLayout: layout) )
self.window?.makeKeyAndVisible()
return true
}
}
class DashboardViewController: UICollectionViewController {
let persistentContainer = PersistentContainer()
lazy var resultsController: NSFetchedResultsController<Entity>? = {
let fetchRequest = NSFetchRequest<Entity>(entityName: "Entity")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "section", ascending: true), NSSortDescriptor(key: "name", ascending: false)]
let resultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
managedObjectContext: self.persistentContainer.viewContext,
sectionNameKeyPath: "section",
cacheName: nil)
resultsController.delegate = self
try! resultsController.performFetch()
return resultsController
}()
private var itemOperations = [() -> Void]()
private var sectionOperations = [() -> Void]()
private var reloadRequired = false
override func viewDidLoad() {
super.viewDidLoad()
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add))
self.collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell")
self.collectionView.register(Header.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "Header")
}
var itemIndex: Int = 0
var section: Double = 0
@objc func add() {
let entity = Entity(context: self.persistentContainer.viewContext)
entity.name = Int64(self.itemIndex)
itemIndex += 1
entity.section = Int64(floor(self.section))
section += 0.5
try! self.persistentContainer.viewContext.save()
}
override func numberOfSections(in collectionView: UICollectionView) -> Int { return resultsController!.sections?.count ?? 0 }
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return self.resultsController!.sections![section].numberOfObjects }
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! Header
let sectionInfo = self.resultsController!.sections?[indexPath.section]
header.label.text = sectionInfo?.name
return header
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let item = self.resultsController?.object(at: indexPath)
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell
cell.label.text = String(describing: item?.name ?? -1)
return cell
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let item = self.resultsController?.object(at: indexPath)
item?.section = max(0, (item?.section ?? 0) - 1)
item?.name = 10 + (item?.name ?? 0)
}
}
@objc(Entity)
public class Entity: NSManagedObject {
@NSManaged public var name: Int64
@NSManaged public var section: Int64
}
class Cell: UICollectionViewCell {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .lightGray
self.label.textAlignment = .center
self.label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.label.frame = self.contentView.bounds
self.label.translatesAutoresizingMaskIntoConstraints = true
self.contentView.addSubview(self.label)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
class Header: UICollectionReusableView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .gray
self.label.textAlignment = .center
self.label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.label.frame = self.bounds
self.label.translatesAutoresizingMaskIntoConstraints = true
self.addSubview(self.label)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
class PersistentContainer: NSPersistentContainer {
convenience init() {
// create object model
let nameProperty = NSAttributeDescription()
nameProperty.name = "name"
nameProperty.attributeType = .integer64AttributeType
let sectionProperty = NSAttributeDescription()
sectionProperty.name = "section"
sectionProperty.attributeType = .integer64AttributeType
let entity = NSEntityDescription()
entity.name = "Entity"
entity.managedObjectClassName = "Entity"
entity.properties = [nameProperty, sectionProperty]
let model = NSManagedObjectModel()
model.entities.append(entity)
// create container
self.init(name: "Foo", managedObjectModel: model)
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
self.persistentStoreDescriptions = [description]
self.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}
extension DashboardViewController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
reloadRequired = false
if type == NSFetchedResultsChangeType.insert {
print(">> insert")
itemOperations.append { [weak self] in
if let self = self {
self.collectionView!.insertItems(at: [newIndexPath!])
}
}
}
else if type == NSFetchedResultsChangeType.update {
print(">> update")
itemOperations.append { [weak self] in
if let self = self, let indexPath = indexPath {
self.collectionView.reloadItems(at: [indexPath])
}
}
}
else if type == NSFetchedResultsChangeType.move {
print(">> move")
self.reloadRequired = true
itemOperations.append { [weak self] in
if let self = self, let newIndexPath = newIndexPath, let indexPath = indexPath {
self.collectionView.moveItem(at: indexPath, to: newIndexPath)
}
}
}
else if type == NSFetchedResultsChangeType.delete {
print(">> delete")
itemOperations.append { [weak self] in
if let self = self {
self.collectionView!.deleteItems(at: [indexPath!])
}
}
}
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
if type == NSFetchedResultsChangeType.insert {
print(">> section insert")
sectionOperations.append { [weak self] in
if let self = self {
self.collectionView!.insertSections(IndexSet(integer: sectionIndex))
}
}
}
else if type == NSFetchedResultsChangeType.update {
print(">> section update")
sectionOperations.append { [weak self] in
if let self = self {
self.collectionView!.reloadSections(IndexSet(integer: sectionIndex))
}
}
}
else if type == NSFetchedResultsChangeType.delete {
print(">> section delete")
sectionOperations.append { [weak self] in
if let self = self {
self.collectionView!.deleteSections(IndexSet(integer: sectionIndex))
}
}
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
collectionView.performBatchUpdates({ [weak self] () -> Void in
guard let self = self else { return }
// execute single item operations first
self.itemOperations.forEach { $0() }
// execute section operations afterwards
self.sectionOperations.forEach { $0() }
self.itemOperations.removeAll(keepingCapacity: false)
self.sectionOperations.removeAll(keepingCapacity: false)
}, completion: { [weak self] (finished) -> Void in
print("blockOperations completed")
guard let self = self else { return }
// in case of a move do a reload in case the item has also changed
// it will not update otherwise
if self.reloadRequired {
self.collectionView.reloadData()
}
})
}
}
Upvotes: 3