I have a UIButton
in a UITableView
's tableFooterView
. The table view is a subview of the view of a UIViewController
. The button in the footer view works fine except that if you manage to tap it too quickly after dragging after the table view such that it needs to bounce back, the button registers the touch (the highlighting changes) but the button's action is not called.
This is a small annoyance for users but for me the primary problem is that my UI tests are tapping the button too early and then failing because the next step doesn't work.
I've tried this out in the simulator and on a device, in my app and in a fresh new app. It always occurs.
Is this a bug in iOS? Either way, is there a workaround, at least for the sake of my UI tests?
I am using iOS 15.
Here is some example code which reproduces the issue:
import UIKit
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
let count = 20
override func viewDidLoad() {
super.viewDidLoad()
let tableView = UITableView()
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.delegate = self
tableView.dataSource = self
let button: UIView = .button(self, action: #selector(onTap))
button.frame = CGRect(origin: .zero, size: .init(width: UIView.noIntrinsicMetric, height: 200))
tableView.tableFooterView = button
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = "Row \(indexPath.row)"
return cell
}
@objc func onTap(_ sender: UIButton) {
let alert = UIAlertController(title: "Alert", message: "Message!", preferredStyle: .alert)
alert.addAction(.init(title: "OK", style: .default, handler: nil))
present(alert, animated: true, completion: nil)
}
}
extension UIView {
static func button(_ target: Any?, action: Selector) -> UIView {
let view = UIView()
view.isUserInteractionEnabled = true
let button = UIButton(type: .system)
button.backgroundColor = .green
button.layer.cornerRadius = 10
button.setTitle("Press Me", for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(button)
button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
button.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
button.widthAnchor.constraint(equalToConstant: 100).isActive = true
button.heightAnchor.constraint(equalToConstant: 50).isActive = true
button.addTarget(target, action: action, for: .touchUpInside)
return view
}
}
A gif showing the issue:
The relevant part of my UI test:
extension XCUIElement {
func tapFooterButton() {
let button = tables.buttons["Press Me"]
XCTAssertTrue(button.waitForExistence(timeout: 10))
button.tap()
}
}
The UI test has no trouble finding the button in the footer; it scrolls automatically to the button, but sometimes it tries tapping the button too early and so the UI test gets stuck.
Maybe the button tap is being interrupted by the scrolling, like it's not registering as touch up inside because the position of the button moved?
CodePudding user response:
I was just checking some Apple iOS apps and it seems this is the normal behavior they intended from a UX point of view.
I could not find many similar questions, however I came across this which seems quite close to what you want, however, the link posted in the answer no longer exists sadly.
One comment from that answer explains this behavior:
UIScrollView delays sending touch events until it knows if those touches are not scroll events. Your problem is a user tapping is a scroll event when the UIScrollView is moving.
With that in mind, here is one workaround that might work, however, it should only be used for testing / debug as it might preventing the bounce from completing.
Here is my thought process
- UITableView is a subclass of UIScrollView which has its own gesture recognizers
- Create a
UITableView
subclass that conforms toUIGestureRecognizerDelegate
- Implement the
gestureRecognizer shouldRecognizeSimultaneouslyWith
in order to get the touch - Check if the touch was on the button
- Programmatically tap the button
- Use this custom table view when initializing your table view
class CustomTableView: UITableView, UIGestureRecognizerDelegate
{
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
{
// DO THE FOLLOWING ONLY IN DEBUG - IFDEF DEBUG
// Get the tap location
let tapLocation = gestureRecognizer.location(in: self)
// Check if the button was tapped
if let button = hitTest(tapLocation, with: nil) as? UIButton
{
// Programmatically perform the button tap
// This might stop the bounce currently on the main thread
button.sendActions(for: .touchUpInside)
}
// ENDIF
return true
}
}
Then used normally
let tableView = CustomTableView()
CodePudding user response:
UI test workaround that I'm currently trying, in case anyone is interested, is to call tap()
on the button element again (first checking for isHittable
) in case it didn't work the first time. It's not ideal but if it works, it's something at least.