Someone asked about this error before here but he was not updating his model and to my knowledge that is all I am doing and then refreshing the tableView so I wanted to see if anyone has any thoughts on this. I would really appreciate any advice, since I can't seem to get to the bottom of this and users keep reporting the issue.
The issue is: The app crashes if a user drags a task from Overdue to any of the other sections. The error is this:
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to move index path (<NSIndexPath: 0x9ed3b3d9edf53a85> {length = 2, path = 0 - 0}) to index path (<NSIndexPath: 0x9ed3b3d9edf52a85> {length = 2, path = 1 - 0}) that does not exist - there are only 0 rows in section 1 after the update'
Other reports from analytics also point to this error with a similar (similarly vague that is) stack trace:
Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (1) must be equal to the number of rows contained in that section before the update (1), plus or minus the number of rows inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 1 moved out)
Here's my setup: I have a tableview that displays several tasks and groups them by due date. There's a variable for the filtered array of each task, like this:
class ZoneController: UIViewController {
var incompleteTasks: [Task] {
let tasks = zone.tasks.filter({ !$0.completed })
if zone.groupTasks { return tasks.sortedByDueDate() }
else { return tasks }
}
var overdueTasks: [Task] {
let tasks = zone.tasks.filter({ ($0.dueDate?.isInThePast ?? false) && !$0.completed })
if zone.groupTasks { return tasks.sortedByDueDate() }
else { return tasks }
}
var todayTasks: [Task] {
let tasks = zone.tasks.filter({ ($0.dueDate?.isToday ?? false) && !$0.completed && !($0.dueDate?.isInThePast ?? false) /* This is here because a task could be today but a few hours earlier, in which case it needs to be overdue */ })
if zone.groupTasks { return tasks.sortedByDueDate() }
else { return tasks }
}
var tomorrowTasks: [Task] {
let tasks = zone.tasks.filter({ ($0.dueDate?.isTomorrow ?? false) && !$0.completed })
if zone.groupTasks { return tasks.sortedByDueDate() }
else { return tasks }
}
var laterTasks: [Task] {
let laterTasks = zone.tasks.filter({
var isLater = true
if let dueDate = $0.dueDate { isLater = (dueDate > Calendar.current.dayAfterTomorrow()) }
return !$0.completed && isLater
})
return laterTasks.sortedByDueDate()
}
}
The tableview has the option to move tasks between groups and change their due date, so in tableView moveRowAt I am switching the destinationIndexPath and changing the dueDate accordingly, then reloading the tableView. Here is the code responsible for the tableView:
import UIKit
import WidgetKit
import AppCenterAnalytics
extension ZoneController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if tasksTabSelected {
if zone.groupTasks {
switch section {
case 0: return overdueTasks.count
case 1: return todayTasks.count
case 2: return tomorrowTasks.count
default: return laterTasks.count
}
} else {
return incompleteTasks.count
}
} else {
return zone.ideas.count
}
}
func numberOfSections(in tableView: UITableView) -> Int {
tasksTabSelected && zone.groupTasks ? 4 : 1
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
if tasksTabSelected && zone.groupTasks && !incompleteTasks.isEmpty {
// This makes the headers scroll with the rest of the content
let dummyViewHeight = CGFloat(44)
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.size.width, height: dummyViewHeight))
tableView.contentInset = UIEdgeInsets(top: -dummyViewHeight, left: 0, bottom: 0, right: 0)
let headerTitles = ["Overdue", "Today", "Tomorrow", "Later"]
let header = tableView.dequeueReusableCell(withIdentifier: "TaskHeaderCell") as! TaskHeaderCell
header.icon.image = UIImage(named: "header-\(headerTitles[section])")
header.name.text = headerTitles[section]
return header
} else {
return nil
}
}
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let footer = UIView()
footer.backgroundColor = .clear
return footer
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
var shouldBeTall = false
if tasksTabSelected && zone.groupTasks {
switch section {
case 0: shouldBeTall = overdueTasks.count != 0
case 1: shouldBeTall = todayTasks.count != 0
case 2: shouldBeTall = tomorrowTasks.count != 0
case 3: shouldBeTall = laterTasks.count != 0
default: return 0
}
}
return shouldBeTall ? 24 : 0
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let tasksAreGrouped = tasksTabSelected && zone.groupTasks && !incompleteTasks.isEmpty
return tasksAreGrouped && section != 0 || tasksAreGrouped && section == 0 && !overdueTasks.isEmpty ? 48 : 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if tasksTabSelected {
let cell = tableView.dequeueReusableCell(withIdentifier: "TaskCell") as! TaskCell
let accentColor = UIColor(hex: zone.firstColor)
var task: Task?
if zone.groupTasks {
print("Section: \(indexPath.section) has \(tableView.numberOfRows(inSection: indexPath.section))")
switch indexPath.section {
case 0: task = overdueTasks[indexPath.row]
case 1: task = todayTasks[indexPath.row]
case 2: task = tomorrowTasks[indexPath.row]
case 3: task = laterTasks[indexPath.row]
default: break
}
} else {
task = incompleteTasks[indexPath.row]
}
cell.task = task
cell.accentColor = accentColor
cell.zoneGroupsTasks = zone.groupTasks
cell.configure()
return cell
} else {
let cell = tableView.dequeueReusableCell(withIdentifier: "IdeaCell") as! IdeaCell
let idea = zone.ideas[indexPath.row]
cell.content.text = idea.content.replacingOccurrences(of: "\n\n\n\n", with: " ").replacingOccurrences(of: "\n\n\n", with: " ").replacingOccurrences(of: "\n\n", with: " ").replacingOccurrences(of: "\n", with: " ")
cell.idea = idea
return cell
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if tasksTabSelected {
let vc = Storyboards.main.instantiateViewController(identifier: "TaskDetails") as! TaskDetails
vc.colors = [UIColor(hex: zone.firstColor), UIColor(hex: zone.secondColor)]
if zone.groupTasks {
switch indexPath.section {
case 0: vc.task = overdueTasks[indexPath.row]
case 1: vc.task = todayTasks[indexPath.row]
case 2: vc.task = tomorrowTasks[indexPath.row]
case 3: vc.task = laterTasks[indexPath.row]
default: break
}
} else {
vc.task = incompleteTasks[indexPath.row]
}
presentSheet(vc)
} else {
let vc = Storyboards.main.instantiateViewController(identifier: "IdeaDetails") as! IdeaDetails
vc.color = UIColor(hex: zone.firstColor)
vc.idea = zone.ideas[indexPath.row]
presentSheet(vc)
}
}
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
if tasksTabSelected {
if zone.groupTasks && dragSourceIndexPath?.section != destinationIndexPath.section {
guard let dragSourceTimestamp = dragSourceTimestamp else { return }
var task = zone.tasks.first(where: { $0.id == dragSourceTimestamp })
task?.reminderNeeded = true
switch destinationIndexPath.section {
case 1:
task?.dueDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())
task?.reminderNeeded = false
case 2: task?.dueDate = Calendar.current.tomorrow(at: 9)
case 3: task?.dueDate = Calendar.current.inTwoWeeks(at: 9)
default: break
}
task?.save()
} else {
guard
let sourceIndex = zone.tasks.firstIndex(where: { $0.id == incompleteTasks[sourceIndexPath.row].id }),
let destinationIndex = zone.tasks.firstIndex(where: { $0.id == incompleteTasks[destinationIndexPath.row].id })
else { return }
Storage.zones[zoneIndex].tasks.move(from: sourceIndex, to: destinationIndex)
}
} else {
Storage.zones[zoneIndex].ideas.move(from: sourceIndexPath.row, to: destinationIndexPath.row)
}
reloadTableView() // This version is being called to make sure the zone is being refreshed, even though technically tableView.reloadData() would have been enough.
Push.updateAllReminders()
WidgetCenter.shared.reloadAllTimelines()
Analytics.trackEvent("Reordered tasks or ideas")
}
func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession) {
tableView.vibrate()
// This is here to prevent users from dragging this task to other zones and a weird scrolling bug that happens
if let pageController = self.parent as? PVC { pageController.decouple() }
}
func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) {
// This is here to prevent users from dragging this task to other zones and a weird scrolling bug that happens
if let pageController = self.parent as? PVC { pageController.recouple() }
}
}
extension ZoneController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
dragSourceIndexPath = indexPath
dragSourceTimestamp = (tableView.cellForRow(at: indexPath) as? TaskCell)?.task.id
return [UIDragItem(itemProvider: NSItemProvider())]
}
func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
let param = UIDragPreviewParameters()
if #available(iOS 14.0, *) { param.shadowPath = UIBezierPath(rect: .zero) }
param.backgroundColor = .clear
return param
}
}
extension ZoneController: UITableViewDropDelegate {
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
if session.localDragSession != nil { // Drag originated from the same app.
let isSameSection = destinationIndexPath?.section == dragSourceIndexPath?.section
let permitted = !isSameSection && destinationIndexPath?.section != 0 || !tasksTabSelected || !zone.groupTasks
return UITableViewDropProposal(operation: permitted ? .move : .forbidden, intent: .insertAtDestinationIndexPath)
}
return UITableViewDropProposal(operation: .cancel, intent: .unspecified)
}
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
}
func tableView(_ tableView: UITableView, dropPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
let param = UIDragPreviewParameters()
if #available(iOS 14.0, *) { param.shadowPath = UIBezierPath(rect: .zero) }
param.backgroundColor = .clear
return param
}
}
A few things to note:
The task.save() method saves the task to disk.
If I completely comment out the code in moveRowAt, the crash still occurs, so it is not something in there that is causing it.
The reloadTableView method sets an empty image if it's needed and also reads the Zone (which contains all tasks) from disk again. I am storing the Zone in memory as a variable of the ViewController for performance reasons (scrolling is very janky if I always read it from disk).
@objc func reloadTableView() { DispatchQueue.main.async { [self] in zone = Storage.zones[zoneIndex] tableView.reloadData() let hidden = tasksTabSelected && incompleteTasks.isEmpty || !tasksTabSelected && zone.ideas.isEmpty emptyImage.isHidden = !hidden emptyImage.image = UIImage(named: tasksTabSelected ? "no-tasks" : "no-ideas") } }
CodePudding user response:
You need to change the model object from which the tableview is getting data in response to the UI changes and then reload the table.
Currently it seems like you're changing Storage.zones
but then you queue asynchronously the update from that to the view zone
copy of the data model that is used to render the table view. They are out of sync for a period of time (at least one cycle of the runloop) and that's likely when you are crashing.
You say you're updating the disk and then reading it back in but I don't see the code that reads it back in or see the code that obviously calls that code. So it's unclear how sensitive that will be to device load etc. and thus how likely you are to hit this delay that causes the issue.
Aside: If you have more than a few items, using UserDefaults to store this is probably not ideal.
Unless the list of items is really small, calling filter and sorting during numberOfRowsInSection
is less than ideal (it's called pretty often). As long as you're caching zones you might as well cache the organized data into the sub-lists your filters are returning?
Anyway, that's probably not the root cause of your crash.