UITableView tableView(_:didEndEditingRowAt:) not called if rapidly tapping during swipe - ios

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.

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.

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

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.

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

Related

Is it possible to customise UITableView's reorder icon to locate at left side?

It is possible to customise UITableView edit mode to certain extend.
By conform the following protocol funcions
// MARK: Customization
func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
return false
}
func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
return UITableViewCell.EditingStyle.none
}
func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
return true
}
func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
return proposedDestinationIndexPath
}
We are able to have something as
Currently, our requirement is that, the reoder icon (icon with 3 horizontal lines) should be at left side. It should look as
I was wondering, during UITableView edit mode, is it ever possible to customise the reorder icon on left side?
In the cell's willDisplayCell you can iterate through the cell's subviews until you find the reorder control. Create a new subview the same size as the control and place it where you want, then add the reorder control as a subview of the subview you created and center it on it.
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
cell.subviews.forEach { (subview) in
if String(describing: type(of: subview.self)) == "UITableViewCellReorderControl" {
let control = UIView(frame: CGRect(origin: CGPoint(x: 0, y: 0), size: subview.frame.size))
control.addSubview(subview)
subview.center = control.center
cell.contentView.addSubview(control)
}
}
}

Distinguishing TableViewCell touch and UIButton inside TableView

I'm not sure if this question has already been asked but I couldn't find one asked for Swift after spending some time. A similar question could have been asked here, but it's for Objective-C and the question was vice-versa what I'm after.
I have a UIButton inside a TableViewCell which has some action once tapped on it, however, when UIButton is clicked, only didSelectRowAt tableView function is getting triggered. The UIButton is in a separate TableViewCell class. The TableViewCell is expandable, so when each row is tapped it expands/collapses. I'm sure there must be a way of controlling this with UITapGestureRecognizer, but I wouldn't know how to manipulate coordinates as I'm relatively new to Swift.
SomeTableViewCell.swift
class SomeTableViewCell: UITableViewCell {
#IBAction func activateButtonTapped(_ sender: Any) {
activateTapButton?(self)
}
var activateTapButton: ((UITableViewCell) -> Void)?
}
ViewController.swift
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
#IBOutlet var someTableView: UITableView!
var selectedIndex: Int = -1
var someNumber = 123456789
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = servicesTableView.dequeueReusableCell(withIdentifier: “cell", for: indexPath) as! SomeTableViewCell
cell.activateButton.isUserInteractionEnabled = true
cell.activateTapButton = {(Void) in
if let url = URL(string: "tel://\(someNumber)"), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if(selectedIndex == indexPath.row) {
return 280
} else {
return 60
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
//indexPath handling once cell clicked
// Expanding cell feature
if (selectedIndex == indexPath.row) {
selectedIndex = -1
} else {
selectedIndex = indexPath.row
}
self. someTableView.beginUpdates()
self. someTableView.reloadRows(at: [indexPath], with: UITableViewRowAnimation.automatic)
self. someTableView.endUpdates()
}
Thought might be worth posting up the answer.
It was simply because the label was getting in the way of UIButton, just changing the layer hierarchy, the button can be tapped now.

Is swipe to delete the only way of deleting a tableView row?

I've been testing an app with people and after I tell them they can swipe to delete they find it intuitive but up front not everyone - in my experience at least - is savvy enough to figure it out.
Is there an alternative? I think ideally i'd like to have a little trash can or "x" on the the tableView cell that can be pressed to delete. But not sure that is easily implemented.
The first issue I encountered is I don't think I can drag an IBOutlet from a TableViewCell to the UIViewController where I have my table view.
And even if that is possible not sure how I would implement the below function when the delete button is clicked.
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
So was just wondering if swipe to delete is my only option?
Thanks.
You can do this for implementing the "x" button on the the tableView cell:
protocol CustomCellDelegate {
func removeButtonTappedOnCell(with indexPath: IndexPath)
}
class CustomCell: UITableViewCell {
var delegate: CustomCellDelegate?
#IBOutlet weak var removeButton: UIButton!
override func awakeFromNib() {
super.awakeFromNib()
}
#IBAction func removeButtonTapped(_ sender: UIButton) {
let indexPath = IndexPath(row: sender.tag, section: 0)
delegate?.removeButtonTappedOnCell(with: indexPath)
}
}
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, CustomCellDelegate {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as! CustomCell
cell.removeButton.tag = indexPath.row
cell.delegate = self
return cell
}
func removeButtonTappedOnCell(with indexPath: IndexPath) {
modelArray.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .right)
}
}
What you are describing is usually achieved by putting an 'Edit' button into one side of the Navigation Bar. The button puts the table into an edit mode that allows tapping of small, red delete buttons. Just another way to do the same thing, i.e. delete a row. Create a Master-Detail app from the iOS template and see how the button is created programmatically in the viewDidLoad method. Then look at the following methods that handle the deletion, whether initiated by a swipe or the Edit button.
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
// Return false if you do not want the specified item to be editable.
return true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
objects.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .fade)
} else if editingStyle == .insert {
// Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view.
}
}

For a nested UITableView inside a UITableViewCell the didSelectRowAt indexPath: is not being called

I have a UITableView nested inside another UITableView.
In the nested tableView, when the user taps on a cell, the cell does highlight, but the delegate method didSelectRowAt indexPath: is not being called.
Other delegate methods are being called, for example methods like willDisplay cell: forRowAt indexPath: or scrollViewDidScroll(_:). So this tells me that the delegates etc. are connected correctly.
In another part of the same application I am using the same kind of structure, UITableView inside another UITableView and there it works fine.
I compared the two extensively, so far I haven't found the difference, and no clue why one should work and the other not!
Please see the implementation of the nested UITableViewCell.
It would be nice if the console would log the line "touch detected".
It does log the lines "scrolling" and "will display cell".
import UIKit
class TestTableViewCell: UITableViewCell {
}
extension TestTableViewCell: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 5
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return tableView.dequeueReusableCell(withIdentifier: "FileCell")!
}
}
extension TestTableViewCell: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
print("touch detected")
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
print("will display cell")
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("scrolling")
}
}
Solution was very simple at last.
I had a UITapGestureRecognizer setup for the outer UITableView.
I had this there to dismiss the keyboard on tap.
After removing the UITapGestureRecognizer it started working fine.
I completely overlooked this, but it's solved now!

Swift: Delay in UITableViewCell Selection

I have a UITableView inside of another view. Whenever I click on a cell in the table, I would like that cell to be highlighted until it is clicked again to deselect it. Using the didSelectRowAtIndexPath method, I have accomplished this. However, the cell selection takes a long time. I have to hold down on a cell for 3 seconds before it highlights the cell, rather than it being instantaneous. How do I get it to select the cell the instant it is touched?
Here is my relevant code.
class AddDataViewController : UIViewController {
#IBOutlet weak var locationTableView: UITableView!
var fstViewController : FirstViewController?
let locationTableViewController = LocationTableViewController()
override func viewWillAppear(animated: Bool) {
// Set the data source and delgate for the tables
self.locationTableView.delegate = self.locationTableViewController
self.locationTableView.dataSource = self.locationTableViewController
// Set the cell separator style of the tables to none
self.locationTableView.separatorStyle = UITableViewCellSeparatorStyle.None
// Refresh the table
self.locationTableView.reloadData()
}
override func viewDidLoad(){
super.viewDidLoad()
// Create a tap gesture recognizer for dismissing the keyboard
let tapRecognizer = UITapGestureRecognizer()
// Set the action of the tap gesture recognizer
tapRecognizer.addTarget(self, action: "dismissKeyboard")
// Add the tap gesture recognizer to the view
//self.view.addGestureRecognizer(tapRecognizer)
}
}
class LocationTableViewController : UITableViewController, UITableViewDelegate, UITableViewDataSource {
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return tableView.frame.height / 5
}
override func tableView(tableView: UITableView, shouldHighlightRowAtIndexPath indexPath: NSIndexPath) -> Bool {
return true
}
override func tableView(tableView: UITableView, didHighlightRowAtIndexPath indexPath: NSIndexPath) {
tableView.cellForRowAtIndexPath(indexPath)?.backgroundColor = UIColor.greenColor()
}
override func tableView(tableView: UITableView, didUnhighlightRowAtIndexPath indexPath: NSIndexPath) {
tableView.cellForRowAtIndexPath(indexPath)?.backgroundColor = UIColor.whiteColor()
}
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
tableView.cellForRowAtIndexPath(indexPath)?.backgroundColor = UIColor.blueColor()
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: UITableViewCellStyle.Value2, reuseIdentifier: "addDataCell")
cell.selectionStyle = UITableViewCellSelectionStyle.None
cell.textLabel!.text = "Test"
return cell
}
}
The problem was the UITapGestureRecognizer was interfering with the tap of the cell. I apologize that the tap gesture code was not in my initial post as I did not realize that could be the culprit. I have added it into the code snippet in the original post.
This should work but it would be much cleaner to make a custom cell subclass.
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: UITableViewCellStyle.Value2, reuseIdentifier: "addDataCell")
cell.textLabel!.text = "Test"
let backgroundView = UIView()
backgroundView.backgroundColor = YOUR_COLOR
cell.selectedBackgroundView = backgroundView
return cell
}
Remove because we want cell selection
cell.selectionStyle = UITableViewCellSelectionStyle.None
Remove since this is already taken care of in the cell subclass
override func tableView(tableView: UITableView, didHighlightRowAtIndexPath indexPath: NSIndexPath) {
tableView.cellForRowAtIndexPath(indexPath)?.backgroundColor = UIColor.greenColor()
}
override func tableView(tableView: UITableView, didUnhighlightRowAtIndexPath indexPath: NSIndexPath) {
tableView.cellForRowAtIndexPath(indexPath)?.backgroundColor = UIColor.whiteColor()
}
I don't have enough reputation points to reply to #Jason247, so hence typing it as an answer. He's right. I had the same issue with a delay of the cells registering a click. Commented out the UITapGestureRecognizer and the delay went away.
In my context, I was using a UISearchBar. I replaced my UITapGestureRecognizer with the search bar's optional delegate method:
class ViewController: UIViewController, UISearchBarDelegate{
func searchBarSearchButtonClicked(searchBar: UISearchBar)
{
self.mySearchBar.endEditing(true)
}
}
You can find more solutions for dismissing the keyboard when using a UISearchBar here

Resources