Sherlock Holmes and the Mystery of the Untappable Button

This is a fairly commonly-asked question that I encounter on Stack Overflow: My button (or some other user-interactive view) doesn’t respond when I tap it.

And naturally enough, I have a commonly-given answer to go with it. In fact, I have a kind of catechism for helping people debug this situation. Even experienced developers can find themselves mystified, so let me take you through my catechism. I’ll recite the possibilities in order from least to most probable.

Is There Really an Action Handler?

It’s always worth asking yourself this question, as any good programmer knows. Very often, the more sure you are of something, the more likely it is that you’re wrong, so self-doubt is always a productive starting attitude. Beginners, in particular, sometimes know only that nothing is happening when they tap the button, but they are vague, both in their own minds and in describing the issue to others, about what “nothing is happening” actually means.

Just to give a trivial example, a button with no action handler at all will “do nothing” when tapped for the simple reason that it has not been given anything to do. If you’re using a storyboard, you probably forgot to give the button an action connection to the view controller. If you’re managing things in code, you probably forgot to call addTarget(_:action:for:) — or, new in iOS 14, addAction(_:for:) — or some similar method. (See my earlier article on giving a control a UIAction.)

A far less trivial case, and one that can catch even experienced iOS developers, is when you did call one of those methods, but you did it in the wrong place, namely, in the initializer of an instance property. This will compile but it isn’t going to work:

class ViewController: UIViewController {
    let b : UIButton = {
        let button = UIButton(type: .system)
        button.addTarget(self, action: #selector(doButton), for: .touchUpInside)
        return button
    }()
    // ...
}

Very clever, but the doButton method is not going to be called when the button is tapped because self in this context isn’t what you think it is. Whatever it is, it isn’t the view controller instance because, at the time this initializer runs, the view controller is exactly what we are in the middle of constructing and can’t be referred to at all. This is the behavior I ranted about in an earlier article.

Is There Something In the Way?

In the preceding section, the situation was that the button was, in fact, tappable; it’s just that the code expected to run when the button was tapped did not, in fact, run. Now I want to get into cases where the button is not physically tappable. Distinguishing the two sorts of situation is usually pretty straightforward; for a button, you might have to do something to make it obvious whether the button is, in fact, being tapped, such as giving it a different background image for the .highlighted state, but other controls will be dead obvious because they won’t perform their own automatic behavior: a UISwitch doesn’t toggle when tapped, or a UITextField doesn’t start editing when you tap in it.

The first possibility to consider is that there’s a view you can’t see covering the thing you’re trying to tap. A view whose isUserInteractionEnabled is true will swallow touches; if the view is clear, it will be invisible, giving the impression that the views you can see, which are actually behind this one, are untappable.

I actually use views like this deliberately as a simple way of disabling large swatches of the interface. But sometimes such views can come along and surprise you. A great example is what happened to all those naughty programmers who were adding subviews directly to a table view cell or a collection view cell: in iOS 14, those subviews are behind the contentView, whose isUserInteractionEnabled is true — resulting in a spate of complaints on Stack Overflow that the cell’s subviews were suddenly untappable. But of course, it was your own fault all along: you should never have been adding subviews directly to a cell in the first place. You should always have been adding them to the contentView. You were breaking the law, and now you’re busted.

Fortunately, this sort of situation is pretty much trivial to detect in the built-in Xcode View Debugger, where you will actually be able to see the view that’s covering whatever you’re trying to tap.

Is a Superview Disabled?

How does touch work in iOS in the first place? It works by hit-testing from the back of the view hierarchy toward the front. You tap on a button, but the runtime starts with the window, the ultimate superview at the top of the view hierarchy, with a call to hitTest(_:with:). This method recursively examines all subviews, looking for the deepest subview containing the point where the tap occurred. The nature of the recursion is such that this will be the frontmost view containing that point. That is the view to which the touch will actually be reported.

Well, if this recursion arrives at a view whose isUserInteractionEnabled is false, it stops and doesn’t recurse into any of its subviews. Therefore, by an elementary application of Logic 101, if a view whose isUserInteractionEnabled is true, such as your button, is a subview (at any depth) of a view whose isUserInteractionEnabled is false, it will be untouchable.

Fortunately, this situation is pretty easy to detect. You can do it in the View Debugger: locate the troublesome view in the Debug navigator on the left, and select successive superviews, watching the Object inspector on the right, looking for a view that says “User Interaction Enabled Off.” If you find one, that’s the source of the issue.

That sort of clunky, repetitive search is just the sort of thing computers are good at (and humans are bad at), so it might be useful to have on hand a utility function that will walk up the view hierarchy for you and report, in the Console, whether a superview is found whose isUserInteractionEnabled is false:

extension UIView {
    @objc func reportNoninteractiveSuperview() {
        if let sup = self.superview {
            if !sup.isUserInteractionEnabled {
                print(sup, "is disabled")
            } else {
                sup.reportNoninteractiveSuperview()
            }
        } else {
            print("no disabled superviews found")
        }
    }
}

If you call that on your untappable button, you’ll know instantly whether it has a disabled superview.

Is This View Outside Its Superview?

We come at last to the most probable and trickiest case. It turns out that a subview (at any depth) that’s outside the bounds of its superview is untouchable. This, again, is because of the way hitTest(_:with:) works. As it recurses down the view hierarchy, it looks first at whether the touch is within a given view before it asks any subviews whether the touch is within any of them. So if a touch is outside a given view, the recursion doesn’t go any further down that part of the hierarchy.

Now, by default, most views have their clipsToBounds set to false. So we are paradoxically able to see a subview that is outside its superview, but we can’t touch it. There is usually nothing visible on the screen to clue you in to the fact that a view is outside its superview.

Beginners, as well as not-so-beginners who are positioning views in code, fall into this trap. A particularly common mistake for beginners is to forget to give a transparent view any size; they just create the view with UIView(). They then proceed to put this view into the interface and give it subviews. There’s no visual indication that anything is wrong; they don’t see the superview, but they never expected to see it — it’s transparent. But then the subviews are mysteriously untappable.

But you can also make the same mistake very deliberately just by assigning a view the wrong frame or giving it the wrong constraints. Here’s some code you can run on your own machine to give yourself an untappable button:

extension UIView {
    func pinToSuperview(_ insets:NSDirectionalEdgeInsets = .zero) {
        guard let sup = self.superview else { return }
        self.translatesAutoresizingMaskIntoConstraints = false
        self.topAnchor.constraint(equalTo: sup.topAnchor, constant: insets.top).isActive = true
        self.trailingAnchor.constraint(equalTo: sup.trailingAnchor, constant: -insets.trailing).isActive = true
        self.leadingAnchor.constraint(equalTo: sup.leadingAnchor, constant: insets.leading).isActive = true
        self.bottomAnchor.constraint(equalTo: sup.bottomAnchor, constant: -insets.bottom).isActive = true
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.backgroundColor = .blue
        let v = UIView()
        self.view.addSubview(v)
        v.backgroundColor = .red
        v.pinToSuperview()

        let v2 = UIView()
        v.addSubview(v2)
        v2.backgroundColor = .green
        v2.pinToSuperview(NSDirectionalEdgeInsets(top: 600, leading: 10, bottom: -600, trailing: 10))

        let v3 = UIView()
        v2.addSubview(v3)
        v3.backgroundColor = .yellow
        v3.pinToSuperview(NSDirectionalEdgeInsets(top: -400, leading: 100, bottom: 800, trailing: 100))

        let b = UIButton(primaryAction: UIAction(title: "Tap Me") { _ in print("Tapped")})
        v3.addSubview(b)
        b.translatesAutoresizingMaskIntoConstraints = false
        b.centerXAnchor.constraint(equalTo: v3.centerXAnchor).isActive = true
        b.centerYAnchor.constraint(equalTo: v3.centerYAnchor).isActive = true
    }
}

This is not so easy to figure out in the View Debugger. You can do it, but the wireframe diagram is sometimes too confusing, and if you walk up the view hierarchy looking at the Size inspector, you have to be super-observant. With luck, as you select a view in the Debug navigator, it will highlight in the wireframe diagram, and you’ll notice that it’s out of place. But if the problem is that it has zero size, it won’t do even that. It probably is a lot easier to make use of other tricks. For example:

  • Try temporarily giving your views different background colors so that when one of the colors is in the wrong place or missing altogether, you know visually what’s wrong. (Brant says: “Make your background colors really ugly because it will encourage you to get this problem debugged so you can turn them off!”)

  • Try turning on clipsToBounds for your views temporarily. This will cause any views outside their superview to be invisible, so when your untappable view turns up missing, you’ll know you’ve found the problem.

  • Use a utility function. Here’s one:

    extension UIView {
        @objc func reportSuperviews(filtering:Bool = true) {
            var currentSuper : UIView? = self.superview
            print("reporting on \(self)\n")
            while let ancestor = currentSuper {
                let ok = ancestor.bounds.contains(ancestor.convert(self.frame, from: self.superview))
                let report = "it is \(ok ? "inside" : "OUTSIDE") \(ancestor)\n"
                if !filtering || !ok { print(report) }
                currentSuper = ancestor.superview
            }
        }
    }

The utility function is probably the best way. Call it on your untappable view, at a time when layout has taken place, and you’re in the situation where you can’t tap it; if there’s a superview that this view is outside of, you’ll hear about it in the console.

You Might Also Like…

Taking Control of Rotation Animations in iOS

We all love to have animation in our app’s interface. But sometimes writing even the simplest animation can be rather elusive. Rotation is a case in point. As you probably know, the way to rotate a view (or a layer) is to change its transform, applying a rotation transform to it. But certain rotations are …

Taking Control of Rotation Animations in iOS Read More »

    Sign Up

    Get more articles and exclusive content that takes your iOS skills to the next level.