UITableView - Deleting rows with animation - swift

I'm trying to delete row from tableview using animation however in most cases it gets stuck. 1 in 15 tries will result in this animation being played. This is what my delete action looks like:
func contextualDeleteAction(forRowAtIndexPath indexPath: IndexPath) -> UIContextualAction {
let action = UIContextualAction(style: .destructive,
title: "Delete") { (contextAction: UIContextualAction, sourceView: UIView, completionHandler: (Bool) -> Void) in
Model().context.delete(notesArray[indexPath.row])
notesArray.remove(at: indexPath.row)
self.myTableView.deleteRows(at: [indexPath], with: UITableView.RowAnimation.bottom)
Model().saveItems()
}
action.title = "Delete"
return action
}
This is what it looks like when it gets stucked when swiping.
When pressing delete button instead.
I've also tried to use tableView.beginUpdate() and tableView.endUpdate() but didn't get different result.

The problem is that you have forgotten your primary duty in this method: you must call the completion handler!
Model().context.delete(notesArray[indexPath.row])
notesArray.remove(at: indexPath.row)
self.myTableView.deleteRows(at: [indexPath], with: UITableView.RowAnimation.bottom)
Model().saveItems()
completionHandler(true) // <-- look!

In Swift 4, you can perform a delete animation just like the iPhone Messages animation.
Use this:
tableView.deleteRows(at: [indexPath], with: UITableViewRowAnimation.automatic)

Related

Wait for UITableView to finish insert

I'm trying to insert a new row, and immediately make it the first responder.
Using .insertRows() seems to execute asynchronously, even with .none. for the animation. That means that when I call cellForRow(at:), it returns nil.
What would be the best way to wait for the insert to finish, before calling becomeFirstResponder?
This does not work:
self.tableView.insertRows(at: [newIndex], with: .none)
self.tableView.cellForRow(at: newIndex)?.becomeFirstResponder() //Returns nil
Put the insertRows(at:with:) call between a beginUpdates() and endUpdates() calls:
self.tableView.beginUpdates()
self.tableView.insertRows(at: [newIndex], with: .none)
self.tableView.endUpdates()
self.tableView.cellForRow(at: newIndex)?.becomeFirstResponder()
Anyway I'm not so fond of letting as first responder a cell in this way: what if it's not visible?
Why do you have to use .insertRaws()? I mean, why don't you just call reloadData() after adding new item to array?
Note: I updated the code and tested in Xcode. Thanks vadian and Will.
array.append(newItem)
tableView.reloadData() // It is required.
let newIndex = IndexPath(row: array.count - 1, section: 0)
let cell = tableView.cellForRow(at: newIndex)!
cell.becomeFirstResponder()
// If animation is needed
let transition = CATransition()
transition.type = CATransitionType.reveal
cell.layer.add(transition, forKey: nil)

TableView cell: Constraint change conflicts tableView.endUpdates()

I have a messaging application written in Swift.
It does have message bubbles: if the message is longer than 200 chars it is being shortened.
Whenever the user clicks on a message it gets selected:
If the message was shortened, I replace the text with the original long text: Therefore I need to call tableView.beginUpdates() and tableView.endUpdates()
Plus I have to change the timeLabel's height constraint with UIView.animate()
But the two seems to conflict each other, and makes a weird animation: (watch the end)
https://youtu.be/QLGtUg1AmFw
Code:
func selectWorldMessage(indexPath: IndexPath) {
if let cell = tableView.cellForRow(at: indexPath) as? WorldMessageCell {
cell.messageLabel.text = data[indexPath.row].originalMessage
self.lastContentOffsetY = self.tableView.contentOffset.y
self.tableView.beginUpdates()
UIView.animate(withDuration: duration) {
cell.timeLabelHeightConstraint.constant = 18
cell.layoutIfNeeded()
}
self.tableView.endUpdates()
self.lastContentOffsetY = nil
cell.layoutIfNeeded()
WorldMessageIdsStore.shared.nearby.saveCellHeight(indexPath: indexPath, height: cell.frame.size.height, expanded : true, depending: indexPath.section == 1 ? true : false )
}
}
#objc func deselectSelectedWorldMessage(){
if (currentSelectedIndexPath == nil){
return
}
let indexPath = currentSelectedIndexPath!
tableView.deselectRow(at: indexPath, animated: false)
if let cell = tableView.cellForRow(at: indexPath) as? WorldMessageCell {
cell.messageLabel.text = data[indexPath.row].shortenedMessage
self.lastContentOffsetY = self.tableView.contentOffset.y
self.tableView.beginUpdates()
UIView.animate(withDuration: duration) {
cell.timeLabelHeightConstraint.constant = 0
cell.layoutIfNeeded()
}
self.tableView.endUpdates()
self.lastContentOffsetY = nil
cell.layoutIfNeeded()
}
currentSelectedIndexPath = nil
}
Is there a way to animate cell height change & constraint change in the same time?
I can not place self.tableView.beginUpdates() and self.tableView.endUpdates() in the UIView.animate(){ ... } part, because it would cause the new appearing rows to flicker.
.
.
.
UPDATE 1
So If I palce the self.tableView.beginUpdates() and self.tableView.endUpdates() inside the UIView.animate(){ ... }, then the animation works fine. But as I mentioned it causes flicker when new rows are appearing.
Video:
https://youtu.be/8Sex3DoESkQ
UPDATE 2 !!
So If I set the UIview.animate's duration to 0.3 everything works fine. I don't really understand why.
This animation can be sticky.
I think your main problem here is bubble shrinking more then it should. If you run your animation in slow animation mode (By the way, the simulator has this option, very useful for debugging) you will notice:
To sort this out, you can make your label vertical content compression resistants
higher, and check label has top and bottom constraints with Hi priority too, so it would not get cut this way.
https://medium.com/#abhimuralidharan/ios-content-hugging-and-content-compression-resistance-priorities-476fb5828ef
Try using the following code to overcome flicking problem
tableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true)
Alternative solution
try using this handsome method to update your cell with animation instead of beginUpdates() and endUpdates() methods
tableView.reloadRows(at: [indexPath], with: UITableView.RowAnimation.bottom )

UIContextualAction with destructive style seems to delete row by default

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.

NSFetchedResultsControllerDelegate returning wrong NSFetchedResultsChangeType

Here is my implementation of the delegate:
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.beginUpdates()
ItemListTableViewController.logger.log("begin updates")
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.endUpdates()
ItemListTableViewController.logger.log("end updates")
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
ItemListTableViewController.logger.log(type.rawValue, "\(indexPath) -> \(newIndexPath)")
switch type {
case .delete:
self.tableView.deleteRows(at: [indexPath!], with: .automatic)
case .insert:
self.tableView.insertRows(at: [newIndexPath!], with: .automatic)
case .move:
self.tableView.moveRow(at: indexPath!, to: newIndexPath!)
case .update:
self.tableView.reloadRows(at: [indexPath!], with: .automatic)
}
}
And here are the log I got:
ItemListTableViewController >>> "perform insert start"
ItemListTableViewController >>> "begin updates"
ItemListTableViewController >>> 1 "nil -> Optional([0, 0])"
ItemListTableViewController >>> 4 "Optional([0, 1]) -> Optional([0, 2])"
ItemListTableViewController >>> "end updates"
ItemListTableViewController >>> "perform insert end"
I'm trying to insert a new item into the context by calling context.insert(item), and a new item inserted according to the 3rd line of log. And the controller moves some item according to the 4th line. But the type of raw value '4' in NSFetchedResultsChangeType should be update but not move.
I also have tested for other cases, when I need to update an item, it gave me a move type.
Am I wrong about the meaning of the update and the move? Or it's a bug?
I was wrong. It was a bad code.
I'm using Xcode 8.3.1
Thank to #Joe Rose, I understand how this works. I'm not dealing with tasks that may insert and move in same time, so I didn't use your solution.
I was wrong about what update and move means. So I read Apple's doc again. It said:
Changes are reported with the following heuristics:
On add and remove operations, only the added/removed object is reported.
It’s assumed that all objects that come after the affected object are also moved, but these moves are not reported.
A move is reported when the changed attribute on the object is one of the sort descriptors used in the fetch request.
An update of the object is assumed in this case, but no separate update message is sent to the delegate.
An update is reported when an object’s state changes, but the changed attributes aren’t part of the sort keys.
So when move is reported, update is reported too. At that time, the fetched objects are updated, but tableView not, so I need to use indexPath to locate the cell, and newIndexPath to get the proper object.
This is my final solution and it behaves good for now:
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
ItemListTableViewController.logger.log(type.rawValue, "\(indexPath) -> \(newIndexPath)")
switch type {
case .insert:
self.tableView.insertRows(at: [newIndexPath!], with: .automatic)
case .delete:
self.tableView.deleteRows(at: [indexPath!], with: .automatic)
case .move:
self.tableView.moveRow(at: indexPath!, to: newIndexPath!)
fallthrough
case .update:
self.configure(cell: tableView.cellForRow(at: indexPath!) as! ItemInfoCell, at: newIndexPath!)
}
}
func configure(cell: ItemInfoCell, at indexPath: IndexPath) {
let item = fetchController.object(at: indexPath)
cell.item = item
}
If there is any problem, please tell me.
4 is the enum value for update; 3 is the value for move. You may be confused by the two index paths that are being delegated. indexPath is the index before the update (before any inserts or deletes) and newIndexPath is the index after the updates. Unfortunately Apple's documentation is wrong when it come to how to update using a fetchedResults controller. For update you should be using newIndexPath since the updates are processed after the inserts and deletes. You may be experiencing crashes or bad behavior because of this.
Also the way you are dealing with move is incorrect. See App crashes after updating CoreData model that is being displayed in a UITableView

Why does "Show" Segue Update UITableViewCells but not reloadData()?

I have a problem which iv been dealing with for a long time just never got around to seeing why.
Problem
I have a UItableview and load data into it from Firebase. When I segue "show" from ViewController #1 to ViewController #2 (with the UITableView) the firebase data is loaded into the cells.
Each cell has a UITableViewRowAction "delete" which basically deletes the Firebase information. But when i press the button..it does delete the Firebase information but not the cell. In order for that UITableView to update I have to segue "show" back to any other ViewController and come back to ViewController #2 for it to update.
** This method only updates if there is more than 1 UITableViewCell in the table
Problem #2 - Only 1 UITableViewCell
Now if there is only 1 UITableViewCell loaded and I "delete" it....it never goes away unless I create a new UITableViewCell in ViewController #2.
What have i tried?
self.tableView.endUpdates()
self.tableView.reloadData()
Iv literally plugged reloadData() anywhere I could to see if would reload data but it just wont reload it!
let delete = UITableViewRowAction(style: .Default, title: "Delete", handler: { (action, indexPath) in
self.tableView.dataSource?.tableView?(
self.tableView,
commitEditingStyle: .Delete,
forRowAtIndexPath: indexPath
)
self.tableView.beginUpdates()
let uid = NSUserDefaults.standardUserDefaults().valueForKey("uid") as! String // uid
DataService.dataService.deleteAudio(uid, project: self.sharedRec.projectName, vocalString: self.sharedRec.audioString )
let currURL = self.sharedRec.audioString
let url = "\(currURL).caf"
//Document Directory
let dirPath = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true)[0]
// Full File to Delete Location
let fileURL = dirPath.stringByAppendingPathComponent(url)
// This is to print out all directory files
let documentsUrl = NSFileManager.defaultManager().URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask).first!
do {
tableView.beginUpdates()
print("DELETED LOCAL RECORD")
try NSFileManager.defaultManager().removeItemAtPath(fileURL)
print("file deleted \(fileURL)")
let directoryContents = try NSFileManager.defaultManager().contentsOfDirectoryAtURL(documentsUrl, includingPropertiesForKeys: nil, options: NSDirectoryEnumerationOptions())
print("PRINT LOCAL DIC\(directoryContents)") // NOT WORKING
self.tableView.reloadData()
} catch {
print("error")
}
tableView.deleteRowsAtIndexPaths([NSArray arrayWithObject: indexPath], withRowAnimation: .Automatic) // SHOWS ERROR
self.tableView.endUpdates()
return
})
In function commitEditingStyle delete data from your array, database etc.
Decrement your current row count.
tableView.beginUpdates()
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
tableView.endUpdates()