Having some weird animation bug using TrailingSwipeActionsConfigurationForRowAt to delete cells - ios

I'm trying to simply delete cells with this method of tableView. For some reason, doing so creates this weird animation bug that I can't figure out. Here is my code:
let deleteSubcatAction = UIContextualAction(style: .destructive, title: "Delete") { (action, view, handler) in
print("Delete Subcategory Action Tapped")
let subcategory = Global.budget.categories[indexPath.section].subcategories[indexPath.row]
Global.budget.categories[indexPath.section].subcategories.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .left) }
deleteSubcatAction.backgroundColor = .red
let rowConfig = UISwipeActionsConfiguration(actions: [deleteSubcatAction])
return rowConfig
When I drag the cell to the left, I see the delete action as I expect and pressing the action calls the method as it should. However, the cell isn't removed from the table. Instead, the actions stay locked in place and the rest of the content of the cell slides back to the right as if I had released my finger without pressing an action. Then, if I tap elsewhere in my app, the actions minimize back to the right, and the cell simply disappears without any sort of removal animation. Here's a video of the bug I recorded. What's going on here?

First thing that pops out is that you aren't calling the completion handler (in your case, the handler property) at the end of your UIContextualAction closure. As in, handler(true).
The second thing is that you aren't doing anything to ensure that the action is properly animated in the table view. The non-intrusive way of doing this (without reloading the entire table view from the data source) is by using:
tableView.beginUpdates()
tableview.endUpdates()
See the API docs for more info.
P.S. Ideally, you should have your data source hooked up to your table view in such a way that the table view reacts to events triggered by observers of your data source, rather than deleting table rows "manually" in response to a UI action.

Related

What is "stable identifier" for SwiftUI Cell?

While listening Use SwiftUI with UIKit (16:54), I heard, that the reporter said:
"When defining swipe actions, make sure your buttons perform their actions using a stable identifier for the item represented.
Do not use the index path, as it may change while the cell is visible, causing the swipe actions to act on the wrong item."
- what??? All these years I was fighting with prepareForReuse() and indexPath in cells trying to somehow fix bugs related to cell reusing.
What is this "stable identifier"?
Why does no one talk about it?
On stackoverflow you can find answers only related to prepareForReuse() function. No "stable identifier".
Is it reuseIdentifier?
If so, how I suppose to use it?
Creating for each cell its own reuseIdentifier, like this:
for index in 0..<dataSourceArray.count {
tableView.register(MyTableViewCell.self, forCellReuseIdentifier: "ReuseIdForMyCell" + "\(index)")
}
She made a mistake, if you download the sample associated with the video you'll see the deleteHandler captures the item from outside already, it doesn't look it up again when the handler is invoked. She was trying to say that if you look up an item in the handler then there is a chance if other rows have been added or removed then its index will have changed so if you use the rows old index to look up the item then you would delete the wrong one. But since no lookup is required that will never happen, so she shouldn't have even mentioned it. Here is the code in question:
// Configures a list cell to display a medical condition.
private func configureMedicalConditionCell(_ cell: UICollectionViewListCell, for item: MedicalCondition) {
cell.accessories = [.delete()]
// Create a handler to execute when the cell's delete swipe action button is triggered.
let deleteHandler: () -> Void = { [weak self] in
// Make sure to use the item itself (or its stable identifier) in any escaping closures, such as
// this delete handler. Do not capture the index path of the cell, as a cell's index path will
// change when other items before it are inserted or deleted, leaving the closure with a stale
// index path that will cause the wrong item to be deleted!
self?.dataStore.delete(item)
}
// Configure the cell with a UIHostingConfiguration inside the cell's configurationUpdateHandler so
// that the SwiftUI content in the cell can change based on whether the cell is editing. This handler
// is executed before the cell first becomes visible, and anytime the cell's state changes.
cell.configurationUpdateHandler = { cell, state in
cell.contentConfiguration = UIHostingConfiguration {
MedicalConditionView(condition: item, isEditable: state.isEditing)
.swipeActions(edge: .trailing) {
Button(role: .destructive, action: deleteHandler) {
Label("Delete", systemImage: "trash")
}
}
}
}
}

Keep timestamp label updated in UITableView

I have a UIViewController that has a UITableView which presents comments fetched form a live Firebase database.
Every time a new comment arrives, I call
tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: self.liveComments.count-1, section: 0)], with: .fade)
tableView.endUpdates()
to insert the latest comment with a fade animation. This works fine.
However, each cell has a label that shows when it was posted, in the form of "seconds, minutes or hours ago". The problem is that when many comments arrive, the age label does not get updated, since the existing cells are not updated, and it looks to the user like the comment ages are wrong.
I've tried calling
tableView.reloadRows(at: self.tableView.indexPathsForVisibleRows ?? [], with: .none)
inside my tableView updated block, but the animation is all messed up, since all of the visible cells seem to get animated in a weird, "jumpy" way.
I've also tried getting all of the visible cells, and calling a method on them to update their timestamp labels manually, but I get a crash when I do this, so I guess it's not recommended:
if let visibleCells = self.tableView.visibleCells as? [LiveCommentTableViewCell] {
visibleCells.forEach { cell in
cell.updateCommentAgeLabel()
}
How can I approach this? I just need to reload all visible cells without an animation, and the last cell with a fade in animation. Thank you!
I would just reload all the data, as long the cellForRowAt sets the timestamp label correctly it should work fine:
// still do your nice animation
tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: self.liveComments.count-1, section: 0)], with: .fade)
tableView.endUpdates()
// now just refresh the entire table
tableView.reloadData()
of course you're going to want to make sure that whatever collection feeds the numberOfItemsInSection is also updated before calling reloadData() im assuming you're already doing this as well or you'd be running into a lot of bugs and crashes
make sure that code that edits UI is on the main thread too, obviously.
That being said what does your cell.updateCommentAgeLabel() function look like bc that would work in theory as well unless potentially its not being called on the main thread again or the cast isn't working.
Perhaps try telling the system you want it to do a layout pass:
if let visibleCells = self.tableView.visibleCells as? [LiveCommentTableViewCell] {
visibleCells.forEach { cell in
cell.updateCommentAgeLabel()
cell.layoutIfNeeded() // either this
}
tableView.layoutIfNeeded() // OR this at the end, I dont expect you'll need to do both but not sure if both work

RxSwift - auto reloading UITableView initialized with Observable

I have main UITableView with some Todos that are populated used an array of Observables:
// on viewDidLoad
self.todosViewModel.todos.asObservable()
.bind(to: tableView.rx.items(cellIdentifier: "TodoViewCell", cellType: TodoTableViewCell.self)) { (row, todo, cell) in
cell.status.image = todo.getStatusImage()
cell.title.text = todo.title.value
cell.todoDescription.text = todo.description.value
cell.dueDate.text = String(describing: Utilities.dateFormatter.string(from: todo.dueDate.value))
}.disposed(by: disposeBag)
On another screen I can add/edit the data and then, click "save" button to append a new todo or modify the one being edited. This works great, except that the tableView don't reload automatically, forcing me to call tableView.reloadData() on viewDidAppear, which is triggered when the other screen is dismissed.
So,
Is there a native way for me to reload a table automatically when then todosViewModel variable is updated?
EDIT:
If on the screen of edition of the todo I reassociate the todosViewModel's value property with the same value, it also works:
self.todosViewModel.todos.value = self.todosViewModel.todos.value
That's pretty ugly, but I only know how to reload a tableView using one of those ways.
When I append a new todo or reassociate a new value to the base todos it works. When I edit, no.
That's the whole point. For the UITableView to update, your todos have to emit when an item inside has been edited or you have to bind something from your todo within the cell (this way you cannot change hight of the cell but you can push some dynamic information onto the cell).
Another option would be to map some observable from todo to the index of the cell where it's presented and call UITableView.reloadRows to update your edited cell.
I would recommend you to take a look at RxDataSources examples.

Pass the result to a Button within a UITableViewCell after executing an on tap closure?

I'm looking for any possible way of passing the result of an networking update to a button in a UITableViewCell as a closure.
I have some UITableViewCells that are products. In these cells, I have an 'Add to Cart' button. I set a buttonTap closure in my UITableViewCell cellforRowAtIndexPath method, setup a touch handler within the cell for the button, and when that handler is called, execute the buttonTap closure. I handle my cart updating on a cart object which lives on the main controller.
The result of the cart update action returns true if they can add more items to their cart. Then, I update the button accordingly. I like this approach because I don't have to deal with delegates and I can keep all of the cart logic itself far far away from the cell; the cell just knows how to make a button enabled/disabled/loading/etc.
/// Buttom tap callback callback.
public typealias Selection = () -> Bool
class MealTableViewCell: UITableViewCell {
var buttonTap: Selection?
// Runs when tapping the button
func didTapAdd() {
if let buttonBlock = buttonTap {
self.button.isLoading = true
// Simulate loading
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.button.enabled = buttonBlock()
self.button.isLoading = false
}
}
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = .... (fetch cell)
cell.buttonTap = {
// returns true if the user can add more items to their cart
return self.cart.update(product: product, quantity: 1)
}
}
My question is that currently, my cart is all local with no API calls. I'm currently switching the cart over to an API driven one, with network calls to add and remove items. That means I can no longer return a BOOL, or return at all, from my cart.update(product:,quantity:) method as it is now an async call.
So, I can do something like rewrite that method signature to be
self.cart.update(product: product, quantity: 1, success: { canAddMore in
// API call succeeded
}, failure: { error in
// fall failed
})
The question is that how can I pass canAddMore to the tableViewCell? If I redefine what Selection means to take in a block that takes a bool as a param, I can't pass that param in from the controller as it would only be passed in when the block is executed on the cell itself.
How can I do something like
cell.buttonTap = {
cell.buttonTap = {
self.cart.update(product: product, quantity: 1), success: { canAddMore in
// !!!! What can I call here to pass canAddMore to the cell.
}, failure: { error in
}
}
}
canAddMore can be any value really, a BOOL is just this example. My big goal is to avoid coupling any knowledge of the cell's loading and buttons to the controller itself. If I use delegates, I would have to have a two way delegate makes the cell a delegate of the controller, and I've always felt that's the sort of wrong direction to approach this. I'm not positive it's really possible to pass the result of a closure back to the cell, but I am hoping there is!
EDIT: The big question I'm really trying to answer is if it is at all possible to pass data back to the cell (or any object) that originally called closure through that closure. There's a million ways to do data modifications in a table view, but that's sort of the main thing I'm trying to address.
I'm also "looking" to avoid storing the canAddMore state (e.g. the quantity remaining for that product) in the main 'products' array that powers the tableview. The initial state is set there, returned from a /products endpoint, but after that, inventory being available or not is returned by the carts API action.
I don't think you want to do what you think you want to do :)
In a nutshell, instead of trying to "talk back" to the cell that called the closure, you probably want to track the "canAddMore" state of each product in your Products data array, and then update the table row(s) when the state changes. So...
User taps "Add to Cart"
Give visual feedback in that row to show that you are processing the tap (gray out the button, or show a spinner, whatever looks good)
Call back to the closure to start the Add-to-cart API call
When the API call returns, update your local Products data to indicate the "canAddMore" state
reload the row(s) in the table to update the Button (make it active, inactive, change the title, whatever)
You almost certainly need to be doing something similar anyway, so the Buttons in each row will be updated when the user scrolls and the cells are reused.
A general approach is to update the cell's content with tableView.reloadRows(at: [indexPath], with: true) in your callback. This will call func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell for the specified cell. Don't forget that this has to be done in the main queue.

iOS 13 UIContextualAction completion triggers unexpected keyboard dismissal

I have a UITableView with cells that have swappable UIContextualActions to delete or rename (edit) individual cell's TextFields.
Since I made the switch to Swift 5 / iOS 13, triggering the rename UIContextualAction on these cells causes the keyboard to launch and instantly dismiss before the user has a chance to type. Not only does the keyboard go away, the particular cell I'm trying to edit becomes completely empty, and the following warning gets generated:
[Snapshotting] Snapshotting a view (0x10c90a470, _UIReplicantView) that has not been rendered at least once requires afterScreenUpdates:YES.
Below is the code for the rename UIContextualAction:
let actionRename = UIContextualAction(style: .normal, title: "") { (action, view, completionHandler) in
let cell = self.tableLayers.cellForRow(at: indexPath) as! LayerUITableViewCell
cell.layerTitle.isEnabled = true // enable UITextField editing
cell.layerTitle.becomeFirstResponder() // launch keyboard
cell.layerTitle.selectedTextRange = cell.layerTitle.textRange(from: (cell.layerTitle.beginningOfDocument), to: (cell.layerTitle.endOfDocument)) // select all text
completionHandler(true)
} // end of let actionRename
I'm guessing the animation of the UIContextual action is somehow triggering the keyboard's resignFirstResponder.
To summarize, prior to swift 5/iOS 13, the order of events went something like this:
user swipes cell left/right
user hits UIContextual button
cell returns to center
text gets selected
keyboard launches
user types, hits return
keyboard resignFirstResponder
Whereas the behavior I'm seeing after the migration looks like this:
user swipes cell left/right
user hits UIContextual button
text gets selected
keyboard launches
cell returns to center (which somehow triggers resignFirstResponder)
keyboard resignFirstResponder
Update 2019/10/02
I have confirmed that it's the cell animation that is causing the premature keyboard dismissal. If I introduce a delay after the completionHandler as follows:
let actionRename = UIContextualAction(style: .normal, title: "") { (action, view, completionHandler) in
completionHandler(true)
self.perform(#selector(self.layerRenameDos), with: nil, afterDelay: 1.0)
// layerRenameDos has the editing/firstResponder code from above
} // end of let actionRename
With this change, the cell animates back to center, keyboard launches, and I'm able to type away. This, however, is obviously a hacky work-around. Any suggestions would be appreciated
I think this is a better solution. Just be sure to execute your code on the next runloop.
CATransaction.begin()
CATransaction.setCompletionBlock {
DispatchQueue.main.async {
//your code on the next runloop after the animation has finished
}
}
complitionHandler(true)
// or tableView.setEditing(false, animated: true)
CATransaction.commit()

Resources