Reputation: 756
I have a UITableView
with an editAction
that is accessible by swiping left.
It seems like there is a built in UITableView
functionality where if you swipe the cell to show the edit action, and then later tap anywhere in the tableView, the action is swiped closed automatically, and didEndEditingRowAt
is called to notify the delegate that editing is over.
However, the problem I am seeing is that sometimes, if you swipe to the left and then really quickly after the swipe (when only a tiny piece of the edit action is visible and the animation is in progress), you tap anywhere else on the screen, the edit action is closed but the didEndEditingRowAt
is not called!
So, with the following code, we end up with the tableView being in Edit Mode, but no view swiped open, and the last line printed being Will Edit
, confirming that didEndEditingRowAt
was never called.
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
let tableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
view = tableView
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "foo")
tableView.delegate = self
tableView.dataSource = self
tableView.allowsSelectionDuringEditing = false
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let deleteAction = UITableViewRowAction(style: .normal, title: " Remove") {(_, indexPath) in print("OK") }
return [deleteAction]
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {}
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
return indexPath
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
print("Will edit")
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
print("Did end edit")
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 80
}
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return tableView.dequeueReusableCell(withIdentifier: "foo") ?? UITableViewCell()
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}
}
Now, this only happens sometimes, and its a bit hard to get the timing right, but its definitely reproducible.
Here is a link to the whole demo: https://github.com/gregkerzhner/SwipeBugDemo
Am I doing or expecting something wrong here? In my real project I have code that fades the other cells to focus on the cell being currently edited, and I end up in a bad state where the other cells get faded, but the focused cell doesn't have any edit actions open.
Upvotes: 1
Views: 1821
Reputation: 21
Here is my solution. Enjoy.
func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
if #available(iOS 10.0, *) {
return .none;
} else {
return .delete;
}
}
Upvotes: 0
Reputation: 756
So in the end, the working solution ended up a bit different from what @ncke and @hybridcattt suggested.
The problem with @ncke's solution is that the func scrollViewDidEndDecelerating(_ scrollView: UIScrollView)
does not get called during this swipe/tap interaction, so the workaround never gets called.
The problem with @hybridcattt's solution is that those UITableViewCell
callbacks get called too early, so if you do the swipe rapid tap action, the UITableViewCellDeleteConfirmationView
is still part of the subviews of the cell when all of those callbacks get called.
The best way seems to be to override the willRemoveSubview(_ subview: UIView)
function of UITableViewCell
. This gets called reliably every time the UITableViewCellDeleteConfirmationView
gets removed, both during normal swipe close, and also in this buggy swipe rapid tap scenario.
protocol BugFixDelegate {
func editingEnded(cell: UITableViewCell)
}
class CustomCell: UITableViewCell {
weak var bugFixDelegate: BugFixDelegate?
override func willRemoveSubview(_ subview: UIView) {
guard String(describing: type(of: subview)) == "UITableViewCellDeleteConfirmationView" else {return }
endEditing(true)
bugFixDelegate.editingEnded(cell: self)
}
}
As @hybridcattt and @ncke suggested, in your controller you can hook into this delegate and send the missing events to the UITableView
and UITableViewDelegate
like
class DummyController: UIViewController {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) as? CustomCell else {return UITableViewCell()}
cell.bugFixDelegate = self
}
}
extension DummyController: BugFixDelegate {
//do all the missing stuff that was supposed to happen automatically
func editingEnded(cell: UITableViewCell) {
guard let indexPath = self.tableView.indexPath(for: cell) else {return}
self.tableView.setEditing(false, animated: false)
self.tableView.delegate?.tableView?(tableView, didEndEditingRowAt: indexPath)
}
}
Upvotes: 2
Reputation: 3041
This is definitely a bug in UITableView, it stays in "editing" state even if the swipe bounced back (e.g. if you swipe it just a little bit).
Good news is that you can employ some of UITableViewCell's methods to find a workaround. UITableViewCell has corresponding methods that notify of action-related state changes:
func willTransition(to state: UITableViewCellStateMask)
func didTransition(to state: UITableViewCellStateMask)
func setEditing(_ editing: Bool, animated: Bool)
var showingDeleteConfirmation: Bool { get }
The transition methods are called when the transition (and animation) begins and ends. When you swipe, willTransition
and didTransition
will be called (with state .showingDeleteConfirmationMask
), and showingDeleteConfirmation
will be true
. setEditing
is also called with true
.
While this is still buggy (cell shouldn't successfully become editing unless you actually unveiled the buttons), didTransition
is a callback where you get a chance to check whether the actions view is indeed visible. I don't think there's any robust way to do this, but maybe simply checking that cell's contentView
takes most of its bounds would be enough.
Upvotes: 2
Reputation: 1094
I'm not saying that this is the best thing ever, but if you want a workaround this could be a start:
extension UITableView {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
for cell in self.visibleCells {
if cell.isEditing && (cell.subviews.count < 3 || cell.subviews[2].frame.origin.x < 30.0) {
print("\(cell) is no longer editing")
cell.endEditing(true)
if let indexPath = self.indexPath(for: cell) {
self.delegate?.tableView?(self, didEndEditingRowAt: indexPath)
}
}
}
}
}
The idea is that a UITableView is a subclass of UIScrollView. Whilst the former's delegate methods seem broken, the latter's are still being called. Some experimentation produced this test.
Just an idea :) You may prefer to subclass UITableView rather than extend, to localise the hack.
Upvotes: 1