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.
Related
I'm following this tutorial https://kylebashour.com/posts/context-menu-guide and am trying to repurpose this snippet which presents a context menu:
class TableViewController: UITableViewController {
let data: [MyModel] = []
override func viewDidLoad() {
super.viewDidLoad()
// Configure the table view
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
let item = data[indexPath.row]
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions in
// Create an action for sharing
let share = UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { action in
print("Sharing \(item)")
}
// Create other actions...
return UIMenu(title: "", children: [share, rename, delete])
}
}
}
5 seconds after the context menu is presented I would like to update the title of the menu
UIMenu(title: "expired", children: [share, rename, delete])
and ensure its children have the .disabled attributed set.
UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up", , attributes: .disabled)
Is there a way I can I update the already presented context menu's title, and the attributes of its children?
If you want dynamics table you should work with data models and update them every time you want to change the data showed on the table view.
What I mean?
A good approach is to have for every row in the table view a model with the data that you want to show in that row (CellModel).
So, in your table view controller you are going to have a list of CellModels:
private var cellModels = [ CellModel ]()
Create cell models on your view did load function and implemente UITableViewDatasource functions:
// MARK: - UITableViewDatasource implementation.
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.cellModels.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellModel = self.cellModels[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: kTableViewCellIdentifier, for: indexPath)
cell.textLabel?.text = cellModel.cellData.menuDataTitle
return cell
}
Now It's time to work with the CellModel class: this class represents the data that you are going to show in one row.
In every row of this simple tutorial you are showing a string with a title, but imagine that you want to show a title, subtitle and an image, in this case It's convenient to create a class in order to represents the data that you are going to show on the row.
I named It as "CellData":
class CellData {
// MARK: - Vars.
private(set) var menuDataTitle: String
// MARK: - Initialization.
init(title: String) {
self.menuDataTitle = title
}
}
On the other hand for each row you have a menu to show, so It's convenient to have a model that represents that menu that you are going to show. I named It as "CellContextMenuData":
class CellContextMenuData {
// MARK: - Vars.
private(set) var contextMenuTitle: String
private(set) var contextMenuActions = [ UIAction ]()
// MARK: - Initialization.
init(title: String) {
self.contextMenuTitle = title
}
}
This model have a title and actions to show on the menus:
// MARK: - UITableViewDelegate.
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
let cellModel = self.cellModels[indexPath.row]
let cellContextMenuData = cellModel.cellContextMenuData
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions in
return UIMenu(title: cellContextMenuData.contextMenuTitle, children: cellContextMenuData.contextMenuActions)
}
}
So, every time that you want to change the menu title (or Actions):
1 - Retrieve the cell model that represents the row that you want to change.
2 - Change the title of the CellContextMenuData.
3 - Reload the tableview.
Example:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Timer to change our data.
Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] timer in
let cellModel = self?.cellModels[3]
let cellContextMenuData = cellModel?.cellContextMenuData
cellContextMenuData?.contextMenuUpdate(title: "Expired")
cellContextMenuData?.contextMenuSetActionsShareUpdateDelete()
self?.tableView.reloadData()
}
}
I created an example, feel free to use/see it: https://bitbucket.org/gastonmontes/contextmenuexample
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.
Evening ladies and gentleman,
I am currently getting used to Swift and wanted to start with a little todo app. So far I can add an item and safe it persistently in a context. When an item has been added, it will be shown in a tableview. Now, I want to use a check swipe to strikethrough items, which have been added and safe this information in my context. Deleting using a swipe works perfectly fine.
Has anybody an idea how realize this? I tried to solve it by myself, but couldnt get it done. A similar question has been asked here before, but didnt get a proper answer: Add strikethrough to tableview row with a swipe
func checkAccessoryType(cell: UITableViewCell, isCompleted: Bool) {
if isCompleted {
cell.accessoryType = .checkmark
} else {
cell.accessoryType = .none
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let todo = CoreDataManager.shared.getTodoItem(index: indexPath.row)
todo.completed = !todo.completed
CoreDataManager.shared.safeContext()
if let cell = tableView.cellForRow(at: indexPath){
checkAccessoryType(cell: cell, isCompleted: todo.completed)
}
}
Assuming you are trying to strikethrough the title of your task -- which should be defined as a label -- here is the approach to take:
1- Make sure your label is set to attributed text rather than plain. To do that, go to Main.storyboard, select your label, and inside the attribute inspector, set text to Attributed.
2- Inside your completion block (that is the completion block executed after a swipe) add the following code:
(SWIFT 5)
let attributeString: NSMutableAttributedString = NSMutableAttributedString(string: taskLabel.text)
attributeString.addAttribute(.strikethroughStyle, value: 1, range: NSRange(location: 0, length: taskLabel.text.count))
taskLabel.attributedText = attributeString
Just a little advice: it's always helpful if you add some code when you ask a question.
Let me know if anything doesn't make sense.
Looking at the link that you provided, you need swipe action on your UITableViewCell.
Try looking into:
leadingSwipeActionsConfigurationForRowAt
trailingSwipeActionsConfigurationForRowAt
You need this action to perform the strikethrough label or delete:
func tableView(_ tableView: UITableView,
leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration?
{
let closeAction = UIContextualAction(style: .normal, title: "Close", handler: { (ac:UIContextualAction, view:UIView, success:(Bool) -> Void) in
print("OK, marked as Closed")
success(true)
})
closeAction.image = UIImage(named: "tick")
closeAction.backgroundColor = .purple
return UISwipeActionsConfiguration(actions: [closeAction])
}
func tableView(_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration?
{
let modifyAction = UIContextualAction(style: .normal, title: "Update", handler: { (ac:UIContextualAction, view:UIView, success:(Bool) -> Void) in
print("Update action ...")
success(true)
})
modifyAction.image = UIImage(named: "hammer")
modifyAction.backgroundColor = .blue
return UISwipeActionsConfiguration(actions: [modifyAction])
}
Source: https://developerslogblog.wordpress.com/2017/06/28/ios-11-swipe-leftright-in-uitableviewcell/
Code
Essentials for the problem parts of VC:
// Part of VC where cell is setting
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(for: indexPath) as Cell
let cellVM = viewModel.cellVM(for: indexPath)
cell.update(with: cellVM)
cell.handleDidChangeSelectionState = { [weak self] selected in
guard
let `self` = self
else { return }
self.viewModel.updateSelectionState(selected: selected, at: indexPath)
}
return cell
}
// Part of code where cell can be deleted
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let deleteAction = UITableViewRowAction(style: .destructive, title: "delete".localized, handler: { [weak self] _, indexPath in
guard let self = self else { return }
self.viewModel.delete(at: indexPath)
tableView.deleteRows(at: [indexPath], with: .left)
})
return [deleteAction]
}
Problem
When cell has been deleted and after that handleDidChangeSelectionState will be involved then indexPath passed into viewModel.updateSelectionState will be wrong (will be equal to value before cell deletion).
I think I know why
IndexPath is a struct so handleDidChangeSelectionState keeps copy of current value (not instance). Any update of original value will not update captured copy.
tableView.deleteRows will not reload datasource of tableview so cellForRowAt will not recall. It means handleDidChangeSelectionState will not capture updated copy.
My to approach to fix that problem
* 1st
Ask about indexPath value inside handleDidChangeSelectionState:
cell.handleDidChangeSelectionState = { [weak self, weak cell] selected in
guard
let `self` = self,
let cell = cell,
// now I have a correct value
let indexPath = tableView.indexPath(for: cell)
else { return }
self.viewModel.updateSelectionState(selected: selected, at: indexPath)
}
* 2nd
After every deletion perform reloadData():
let deleteAction = UITableViewRowAction(style: .destructive, title: "delete".localized, handler: { [weak self] _, indexPath in
guard let self = self else { return }
self.viewModel.delete(at: indexPath)
tableView.deleteRows(at: [indexPath], with: .left)
// force to recall `cellForRowAt` then `handleDidChangeSelectionState` will capture correct value
tableView.reloadData()
})
Question
Which approach is better?
I want to:
keep smooth animations (thanks to tableView.deleteRows(at: [])
find the better performance (I not sure which is more optimal, reloadData() or indexPath(for cell:))
Maybe there is a better 3rd approach.
Thanks for any advice.
Only the 1st approach fulfills the first condition to keep smooth animations. Calling reloadData right after deleteRows breaks the animation.
And calling indexPath(for: cell) is certainly less expensive than reloading the entire table view.
Don't use reload specific row, because the index has changed when you delete row, and it will remove animation after deleting row that's why you need to reload tableview.reloadData() it is a better option in your case.
let deleteAction = UITableViewRowAction(style: .destructive, title: "delete".localized, handler: { [weak self] _, indexPath in
guard let self = self else { return }
self.viewModel.delete(at: indexPath)
tableView.deleteRows(at: [indexPath], with: .left)
// force to recall `cellForRowAt` then `handleDidChangeSelectionState` will capture correct value
tableView.reloadData()
})
I'm using SwipeCellKit for my TO DO List app. When the user swipes left it deletes the item, but when the user swipes right I want him to be able to set a reminder on this item, so I've created an actionset a reminder
this action should perform a segue which brings the user to a custom popup with a date picker in it. The problem is that when I click on the button to set a reminder the simulator quits with an uncaught exception. I've already tried to perform deletion from this button it works perfectly, I've also tried to perform another segue to another view controller from this button the simulator quits. Could someone tell me what I'm doing wrong here? Here's my code:
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath, for orientation: SwipeActionsOrientation) -> [SwipeAction]? { if orientation == .left {
guard isSwipeRightEnabled else { return nil }
let setReminder = SwipeAction(style: .default, title: "Set a reminder") { action, indexPath in
self.updateModelByAddingAReminder(at: indexPath)
}
setReminder.image = UIImage(named: "reminder-icon")
return[setReminder]
}else{
let deleteAction = SwipeAction(style: .destructive, title: "Delete") { action, indexPath in
self.updateModel(at: indexPath)
}
// customize the action appearance
deleteAction.image = UIImage(named: "delete-icon")
// return [setReminder, deleteAction]
return [deleteAction]
}
Ok, I found problem in your options for cell.
From doc
The built-in .destructive, and .destructiveAfterFill expansion styles are configured to automatically perform row deletion when the action handler is invoked (automatic fulfillment).
And you need use destructive style for cell in editActionsForRowAt. Or use another options, for example
func tableView(_ tableView: UITableView, editActionsOptionsForRowAt indexPath: IndexPath, for orientation: SwipeActionsOrientation) -> SwipeTableOptions {
var options = SwipeTableOptions()
options.transitionStyle = .border
if orientation == .left{
//or none
options.expansionStyle = .selection
}else{
options.expansionStyle = .destructive
}
return options
}
Hope it's help.