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.
Related
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.
I Have a details page with collectionView. The collection view has a header view which has some buttons(play buttons).
When the page first opens the focus will be on the play button. after loading the header view we are inserting rail cells for showing related items in the collectionView. But whenever the rails get loaded the focus automatically moves to the rail cell.
There is no focus handling done for collectionView only one is this code
func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool {
return true
}
how can I remove this behavior?
I tried "should update focus" to get this focus change and block it
override func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool {
<#code#>
}
but this function is getting triggered only when focus change is through remote and no automatic focus change (like my case)
Now am handling the situation with
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
if let headerView = getHeaderView(), let collectionViewCell = detailsCollectionView.cellForItem(at: IndexPath(item: 0, section: 0)) as? CollectionViewCell {
let previouslyFocusedView = context.previouslyFocusedView
let nextFocusedView = context.nextFocusedView
if previouslyFocusedView?.isDescendant(of: headerView.playButton) ?? false, nextFocusedView?.isDescendant(of: collectionViewCell.getInnerCollectionView()) ?? false {
cancelFocusChange(previouslyFocusedView: previouslyFocusedView)
}
}
super.didUpdateFocus(in: context, with: coordinator)
}
but I don't think it is a good idea. There must be a reason why this focus change is happening. If anyone knows the answer to this, please do help.
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.
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
I have implement drag and drop between two UICollectionViews. Sometimes I get this weird error.
Assertion failure in -[UICollectionView _beginInteractiveMovementForItemAtIndexPath:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit/UIKit-3698.54.4/UICollectionView.m:8459
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to begin reordering on collection view while reordering is already in progress'
My setup is as follows,
I have two UICollectionViews(e.g. A & B)
Re-ordering is enabled in both collection views
When you drag an item from A to B. The operation is copy
When you drag an item from B to A. The operation is delete from B. A is not effected.
My code so far is as follows(abstract version)
// Drop delegate
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
print(#function)
let destinationIndexPath: IndexPath
if let indexPath = coordinator.destinationIndexPath {
destinationIndexPath = indexPath
self.performDrop(coordinator: coordinator, destinationIndexPath: destinationIndexPath, collectionView: collectionView)
} else if collectionView.tag == CollectionView.timeline.rawValue {
// Get last index path of collection view.
let section = collectionView.numberOfSections - 1
let row = collectionView.numberOfItems(inSection: section)
destinationIndexPath = IndexPath(row: row, section: section)
self.performDrop(coordinator: coordinator, destinationIndexPath: destinationIndexPath, collectionView: collectionView)
}
}
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
print(#function)
if session.localDragSession != nil {
// Trying to drag and drop an item within the app
if collectionView.hasActiveDrag {
// Trying to re-oder within the same collection view
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
} else {
// Trying to copy an item from another collection view
return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
}
} else {
// Trying to drag and drop an item from a different app
return UICollectionViewDropProposal(operation: .forbidden)
}
}
// Drag delegate
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
print(#function)
var item: Item?
if collectionView.tag == CollectionView.media.rawValue {
item = mediaItems[indexPath.row]
} else {
item = StoryManager.sharedInstance.timelineItems[indexPath.row]
}
if let item = item {
let itemProvider = NSItemProvider(object: item.id as NSString)
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = item
return [dragItem]
}
return []
}
Steps to re-produce
Have two UICollectionViews with the above delegate methods implemented
Both should span across the screen
Drag an item from one and try holding on top of the other towards the edge of the screen pretending to drop it
Observe the items re-arranging making room for the new item (to be dropped item)
Slide your finger away from the screen and observe how the it appears as if the drag got cancelled.
Observe that the elements which got re-arranged making room for the new item still stays the same. If you have console logs for the layout delegate methods of the collection view you may observe they keep getting called
Now if you try to drag an item again or try to navigate away from the screen app crashes.
If any of you have any insight to what is happening, that would be of great help.
Ending any interactive movement in the drop location (UICollectionView) when initiating a new drag fixed my problem. I had to modify my UICollectionViewDragDelegate method as follows,
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
var item: Item?
if collectionView.tag == CollectionView.media.rawValue {
item = mediaItems[indexPath.row]
// New line to fix the problem. 'timeline' is the IBOutlet of the UICollectionView I'm going to drop the item
timeline.endInteractiveMovement()
} else {
item = StoryManager.sharedInstance.timelineItems[indexPath.row]
// New line to fix the problem. 'media' is the IBOutlet of the UICollectionView I'm going to drop the item
media.endInteractiveMovement()
}
if let item = item {
let itemProvider = NSItemProvider(object: item.id as NSString)
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = item
return [dragItem]
}
return []
}
To fix the app crashing upon navigation I had to end any interactive movement in viewWillDisappear.
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// 'media' and 'timeline' are the IBOutlets of the UICollectionViews I have implemented drag and drop
timeline.endInteractiveMovement()
media.endInteractiveMovement()
}
Hopefully, Apple will fix this in a future release.
It pretty much looks like UICollectionViewDropDelegate's dropSessionDidEnd method is being called anytime one get's into this "issue".
I think it's quite a bit more elegant to ensure to "end interactive movement" here:
func collectionView(_ collectionView: UICollectionView, dropSessionDidEnd session: UIDropSession) {
timeline.endInteractiveMovement()
media.endInteractiveMovement()
}