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.
Related
something goes wrong when trying to update rows of tableview after delete of Firebase data.
Below is method I use.
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let delete = UITableViewRowAction(style: .destructive, title: "Delete") { (action, indexPath) in
let cell = self.messages[indexPath.row]
let b = cell.msgNo
let action = MyGlobalVariables.refMessages.child(MyGlobalVariables.uidUser!)
action.queryOrdered(byChild: "msgNo").queryEqual(toValue: b).observe(.childAdded, with: { snapshot in
if snapshot.exists() { let a = snapshot.value as? [String: AnyObject]
let autoId = a?["autoID"]
action.child(autoId as! String).removeValue()
self.messages.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
} else {
print("snapshot empty")
}}) }
...
return [delete, edit, preview]
}
Initially I checked whole logic without including line /*action.child(autoId as! String).removeValue()*/ then it works normally and removes rows as should be. But once I add this line it removes data from Firebase but tableview is updated in strange way by adding new rows below existing
My guess is that somewhere else in your application you have code like action .observe(.value, which shows the data in the table view. When you delete a node from the database, the code that populates the database gets triggered again, and it adds the same data (minus the node that you removed) to the table view again.
When working with Firebase it's best to follow the command query responsibility segregation principle, meaning that you keep the code that modifies the data completely separate from the flow that displays the data. That means that your code that deletes the data, should not try to update the table view. So something more like:
let action = MyGlobalVariables.refMessages.child(MyGlobalVariables.uidUser!)
action.queryOrdered(byChild: "msgNo").queryEqual(toValue: b).observe(.childAdded, with: { snapshot in
if snapshot.exists() { let a = snapshot.value as? [String: AnyObject]
let autoId = a?["autoID"]
action.child(autoId as! String).removeValue()
} else {
print("snapshot empty")
}}) }
All the above does is remove the selected message from the database.
Now you can focus on your observer, and ensuring it only shows the messages once. There are two options for this:
Always clear self.messages when your .value completion handler gets called before you add the messages from the database. This is by far the simplest method, but may cause some flicker if you're showing a lot of data.
Listen to the more granular messages like .childAdded and .childRemoved and update self.messages based on those. This is more work in your code, but will result in a smoother UI when there are many messages.
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?
I have a large table designed to update a customers data protection preferences.
Some of the table is populated with reusable cells that contain a variable number of checkboxes, and depending on the json returned from the server, some of these checkboxes may be pre-checked.
When I pass the pre-checked state to the cell from tableView cellForRowAt all is well (checkboxes that are pre-checked are pre-checked). The problem I have is that these are reusable cells, and after a user has changed their selections, scrolling up or down the table triggers more calls to the setupCell function, which then resets the checkboxes to their original pre-checked state.
So, the question I have is...
What are the options for me to preserve a user’s selections after they have scrolled a table with resuable cells?
The switch statement in setupCell currently sets the pre-selections with the call to updateSelections(). Obviously this is the cause of the issue and I'm not entirely happy with placing logic directly in the cell anyway, but where is the best place to perform this logic only once? Or, is using reusable cells the wrong approach entirely to have pre-selections?
Any suggestions welcome. Here's a small code snippet to illustrate the point:
// UITableViewDataSource - passing the previous selections to setupCell in the UITableViewCell
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let viewModel = viewModels[indexPath.row]
switch viewModel {
case .preferences(let preferenceId, let titleText, let isEnabled):
guard let cell = tableView.dequeueReusableCell(withIdentifier: "Preferences") as? MarketingChannelPreferencesTableViewCell else {
return UITableViewCell()
}
cell.setupCell(id: id, text: text, isPreChecked: isPreChecked)
return cell
}
}
// UITableViewCell
func setupCell(id: String, text: String, isPreChecked: Bool) {
switch id {
case "email":
emailSelected = isPreChecked
updateSelections(id: id, isPreChecked: emailSelected)
case "post":
postSelected = isPreChecked
updateSelections(id: id, isPreChecked: postSelected)
case "text":
textSelected = isPreChecked
updateSelections(id: id, isPreChecked: textSelected)
default:
break
}
}
viewModels hold the information needed to setup each cell right? And you receive the viewModels from the service?
If so, when the user changes a specific checkbox, you should update the according viewModel. Thus, when you call setupCell inside cellForRowAt you should pass the updated info of each viewModel, resulting in the correct state of each checkbox.
You should make some action method for your checkbox buttons that you put on the MarketingChannelPreferencesTableViewCells and change your viewModels based on changing the value of these checkboxes. So when the cells data reload with user scrolling, cells show the new datas of viewModel
There're several ways.
I've created a small project maybe it will give you an solution to the problem.
https://drive.google.com/open?id=1d_RFdr6luNvRTdSC6XNE2vRWTi2IRyuT
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...).
I got a validation function that loop through my table view, the problem is that it return nil cell at some point.
for var section = 0; section < self.tableView.numberOfSections(); ++section {
for var row = 0; row < self.tableView.numberOfRowsInSection(section); ++row {
var indexPath = NSIndexPath(forRow: row, inSection: section)
if section > 0 {
let cell = tableView.cellForRowAtIndexPath(indexPath) as! MyCell
// cell is nil when self.tableView.numberOfRowsInSection(section) return 3 for row 1 and 2
// ... Other stuff
}
}
}
I'm not really sure what I'm doing wrong here, I try double checking the indexPath row and section and they are good, numberOfRowsInSection() return 3 but the row 1 and 2 return a nil cell... I can see my 3 cell in the UI too.
Anybody has an idea of what I'm doing wrong?
My function is called after some tableView.reloadData() and in viewDidLoad, is it possible that the tableview didn't finish reloading before my function is executed event though I didn't call it in a dispatch_async ??
In hope of an answer.
Thank in advance
--------------------------- Answer ------------------------
Additional explanation :
cellForRowAtIndexPath only return visible cell, validation should be done in data model. When the cell is constructed in
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
It should change itself according to the validation state.
As stated in the documentation, cellForRowAtIndexPath returns:
An object representing a cell of the table, or nil if the cell is not visible or indexPath is out of range.
Hence, unless your table is fully displayed, there are some off screen rows for which that method returns nil.
The reason why it returns nil for non visible cells is because they do not exist - the table reuses the same cells, to minimize memory usage - otherwise tables with a large number of rows would be impossible to manage.
So, to handle that error just do optional binding:
// Do your dataSource changes above
if let cell = tableView.cellForRow(at: indexPath) as? MyTableViewCell {
// yourCode
}
If the cell is visible your code got applied or otherwise, the desired Cell gets reloaded when getting in the visible part as dequeueReusableCell in the cellForRowAt method.
I too experienced the issue where cellForRowAtIndexPath was returning nil even though the cells were fully visible. In my case, I was calling the debug function (see below) in viewDidAppear() and I suspect the UITableView wasn't fully ready yet because part of the contents being printed were incomplete with nil cells.
This is how I got around it: in the viewController, I placed a button which would call the debug function:
public func printCellInfo() {
for (sectionindex, section) in sections.enumerated() {
for (rowIndex, _) in section.rows.enumerated() {
let cell = tableView.cellForRow(at: IndexPath(row: rowIndex, section: sectionindex))
let cellDescription = String(describing: cell.self)
let text = """
Section (\(sectionindex)) - Row (\(rowIndex)): \n
Cell: \(cellDescription)
Height:\(String(describing: cell?.bounds.height))\n
"""
print(text)
}
}
}
Please note that I'm using my own data structure: the data source is an array of sections, each of them containing an array of rows. You'll need to
adjust accordingly.
If my hypothesis is correct, you will be able to print the debug description of all visible cells. Please give it a try and let us know if it works.