Why is a UIButton consuming touches but not a UIControl
Asked Answered
P

1

6
  • I have some custom buttons in a table view cell.
  • These buttons are contained by another view which does not take up the whole cell.
  • I want the buttons to always respond to taps (and consume the tap so that the cell is not selected at the same time).
  • I want my button container view to consume taps that are not on the buttons themselves (so that the cell is not selected).
  • I want anywhere in the cell outside my buttons container to select the cell as per usual.

To this end, I have attached a gesture recogniser to my buttons container view.

This has the desired effect, as long as my buttons are UIButtons (ie tapping the button itself cause a TouchUpInside event on the button, tapping anywhere else in the buttons container does nothing and tapping anywhere else in the cell, outside the buttons container, causes the cell to be selected). However, if I use a UIControl instead of a UIButton then this is no longer the case — the control never responds to tapping (the buttons container always consumes the tap and tapping outside the buttons container, in the cell, causes the cell to be selected). It should be noted that if I do not add a gesture recogniser to my buttons container then the control responds to taps in the same way as a UIButton.

My only explanation is that a UIButton (which inherits from UIControl) somehow adds some extra touch handling. In which case I would like to know what it does and how I should emulate it (I need to use a UIControl instead of a UIButton because my button has a custom view hierarchy for which I do not want to play around in the UIButton).

The code below for a view controller should allow anyone to reproduce the problem:

class ViewController: UITableViewController, UIGestureRecognizerDelegate {

    lazy var containerView: UIView = {
        let view: UIView = UIView()
        view.backgroundColor = UIColor.redColor()
        view.setTranslatesAutoresizingMaskIntoConstraints(false)
        view.addSubview(self.buttonContainerView)
        view.addConstraints([
            NSLayoutConstraint(item: self.buttonContainerView, attribute: NSLayoutAttribute.Leading, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.LeadingMargin, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: self.buttonContainerView, attribute: NSLayoutAttribute.Trailing, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.TrailingMargin, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: self.buttonContainerView, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.TopMargin, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: self.buttonContainerView, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.BottomMargin, multiplier: 1.0, constant: 0.0)
        ])

        return view
    }()

    lazy var buttonContainerView: UIView = {
        let view: UIView = UIView()
        view.backgroundColor = UIColor.blueColor()
        view.setTranslatesAutoresizingMaskIntoConstraints(false)
        view.addSubview(self.control)
        view.addSubview(self.button)
        view.addConstraints([
            NSLayoutConstraint(item: self.control, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.CenterX, multiplier: 0.5, constant: 0.0),
            NSLayoutConstraint(item: self.control, attribute: NSLayoutAttribute.CenterY, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.CenterY, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: self.button, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.CenterX, multiplier: 1.5, constant: 0.0),
            NSLayoutConstraint(item: self.button, attribute: NSLayoutAttribute.CenterY, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.CenterY, multiplier: 1.0, constant: 0.0)
        ])

        return view
    }()

    lazy var control: UIControl = {
        let view: UIControl = TestControl(frame: CGRectZero)
        view.addTarget(self, action: Selector("controlTapped:"), forControlEvents: UIControlEvents.TouchUpInside)

        return view
    }()

    lazy var button: UIButton = {
        let view: UIButton = UIButton.buttonWithType(UIButtonType.Custom) as! UIButton
        view.setTitle("Tap button", forState: UIControlState.Normal)
        view.setTranslatesAutoresizingMaskIntoConstraints(false)
        view.addTarget(self, action: Selector("buttonTapped:"), forControlEvents: UIControlEvents.TouchUpInside)

        return view
    }()

    func controlTapped(sender: UIControl) -> Void {
        println("Control tapped!")
    }

    func buttonTapped(sender: UIButton) -> Void {
        println("Button tapped!")
    }

    var recogniser: UITapGestureRecognizer?
    var blocker: UITapGestureRecognizer?

    override func viewDidLoad() {
        super.viewDidLoad()

        self.tableView.rowHeight = 200.0

        self.containerView.layoutMargins = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0)

        let recogniser: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: Selector("tappedContainer:"))
        recogniser.delegate = self
        self.recogniser = recogniser

        self.containerView.addGestureRecognizer(recogniser)

        let blocker: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: Selector("tappedBlocker:"))
        blocker.delegate = self
        self.blocker = blocker

        self.buttonContainerView.addGestureRecognizer(blocker)
    }

    func tappedContainer(recogniser: UIGestureRecognizer) -> Void {
        println("Tapped container!")
    }

    func tappedBlocker(recogniser: UIGestureRecognizer) -> Void {
        println("Tapped blocker!")
    }

    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let identifier: String = "identifier"
        let cell: UITableViewCell
        if let queuedCell: UITableViewCell = tableView.dequeueReusableCellWithIdentifier(identifier) as? UITableViewCell {
            cell = queuedCell
        }
        else {
            cell = UITableViewCell(style: UITableViewCellStyle.Default, reuseIdentifier: identifier)

            cell.contentView.layoutMargins = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0)
            cell.contentView.backgroundColor = UIColor.purpleColor()

            cell.contentView.addSubview(self.containerView)

            cell.contentView.addConstraints([
                NSLayoutConstraint(item: self.containerView, attribute: NSLayoutAttribute.Leading, relatedBy: NSLayoutRelation.Equal, toItem: cell.contentView, attribute: NSLayoutAttribute.LeadingMargin, multiplier: 1.0, constant: 0.0),
                NSLayoutConstraint(item: self.containerView, attribute: NSLayoutAttribute.Trailing, relatedBy: NSLayoutRelation.Equal, toItem: cell.contentView, attribute: NSLayoutAttribute.TrailingMargin, multiplier: 1.0, constant: 0.0),
                NSLayoutConstraint(item: self.containerView, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: cell.contentView, attribute: NSLayoutAttribute.TopMargin, multiplier: 1.0, constant: 0.0),
                NSLayoutConstraint(item: self.containerView, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: cell.contentView, attribute: NSLayoutAttribute.BottomMargin, multiplier: 1.0, constant: 0.0)
            ])
        }

        return cell
    }

    override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        tableView.deselectRowAtIndexPath(indexPath, animated: true)
        println("selected cell")
    }

    override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }

    override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return 1
    }
}

class TestControl: UIControl {
    override init(frame: CGRect) {
        super.init(frame: frame)

        let view: UIControl = self

        let label: UILabel = UILabel()
        label.text = "Tap control"
        label.userInteractionEnabled = false

        view.layer.borderColor = UIColor.orangeColor().CGColor
        view.layer.borderWidth = 2.0
        view.setTranslatesAutoresizingMaskIntoConstraints(false)
        label.setTranslatesAutoresizingMaskIntoConstraints(false)

        view.addSubview(label)
        view.addConstraints([
            NSLayoutConstraint(item: label, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.TopMargin, multiplier: 1.0, constant: 5.0),
            NSLayoutConstraint(item: label, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.CenterX, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: label, attribute: NSLayoutAttribute.Leading, relatedBy: NSLayoutRelation.GreaterThanOrEqual, toItem: view, attribute: NSLayoutAttribute.LeadingMargin, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: label, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.LessThanOrEqual, toItem: view, attribute: NSLayoutAttribute.BottomMargin, multiplier: 1.0, constant: 0.0)
        ])
    }

    required init(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

EDIT

To be clear, I am not looking for an alternative solution which 'just works' — I want to understand what this difference is and what I should be doing to emulate it, or potentially another semantically correct way.

Prajna answered 7/9, 2015 at 13:56 Comment(0)
S
8

My only explanation is that a UIButton (which inherits from UIControl) somehow adds some extra touch handling.

You are correct that UIButton is special. I did some research while answering a related question a while back and the reason why the button's event fires is mentioned in the Event Handling Guide for iOS: Gesture Recognizers' "Interacting with Other User Interface Controls" section:

In iOS 6.0 and later, default control actions prevent overlapping gesture recognizer behavior. For example, the default action for a button is a single tap. If you have a single tap gesture recognizer attached to a button’s parent view, and the user taps the button, then the button’s action method receives the touch event instead of the gesture recognizer.

It then lists examples and a single finger single tap on a UIButton is one of them.

The way to block gesture recognizers like the default controls do would be to have TestControl override gestureRecognizerShouldBegin: (see UIView Class Reference). If you wanted to mimic UIButton's behavior, you could use something like:

override func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
    if let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer {
        if tapGestureRecognizer.numberOfTapsRequired == 1 && tapGestureRecognizer.numberOfTouchesRequired == 1 {
            return false;
        }
    }
    return true;
}
Shindig answered 8/9, 2015 at 14:59 Comment(5)
Thank you for the explanation and link — it does seem that Apple are telling us that UIButton is an exception (without telling us how to implement similar behaviour). Your suggested code works perfectly, however, I would like to understand why and be sure that it is not prone to changing. UIControl does not declare UIGestureRecognizerDelegate conformance so am I effectively overriding a private method for a hidden gesture recogniser?Prajna
@Prajna gestureRecognizerShouldBegin: is actually a method on UIView (which UIControl inherits from), so there aren't any hidden or private APIs involved. I just double-checked the method's documentation in the UIView Class Reference and it notes that UISlider uses it to block certain swipe gestures, so this is the official way to handle these situations (I'll update my answer to note that).Shindig
@Shindig Out of curiosity, how did you eventually solve this issue?Inexecution
@Inexecution I haven't run into this specific scenario myself, but since Rupert said that the snippet I included worked I would probably go with that.Shindig
Of course, posted the comment under the answer! I will remove my silly question, and this very comment shortly...Inexecution

© 2022 - 2024 — McMap. All rights reserved.