Weird animation when deleting item from UICollectionView while UIContextMenu is shown - ios

I'm using UIContextMenuInteraction to show a context menu for UICollectionView as follows:
func collectiovnView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { _ in
let deleteAction = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
self.deleteItem(at: indexPath)
}
return UIMenu(title: "Actions", children: [deleteAction])
})
}
func deleteItem(at indexPath: IndexPath) {
self.collectionView.performBatchUpdates({
self.items.remove(at: indexPath.item)
self.collectionView.deleteItems(at: [indexPath])
})
}
Everything works well, but when I tap the "Delete" item, a weird animation happens where the deleted item stays in its place while other items are moving, and then it disappears instantly. And sometimes I even see an empty space or a random item for fraction of a second before the new item appears.
If I call collectionView.deleteItems() while the context menu isn't shown the deletion animation works as expected.

It looks like the weird animation is a result of a conflict between two animations that run at almost the same time:
Deletion animation: When "Delete" item is tapped, collectionView.deleteItems() is called and the specified collection item is deleted with animation.
Menu dismiss animation: After the menu item is tapped, the context menu is also dismissed with another animation that shows the deleted item for a fraction of a second.
This looks like a bug that should be fixed by Apple. But as a workaround, I had to delay the deletion until the dismiss animation completed:
func deleteItem(at indexPath: IndexPath) {
let delay = 0.4 // Seconds
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.collectionView.performBatchUpdates({
self.items.remove(at: indexPath.item)
self.collectionView.deleteItems(at: [indexPath])
})
}
}
The 0.4 seconds is the shortest delay that worked for me.

Related

UITableView Destructive Context Menu Animation

I'm adding iOS13 context menus to my table view. One of the menu actions allows the user to delete the item:
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions in
let deleteAction = UIAction(title: "Delete", image: UIImage(systemName: "trash.fill"), identifier: nil, discoverabilityTitle: "", attributes: UIMenuElement.Attributes.destructive) { action in
self.data.remove(at: indexPath.row)
//Remove from the table.
self.tableView.deleteRows(at: [indexPath], with: .automatic)
}
return UIMenu(title: "", children: [deleteAction])
}
}
I'm using the default preview view controller (so it just shows the cell). I'm currently seeing a weird animation artifact where the context menu preview is displayed while the items below the row being removed animated up, then the preview fades away to white (so it looks like there is a blank row in the list), then the table repaints and displays the item that was covered up.
This is using the default cell but it looks a lot worse when using a customized cell with a lot more information. Is there anyway to make this action animate better?
I ran into this problem as well. The bug is probably due to the fact that the original cell used to generate the preview was deleted, moved or changed.
The solution I found was to implement the delegate method tableView(_:previewForHighlightingContextMenuWithConfiguration:), passing it the original cell as the view, but customising UIPreviewParameters to use UIColor.clear:
override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
guard let indexPath = configuration.identifier as? IndexPath, let cell = tableView.cellForRow(at: indexPath) else {
return nil
}
let parameters = UIPreviewParameters()
parameters.backgroundColor = .clear
return UITargetedPreview(view: cell, parameters: parameters)
}
In order to identify the original cell in this delegate method, you'd need a way to identify it. One way is to set the indexPath as the identifier to UIContextMenuConfiguration, like:
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { _ in
return UIMenu(title: "", children: [
UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { action in
self.data.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
}
])
}
}
However if your data can change between the presentation of the context menu and the action, then you need a more robust way to identify it.
I did not have to implement tableView(_:previewForDismissingContextMenuWithConfiguration:) for this to work.

Animation Glitch When Deleting UICollectionViewDiffableDataSource Item From Context Menu

I have adopted the new UICollectionViewDiffableDataSource. I am applying a datasource snapshot everytime I delete an item:
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
snapshot.appendSections([0])
snapshot.appendItems(items)
apply(snapshot, animatingDifferences: true)
The delete is offered through the built in collection view configuration option:
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return nil
}
let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash.fill"), attributes: .destructive) { _ in
self.deleteItem(item)
}
return UIMenu(title: "", image: nil, identifier: nil, children: [delete])
}
return configuration
}
If I delete the item from outside the context menu, the animation works great. If I delete from the context menu then one cell disappears and then causes the next to flash. I suspect there is some sort of conflict between closing the context menu & running the delete animation. I'm looking for a way to work around this.
edit: This was very stable resulting in freezing of the UI and other weird glitches where it sometimes didn't respond to long-press, do not use this. I wish this API didn't have that glitch, it's very annoying.
I found this ugly glitch too and was very close on giving up until I thought I'd give it a shot implementing the other interaction API instead of the built-in on UICollectionView, weirdly enough the animation went away.
First add the interaction to your cells contentView
let interaction = UIContextMenuInteraction(delegate: self)
cell.contentView.addInteraction(interaction)
Then implement the delegate, presumably where you present the cell, in my case in the viewController
extension ViewController : UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
// loop through all visible cells to find which one we interacted with
guard let cell = collectionView.visibleCells.first(where: { $0.contentView == interaction.view }) else {
return nil
}
// convert it to an indexPath
guard let indexPath = collectionView.indexPath(for: cell) else {
return nil
}
// continue with your magic!
}
}
And voila, that's it, no more glitchy animation.
I don't know if there's some edge cases where wrong cell will be selected or other weird stuff, but this seems to work just fine.

Removing a cell from a UICollectionView has a weird animation

I have a weird animation/behaviour when removing an item from a collection view. It happens only when I have two items and that I remove the second item first. Here are two gif.
The first gif is the expected animation, it happens when I delete the first item.
The second gif is the wrong behaviour, it looks like the entire collection view is being reloaded:
Here is the method called to remove the item when clicking on the button:
#objc func removeCell(_ notification: NSNotification) {
collectionView.performBatchUpdates({ () -> Void in
let indexPath = IndexPath(row: itemIndex, section: 0)
let indexPaths = [indexPath]
viewModels.remove(at: itemIndex)
collectionView.deleteItems(at: indexPaths)
pageControl.numberOfPages = min(10, viewModels.count)
tableViewDelegate?.performTableViewBatchUpdates(nil, completion: nil)
})
}
Thank you for your help!

How I can reload tableview data after the animation of fade swipeAction completed

I use SwipeCellKit for do swipe actions for my tableview.
I try to do the left swipe for check or unckech for accessoryType of my cell and everything work fine, but after i press check the tableview reload the data immediatelly and i can't see the animation of roollback the check button. So i want to ask how i can call reload data after this animation end.
I have something like this:
I have something like this:
But i want fade animation like this
I want this animation
My code:
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath, for orientation: SwipeActionsOrientation) -> [SwipeAction]? {
var action = super.tableView(tableView, editActionsForRowAt: indexPath, for: orientation) ?? []
guard orientation == .left else { return action }
let checkAction = SwipeAction(style: .destructive, title: nil) { action, indexPath in
self.completeItem(at: indexPath)
}
action.append(checkAction)
return action
}
private func completeItem(at indexPath: IndexPath){
if let item = itemArray?[indexPath.row]{
do{
try realm.write {
item.done = !item.done
}
}
catch{
print( error.localizedDescription)
}
}
tableView.reloadData()
}
In order to hide the action on select, you need to set the hidesWhenSelected property for the action.
checkAction.hidesWhenSelected = true
Also, the example doesn't reload the tableView. Instead, they use an animation to remove the dot. You will need to manually delay your reload until the hide animation is complete.
See line 156 for the property
See lines 256 - 273 for the animation
https://github.com/SwipeCellKit/SwipeCellKit/blob/develop/Example/MailExample/MailTableViewController.swift

When deleting a row, the edit action part ignores the UITableViewRowAnimation setting

If I side swipe on a table view row to delete it then the row does not dismiss as a complete entity - by this I mean the edit action portion of the cell always dismisses itself by sliding upwards, but the rest of the cell dismisses itself according to whatever value has been set for UITableViewRowAnimation.
So for example if the row below is deleted using deleteRows(... .left) then the white portion of the row will slide off to the left of the screen but the UNBLOCK part behaves separately - it always slides up the screen, irrespective of the UITableViewRowAnimation value used in deleteRows.
Code is as follows:
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let deleteAction = UITableViewRowAction(style: .default, title: "UNBLOCK") { action, index in
self.lastSelectedRow = indexPath
let callerToUnblock = self.blockedList[indexPath.row]
self.unblockCaller(caller: callerToUnblock)
}
deleteAction.backgroundColor = UIColor.tableViewRowDeleteColor()
return [deleteAction]
}
func unblockCaller(caller:Caller) {
DispatchQueue.global(qos: .background).async {
Model.singleton().unblockCaller(caller) {(error, returnedCaller) -> Void in
if error == nil {
DispatchQueue.main.async {
if let lastSelectedRow = self.lastSelectedRow {
self.tableView.deleteRows(at: [lastSelectedRow], with: .left)
}
}
...
Does anybody know how to make the whole row move consistently together?
Correct. These two objects behave as separate entities. You cannot control the animation of the UITableViewRowAction button as it drawn outside of the UITableViewCell frame.
See the UITableViewCellDeleteConfirmationView layout in this Captured View Hierarchy. (Notice how the UITableViewCell frame, in blue, doesn't encompass the UITableViewCellDeleteConfirmationView, in red):
Fix
Hide the editing button prior deleting the cell. It will be erased prior the cell deletion animation, before the rest of the cells scroll upward.
func unblockCaller(caller:Caller, indexPath:IndexPath) {
// Perform the deletion
// For example: self.blockedList.remove(at: indexPath.row)
// if error == nil...
DispatchQueue.main.async {
self.tableView.setEditing(false, animated: false)
self.tableView.deleteRows(at: [indexPath], with: .left)
}
}
Additional Comments
I recommend not using state variables to keep track of which cell must be deleted (self.lastSelectedRow = indexPath). Pass the path to the worker method instead for a better isolation of the tasks.
Use the standard appearance whenever possible ; if the UNBLOCK button is but a red control, prefer the style: .destructive and avoid custom color schemes. The editActionsForRowAt becomes a single line:
self.unblockCaller(caller: self.blockedList[indexPath.row], indexPath: indexPath)

Resources