Full title: Swift 4.2 using split view controller, issue with "repeat" Notification on first load that disrupts ability to programmatically select a "new" object in the Master Table View.
Background
I am working with a split view controller with master and detail table views - master is a plain dynamic table view that displays a list of entities to the user and detail is a grouped dynamic table view that displays the attributes and relationship values for the user-selected entity in the master.
I have implemented Core Data.
The master table view shows a list of entities. The data source for the master table view is a fetched results controller.
The detail table view shows the attributes and associated relationship values of the currently selected row (entity) in the master table view. The data source for the detail table view is the entity relating to the currently selected row in the master table view, which it is passed using a "Show Detail" segue.
On devices with larger screens where splitViewController.isCollapsed == false, the master and detail table views are both active on screen, per the image below.
Fairly standard arrangement for a data driven app...?
Logic Flow
When a user updates an existing entity (in the case of the screenshot example, an existing "Event"), they click the the Save button in the navigation bar of the detail table view.
Because the entity already exists, in the Master Table View Controller's controller(_:didChange:at:for:newIndexPath) Fetched Results Controller Delegate method:
under case .update, implement tableView.reloadRows(at: [indexPath!], with: UITableViewRowAnimation.none), which triggers tableView(_willDisplay:forRowAt:) which in turn updates the formatting of the selected Table View Cell; AND
under case .insert, case .update and case .move, I set a temporary value for the current IndexPath indexPathForManagedObjectChanged
The Fetched Results Controller Delegate method:
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)
indexPathForManagedObjectChanged = newIndexPath
print("___controllerDidChangeObject: INSERTED OBJECT")
case .delete:
tableView.deleteRows(at: [indexPath!], with: .fade)
print("___controllerDidChangeObject: DELETED OBJECT")
case .update:
configureCell(tableView.cellForRow(at: indexPath!)!, withEvent: anObject as! PT_Events)
indexPathForManagedObjectChanged = indexPath
tableView.reloadRows(at: [indexPath!], with: UITableView.RowAnimation.none)
print("___controllerDidChangeObject: UPDATED OBJECT")
case .move:
configureCell(tableView.cellForRow(at: indexPath!)!, withEvent: anObject as! PT_Events)
indexPathForManagedObjectChanged = newIndexPath
tableView.moveRow(at: indexPath!, to: newIndexPath!)
print("___controllerDidChangeObject: MOVED OBJECT")
}
}
Then, in the Master Table View Controller's controllerDidChangeContent(_:) Fetched Results Controller Delegate method, I execute the following code...
guard let indexPathOfRowToSelect: IndexPath = indexPathForManagedObjectChanged else {
return
}
indexPathForManagedObjectChanged = nil
tableView.selectRow(at: indexPathOfRowToSelect, animated: false, scrollPosition: UITableViewScrollPosition.none)
This ensures that after inserts, updates and moves, the row that corresponds to the data in the Detail Table View is selected.
I use a Notification of type UserDefaults.didChangeNotification.
The observer is added in the Master Table View Controller's (EDIT was viewDidLoad, but now) viewWillAppear(_:) method. The observer is removed in the Master Table View Controller's viewWillDisappear(_:) method.
If the user changes one of the settings the Notification observer calls the following function...
#objc
func userDefaultSettingsDidChange(_ notification: Notification) {
if (notification.object as? UserDefaults) != nil {
NSFetchedResultsController<NSFetchRequestResult>.deleteCache(withName: classCacheName)
fetchedResultsController = nil
tableView.reloadData()
}
}
Problem
On first run of the Master Table View Controller there is a Notification that calls the userDefaultSettingsDidChange method, but it calls this AFTER the first run through of the Fetched Results Controller Delegate methods in response to the first user interaction. This despite no change to the user defaults.
The issue occurs on simulator and on device.
I've added a stack of print()s (removed for ease of reading code) to track what is happening in what order.
The console logs the userDefaultSettingsDidChange function as executing AFTER the Fetched Results Controller Delegate methods have completed - BUT only on first run.
Because of this, the call to reloadData wipes out my previous call to selectRow.
Solution
I can wrap my call to the selectRow(at:animated:scrollPosition:) instance method in the Master Table View Controller's controllerDidChangeContent(_:) Fetched Results Controller Delegate method to include a slight delay...
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.endUpdates()
guard let indexPathOfRowToSelect: IndexPath = indexPathForManagedObjectChanged else {
return
}
indexPathForManagedObjectChanged = nil
let when = DispatchTime.now() + 0.20
DispatchQueue.main.asyncAfter(deadline: when) {
self.tableView.selectRow(at: indexPathOfRowToSelect, animated: false, scrollPosition: UITableView.ScrollPosition.none)
}
}
This works!
But - I'm not satisfied.
I've done a lot of reading but for some reason, cannot seem to understand what is happening in NotificationCenter that is causing this ghost update to user settings despite no update.
Could someone please explain why I'm seeing this delayed "update" to user settings that is wreaking havoc on only the first user interaction in my UI?
Related
I have an array "numbers" which I call in my table view file to make the table view cells. When one is clicked it goes to a view controller which shows details about that cell, and in within that view controller is a delete button. How would I delete the item from the array, and reload the data in the table view controller?
So I set it up that when the delete button is clicked it runs an exit code, and deletes the item from the array, and reloads the data. I tried testing it but it never seems to execute.
This is in the detailViewController where it runs the exit function and runs the protocol to delete the item from the array
func deleteNumber() {
self.delegate?.unwind()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Change `2.0` to the desired number of seconds.
self.performSegue(withIdentifier: "unwindToNumbersList2WithSender", sender: self)
}
}
Then in the table view controller it runs this:
func unwind() {
numbers.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .fade)
saveNumbers()
}
I also tried using:
func unwind() {
numbers.remove(at: indexPath.row)
tableView.reloadData()
saveNumbers()
}
So I wanted it to run an either delete the row or delete the item from the array, and reload the data, but neither of those ran. Is there a way I can delete it from the detail view controlle?
You can use NotificationCenter for doing this,
First you need to add notification in UIViewController like this
let DELETE_DATA = "DELETE_DATA".notificationName()
NotificationCenter.default.addObserver(self, selector: #selector(self.delete_data), name: DELETE_DATA, object: nil)
Make a function named delete_data
#objc func delete_data(_ notification : NSNotification){
let userData = notification.userInfo as? [String:Any] ?? [String:Any]()
// here you can get your wanted index to be deleted.
}
From details view controller you need to POST this notification with index you want to delete
let obj = ["index":`your index to be deleted`]
NotificationCenter.default.post(name: DELETE_DATA, object: nil, userInfo: obj)
todoTableView.rx.itemDeleted.asObservable()
.subscribe({ (event) in
let indexpath = event.element
self.viewModel.deleteToDo(index: (indexpath?.row)!, completion: {
self.todoTableView.deleteRows(at: [indexpath!], with: .fade)
})
})
.disposed(by: disposeBag)
when I delete a row from tableView, The table gets updated before the delete animation occurs, due to which i am receiving an internal inconsistency error.
You need to delete it in the data source the table is subscribed to. Then it is automatically propagated to the table. I.e. not via deleteRows method.
The problem I'm seeing is that when I create a UIContextualAction with .destructive and pass true in completionHandler there seems to be a default action for removing the row.
If you create a new Master-Detail App from Xcode's templates and add this code in MasterViewController...
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let testAction = UIContextualAction(style: .destructive, title: "Test") { (_, _, completionHandler) in
print("test")
completionHandler(true)
}
return UISwipeActionsConfiguration(actions: [testAction])
}
the row you swipe will be removed. Notice that there's no code there updating the table view. Also the model is not updated and if you scroll way up to cause the row to be reloaded it will reappear.
Passing false in this case does not remove the row. Or using the .normal style and true also does not remove the row.
.destructive and true results in the row being removed by default.
Can anyone explain this behaviour? Why is the row being removed?
Per the documentation for the Destructive option:
An action that deletes data or performs some type of destructive task.
The completion is meant to signify if the action was a success. Passing true would mean that the destructive task was a success and thus the row should be removed.
You are meant to manually update your dataSource when the destructive action occurs and not doing so would cause scrolling to make the data reappear. You will also need to tell the tableView that the data has been deleted.
Below is some code showing a working example:
UIContextualAction(style: .destructive, title: "Delete") { [weak self] (_, _, completion) in
if self?.canDelete(indexPath) { // We can actually delete
// remove the object from the data source
self?.myData.remove(at: indexPath.row)
// delete the row. Without deleting the row or reloading the
// tableview, the index will be off in future swipes
self?.tableView?.deleteRows(at: [indexPath], with: .none)
// Let the action know it was a success. In this case the
// tableview will animate the cell removal with the swipe
completion(true)
} else { // We can't delete for some reason
// This resets the swipe state and nothing is removed from
// the screen visually.
completion(false)
}
}
Then I need to reload the tableview or call deleteRows in order to have the indexPath be properly computed on the next swipe.
If I have 10 rows and I swipe the 5th one to delete, every one after that will be off by one row unless the tableview is reloaded or the tableview is told of the row being removed in some way.
I can't reproduce this issue in iOS 13. Either the behavior in iOS 12 and before was a bug or it has simply been withdrawn (perhaps because it was confusing).
In iOS 13, if you just return from a .destructive action by calling completion(true) without doing anything, nothing happens and that's the end of the matter. The cell is not deleted for you.
I agree with the answer by Kris Gellci, but notice that if you are using a NSFetchedResultsController it may complicate things. It seems that for a destructive UIContextualAction the call completion(true) will delete the row, but so may the NSFetchedResultsController's delegate. So you can easily end up with errors in that way. With NSFetchedResultsController I decided to call completion(false) (to make the contextual menu close), regardless of whether the action was a success or not, and then let the delegate take care of deleting the table row if the corresponding object has been deleted.
Use the below flag for disabling full swipe.
performsFirstActionWithFullSwipe
By default it will automatically performs the first action which is configured in UISwipeActionsConfiguration. so if you want to disable full swipe delete then set "performsFirstActionWithFullSwipe" as false.
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let test = UIContextualAction(style: .destructive, title: "test") { (action, view, completion) in
// Your Logic here
completion(true)
}
let config = UISwipeActionsConfiguration(actions: [test])
config.performsFirstActionWithFullSwipe = false
return config
}
Hope this will solve your problem.
I’m using NSFetchedResultsController and DATAStack. My NSFetchedResultsController doesn’t update my table if I make any changes in another contexts.
I use dataStack.mainContext in my NSFetchedResultsController. If I do any updates from mainContext everything is okay. But if I write such code for example:
dataStack.performInNewBackgroundContext({ (backgroundContext) in
// Get again NSManagedObject in correct context,
// original self.task now in main context
let backTask = backgroundContext.objectWithID(self.task.objectID) as! CTask
backTask.completed = true
let _ = try? backgroundContext.save()
})
NSFetchedResultsController doesn’t update cells. I will see updated data if I reload application or whole view for example.
There is no error in data saving (I’ve checked).
I’ve tried to check NSManagedObjectContextDidSaveNotification is received, but if I do .performFetch() after notification is received I again get old data in table.
Finally I’ve got it, it’s not context problem.
I’ve used such type of code in didChangeObject func in NSFetchedResultsControllerDelegate if NSFetchedResultsChangeType is Move (that event was generated because of sorting I think):
guard let indexPath = indexPath, newIndexPath = newIndexPath else { return }
_tableView?.moveRowAtIndexPath(indexPath, toIndexPath: newIndexPath)
And that’s incorrect (I don’t know why NSFetchedController generates move event without «reload data event» if data was changed), I’ve replaced it with next code and everything now is working.
guard let indexPath = indexPath, newIndexPath = newIndexPath else { return }
_tableView?.deleteRowsAtIndexPaths([indexPath], withRowAnimation: UITableViewRowAnimation.Fade)
_tableView?.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: UITableViewRowAnimation.Fade)
It isn't documented terribly well what the different background context options should be used for and how. Likely worth raising an issue / submitting a PR.
Your issue is that performInNewBackgroundContext doesn't merge changes into the main context. This is because the context hierarchy isn't prescribed, so you can choose what you want to do.
So, you can either monitor the save and merge the changes yourself. Or you could set the parent context. Or you could use newBackgroundContext (which does merge changes automatically, even though that's a bit weird...).
Below is a link to download a simplified version of my app that has the exact same problem. The plus "Add" button at the top adds a new record that is set at name = 1, qty = 1, and section = 1. Selecting a Cell increments them all to the next number. You can see that both the name and qty update, but the section never updates until you quit the app and start it again.
DropBox Download Link
I have the following relationship setup in CoreData:
In in my TableViewController, I am creating my FetchRequestController (frc) with the following code:
func fetchRequest() -> NSFetchRequest {
let fetchRequest = NSFetchRequest(entityName: "Items")
let sortDesc1 = NSSortDescriptor(key: "catalog.sections.section", ascending: true)
let sortDesc2 = NSSortDescriptor(key: "isChecked", ascending: true)
let sortDesc3 = NSSortDescriptor(key: "catalog.name", ascending: true)
fetchRequest.sortDescriptors = [sortDesc1, sortDesc2, sortDesc3]
return fetchRequest
}
func getFCR() -> NSFetchedResultsController {
frc = NSFetchedResultsController(fetchRequest: fetchRequest(), managedObjectContext: moc, sectionNameKeyPath: "catalog.sections.section" , cacheName: nil)
return frc
}
So as shown, I'm preforming the fetch request on the Item Entity, and sorting by attributes in both the Catalog and Sections entities. And specifically to my problem, I have the sections key in my frc as the section attribute in the Sections Entity (which is related through the Catalog Entity).
When I'm updating various parts of the Item or Catalog I see the table cell update correctly (i.e. the didChangeObject event is called)
But if I change the section it never updates unless I completely back out of the table and then reenter it. (i.e. the didChangeSection event is never called even though the section is changing)
Below is the code I'm using to edit a pre-existing Item Record.
func editItem() {
let item: Items = self.item!
item.qty = Float(itemQty.text!)
item.catalog!.name = Util.trimSpaces(itemName.text!)
item.catalog!.brand = Util.trimSpaces(itemBrand.text!)
item.catalog!.qty = Float(itemQty.text!)
item.catalog!.price = Util.currencyToFloat(itemPrice.text!)
item.catalog!.size = Util.trimSpaces(itemSize.text!)
item.catalog!.image = UIImageJPEGRepresentation(itemImage.image!, 1)
if (self.section != nil) {
item.catalog!.sections = self.section
}
do {
try moc.save()
} catch {
fatalError("Edit Item save failed")
}
if (itemProtocal != nil) {
itemProtocal!.finishedEdittingItem(self, item: self.item!)
}
}
Just to note, when I add in a new record into the Item Entity, the didChangeObject and didChangeSection events are both properly called. Only when editing them is didChangeSection getting skipped or missed.
Just for completion, below is my code I'm using for didChangeObject and didChangeSection.
func controllerWillChangeContent(controller: NSFetchedResultsController) {
tableView.beginUpdates()
}
func controllerDidChangeContent(controller: NSFetchedResultsController) {
tableView.endUpdates()
}
func controller(controller: NSFetchedResultsController, didChangeSection sectionInfo: NSFetchedResultsSectionInfo, atIndex sectionIndex: Int, forChangeType type: NSFetchedResultsChangeType) {
switch type {
case NSFetchedResultsChangeType.Update:
self.tableView.reloadSections(NSIndexSet(index: sectionIndex), withRowAnimation: UITableViewRowAnimation.Automatic)
case NSFetchedResultsChangeType.Delete:
self.tableView.deleteSections(NSIndexSet(index: sectionIndex), withRowAnimation: UITableViewRowAnimation.Automatic)
case NSFetchedResultsChangeType.Insert:
self.tableView.insertSections(NSIndexSet(index: sectionIndex), withRowAnimation: UITableViewRowAnimation.Automatic)
case NSFetchedResultsChangeType.Move:
self.tableView.deleteSections(NSIndexSet(index: sectionIndex), withRowAnimation: UITableViewRowAnimation.Automatic)
self.tableView.insertSections(NSIndexSet(index: sectionIndex), withRowAnimation: UITableViewRowAnimation.Automatic)
}
}
func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
switch type {
case NSFetchedResultsChangeType.Update:
self.tableView.reloadRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Automatic)
case NSFetchedResultsChangeType.Delete:
self.tableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Automatic)
case NSFetchedResultsChangeType.Insert:
self.tableView.insertRowsAtIndexPaths([newIndexPath!], withRowAnimation: UITableViewRowAnimation.Fade)
case NSFetchedResultsChangeType.Move:
self.tableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Automatic)
self.tableView.insertRowsAtIndexPaths([newIndexPath!], withRowAnimation: UITableViewRowAnimation.Automatic)
}
}
When I googled this issue, I found that others have had problems similar to this and it seems to be a feature (or bug) of the frc and how Xcode handles relationships. Basically, the frc is only watching the Item Entity, and when the Section Entity changes, it doesn't register with the frc. People have suggested various hacks as well, but so far none of them seem to be working for me. Examples are to do something like this item.catalog.sections = item.catalog.sections
None of the examples had the section key as a related entity, so I'm not sure if that is why they aren't working for me.
So my question is if is there some way to tell didChangeSection to execute and send it the proper NSFetchedResultsChangeType? Or even better yet, is there some way to "encourage" the frc to notice what is happening in the Section Entity that is related to the Item Entity through the Catalog Entity.
After playing with this a little, it seems the didChangeSection is only fired if the first relationship named in the sectionNameKeyPath is directly modified (ie. in this case, if you create a new Catalog linked to the correct section, and set item.catalog = newCatalog). But I think that is too convoluted as a work-around.
One solution would be to change your FRC to fetch the Catalog objects instead of Items. Since they map one-one, the table view should retain the same structure. The key changes are:
func fetchRequest() -> NSFetchRequest {
let fetchRequest = NSFetchRequest(entityName: "Catalog")
let sortDesc1 = NSSortDescriptor(key: "sections.section", ascending: true)
let sortDesc2 = NSSortDescriptor(key: "name", ascending: true)
fetchRequest.sortDescriptors = [sortDesc1, sortDesc2]
return fetchRequest
}
and
func getFCR() -> NSFetchedResultsController {
frc = NSFetchedResultsController(fetchRequest: fetchRequest(), managedObjectContext: moc, sectionNameKeyPath: "sections.section" , cacheName: nil)
return frc
}
Then modify the references to frc to reflect this change:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath) as! TableViewCell
let catalog: Catalog = frc.objectAtIndexPath(indexPath) as! Catalog
cell.nameLbl.text = "Item #\(catalog.name!)"
cell.qtyLbl.text = "Qty: \(catalog.items.qty!.stringValue)"
return cell
}
and
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let catalog: Catalog = frc.objectAtIndexPath(indexPath) as! Catalog
var qty: Int = Int(catalog.items.qty!)
qty = qty + 1
catalog.items.qty = qty
var name: Int = Int(catalog.name!)
name = name + 1
catalog.name = name
var sec: Int = Int(catalog.sections.section!)
sec = sec + 1
var section: Sections?
if (checkSectionName(sec, moc: self.moc) == false) {
let entityDesc = NSEntityDescription.entityForName("Sections", inManagedObjectContext: self.moc)
section = Sections(entity: entityDesc!, insertIntoManagedObjectContext: self.moc)
section!.section = sec
} else {
section = returnSection(sec, moc: self.moc)
}
catalog.sections = section
do {
try moc.save()
} catch {
fatalError("Edit item save failed")
}
}
Because you are directly modifying the sections property of the catalog object, this will trigger the didChangeSection method. This still feels to me like a bit of a hack, but since the FRC is not behaving as one would like, a hack might be a necessary evil.
I have run into a similar situation and I ended up creating a second NSFetchedResultsController for the other type, and just listened to it for change events and updated my view as appropriate. Not an ideal solution, since it requires some manual coordination and keeping of some meta data, but it does get the job done.
This is really an extension of the answer from #CharlesA
As you have found the FRC doesn't observe anything other than its associated entity for changes. This isn't a feature or a bug, it's an implementation detail because it covers a large percentage of usage and the code required to analyse the graph for arbitrary depth changes is complex. In addition to that the delegate callbacks for a single change merged into the context could be extremely complex and effectively impossible to apply to the table with animations.
So, you really need to change your approach. The answer from Charles using a second FRC requires that the second FRC is used to instruct the first FRC to re-execute it's fetch and the fully reload the table view. You need to re-execute the fetch because otherwise your data source is wrong and there's no other way to update it. Technically you could try to apply the changes to the table with animation, depending on what you know / guarantee about how the changes are made and saved to the context it could work. Like if the user can only change 1 section at a time and it's auto-saved. And that no compound changes are made on a background thread.
The answer from #pbasdf is also a suitable alternative, but it will still only deal with relationship changes, not changes to the values in the objects at the end of those relationships - this is a key distinction you need to understand and appreciate when using FRCs.