1
votes

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.

4
I was surprised that I was able to replicate this in my own project, albeit only one in ten tries. To me this seems like a bug on Apple's part, which I am usually hesitant to say. Personally, I don't see that a user would ever do this on purpose if they are using the app normally.Alec O
yeah, perhaps related to openradar.me/19411256gregkerzhner
I wonder if there are any workarounds short of building my own swipe code. Otherwise this isn't getting past QA!gregkerzhner

4 Answers

2
votes

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.

2
votes

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)
  }
}
1
votes

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.

0
votes

Here is my solution. Enjoy.

func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
    if #available(iOS 10.0, *) {
    return .none;
    } else {
        return .delete;
    }
}