skip to Main Content

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:

gif of bouncing table view button action

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?

2

Answers


  1. Chosen as BEST ANSWER

    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.


  2. 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

    1. UITableView is a subclass of UIScrollView which has its own gesture recognizers
    2. Create a UITableView subclass that conforms to UIGestureRecognizerDelegate
    3. Implement the gestureRecognizer shouldRecognizeSimultaneouslyWith in order to get the touch
    4. Check if the touch was on the button
    5. Programmatically tap the button
    6. 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()
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search