I have a UITableView
which displays data from an NSFetchedResultsController
. There are varying quantities of data which show up in the FRC, ranging from ~20 records for one fetch to ~14k records for the biggest one. The issue I'm encountering is if I perform a fetch while the tableView is scrolling on the large fetch, I get an exception. By the time cellForRowAtIndexPath
gets called, the FRC has already updated and there's no data for it there, resulting in an exception.
I found this post, which sounds like what I've encountered, though I'm unable to resolve it via that methodology.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseID, for: indexPath) as! CustomTableViewCell
// Exception occurs here
let myObject = fetchedResultsController.object(at: indexPath)
cell.myObject = myObject
return cell
}
Here is the stack trace:
0 CoreFoundation 0x0000000110b241e6 __exceptionPreprocess + 294
1 libobjc.A.dylib 0x000000010b820031 objc_exception_throw + 48
2 CoreData 0x000000010c2458fd -[NSFetchedResultsController objectAtIndexPath:] + 685
3 MyApp 0x000000010aaf36c5 _T011MyApp0B14ViewControllerC05tableC0So07UITableC4CellCSo0fC0C_10Foundation9IndexPathV12cellForRowAttF + 437
4 MyApp 0x000000010aaf39ec _T011MyApp0B14ViewControllerC05tableC0So07UITableC4CellCSo0fC0C_10Foundation9IndexPathV12cellForRowAttFTo + 92
5 UIKit 0x000000010cf45567 -[UITableView _createPreparedCellForGlobalRow:withIndexPath:willDisplay:] + 783
6 UIKit 0x000000010cf45ae4 -[UITableView _createPreparedCellForGlobalRow:willDisplay:] + 74
7 UIKit 0x000000010cf0ceaa -[UITableView _updateVisibleCellsNow:isRecursive:] + 3168
8 UIKit 0x000000010cf2d7e0 -[UITableView layoutSubviews] + 176
9 UIKit 0x000000010ceb77a8 -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1515
10 QuartzCore 0x000000010cc21456 -[CALayer layoutSublayers] + 177
11 QuartzCore 0x000000010cc25667 _ZN2CA5Layer16layout_if_neededEPNS_11TransactionE + 395
12 QuartzCore 0x000000010cbac0fb _ZN2CA7Context18commit_transactionEPNS_11TransactionE + 343
13 QuartzCore 0x000000010cbd979c _ZN2CA11Transaction6commitEv + 568
14 UIKit 0x000000010cde22ef _UIApplicationFlushRunLoopCATransactionIfTooLate + 167
15 UIKit 0x000000010d747662 __handleEventQueueInternal + 6875
16 CoreFoundation 0x0000000110ac6bb1 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
17 CoreFoundation 0x0000000110aab4af __CFRunLoopDoSources0 + 271
18 CoreFoundation 0x0000000110aaaa6f __CFRunLoopRun + 1263
19 CoreFoundation 0x0000000110aaa30b CFRunLoopRunSpecific + 635
20 GraphicsServices 0x0000000115c2fa73 GSEventRunModal + 62
21 UIKit 0x000000010cde8057 UIApplicationMain + 159
22 MyApp 0x000000010aacd427 main + 55
23 libdyld.dylib 0x0000000111c5b955 start + 1
I welcome recommendations in resolving this issue. I would like to avoid doing something hacky like disabling new fetches until the tableview
is done decelerating. Thank you for reading.
Update
In response to a comment, here is my implementation of the UIFetchedResultsControllerDelegate
. It is the code Apple has in documentation:
extension ViewController: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.beginUpdates()
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange sectionInfo: NSFetchedResultsSectionInfo,
atSectionIndex sectionIndex: Int,
for type: NSFetchedResultsChangeType) {
switch type {
case .insert:
tableView.insertSections(IndexSet(integer: sectionIndex), with: .fade)
case .delete:
tableView.deleteSections(IndexSet(integer: sectionIndex), with: .fade)
case .move:
break
case .update:
break
}
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?) {
switch type {
case .insert:
tableView.insertRows(at: [newIndexPath!], with: .fade)
case .delete:
tableView.deleteRows(at: [indexPath!], with: .fade)
case .update:
tableView.reloadRows(at: [indexPath!], with: .fade)
case .move:
tableView.moveRow(at: indexPath!, to: newIndexPath!)
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.endUpdates()
}
}
New Attempt
In response to a comment, I added my NSFetchedResultsControllerDelegate
implementation, which is what Apple's got listed in documentation. I noticed there was force unwrapping of optionals, so I threw some guard
statements in and I turned off animation for good measure. I get the same crash in the same place doing the same thing.
Here's the updated delegate method with a guard statements added and animation turned off:
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?) {
guard let indexPath = indexPath else { return }
switch type {
case .insert:
guard let newIndexPath = newIndexPath else { return }
tableView.insertRows(at: [newIndexPath], with: .none)
case .delete:
tableView.deleteRows(at: [indexPath], with: .none)
case .update:
tableView.reloadRows(at: [indexPath], with: .none)
case .move:
guard let newIndexPath = newIndexPath else { return }
tableView.moveRow(at: indexPath, to: newIndexPath)
}
}
Update 2
In response to a comment, this is the code I'm using to update the predicate. I have an observer of startDate
& endDate
that calls updatePredicate()
. startDate
and endDate
are updated when a segmentedControl
is pressed.:
// This is used to set values for searching via segmentedControl
@objc dynamic var startDate: Date?
@objc dynamic var endDate: Date?
func updatePredicate() {
// you need a start date or stop executing
guard let startDate = startDate else { return }
var predicateArray: [NSPredicate] = []
if let endDate = endDate {
let startEndPredicate = NSPredicate(format: "time >= %@ AND time <= %@", argumentArray: [startDate, endDate])
predicateArray.append(startEndPredicate)
} else {
// use the startDate's end of day if endDate is nil
let startPredicate = NSPredicate(format: "time >= %@ AND time <= %@", argumentArray: [startDate, startDate.endOfDay!])
predicateArray.append(startPredicate)
}
let sliderPredicate = NSPredicate(format: "magnitude >= %f", argumentArray: [magSlider.value])
predicateArray.append(sliderPredicate)
currentPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicateArray)
fetchResults()
}
Here is fetchResults():
func fetchResults() {
do {
fetchedResultsController.fetchRequest.fetchBatchSize = 35
fetchedResultsController.fetchRequest.fetchLimit = 1_000
try fetchedResultsController.performFetch()
} catch let error {
print("\(error) \(error.localizedDescription)")
}
DispatchQueue.main.async {
self.tableView.reloadData()
self.tableView.setContentOffset(.zero, animated: true)
self.updateLabelsForResults()
}
}
Update 3
In response to another comment, below is the FRC declaration:
private lazy var fetchedResultsController: NSFetchedResultsController<EarthquakeEntity> = {
let managedObjectContext = appDelegate.persistentContainer.viewContext
let fetchRequest: NSFetchRequest<EarthquakeEntity> = EarthquakeEntity.fetchRequest()
let dateSortDescriptor = NSSortDescriptor(key: #keyPath(EarthquakeEntity.time), ascending: false)
fetchRequest.sortDescriptors = [dateSortDescriptor]
let frc = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: frcKeyPath, cacheName: nil)
frc.delegate = self
return frc
}()