How does UIKit decide which view receives a touch, and why can a subview outside its parent’s bounds not be tapped?

7.1.1 · L2 · Interview

Predict first

Two overlapping siblings; b was added after a. Which does a touch in the overlap hit?

import UIKit

let v = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 100))
let a = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
let b = UIView(frame: CGRect(x: 50, y: 0, width: 100, height: 100))
v.addSubview(a); v.addSubview(b)
print(v.hitTest(CGPoint(x: 70, y: 50), with: nil) === b)
  • true, b (added later, in front)
  • false, a

Predict first

A subview hangs outside its parent. Is a touch on the overhanging part delivered to it?

import UIKit

let parent = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 100))
let sub = UIView(frame: CGRect(x: 150, y: 50, width: 100, height: 100))
parent.addSubview(sub)
print(parent.hitTest(CGPoint(x: 220, y: 120), with: nil) === sub)
  • true
  • false

Predict first

What does a hit test return when the subview is transparent, or has interaction disabled?

import UIKit

let parent = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 100))
let sub = UIView(frame: CGRect(x: 150, y: 50, width: 100, height: 100))
parent.addSubview(sub)
sub.alpha = 0
print(parent.hitTest(CGPoint(x: 160, y: 60), with: nil) === parent)
sub.alpha = 1
sub.isUserInteractionEnabled = false
print(parent.hitTest(CGPoint(x: 160, y: 60), with: nil) === parent)
  • true then true (the parent)
  • false then false (the subview)

Mental model. A receptionist asks each floor in turn, starting at the top: “is anyone here for this?” If the floor says no, nobody on that floor is even asked.

When a touch begins, UIKit calls hitTest(_:with:) on the window, which recurses down the tree. For each view it asks: is isUserInteractionEnabled true, is it not hidden, is its alpha above 0.01, and does point(inside:with:) contain the point? If not, that view and its whole subtree are skipped. If it passes, the view tries its subviews in reverse order (front-most first) with the point converted to their coordinates, and returns the deepest view that accepts it, or itself if none do. The result is the hit-test view, where the touch starts.

The rule that surprises people: the parent is tested first with point(inside:) against its own bounds. A subview that hangs outside its parent is never reached for a point outside the parent, even if clipsToBounds is false, because the parent already said no. The fix is to enlarge the parent’s hit area by overriding point(inside:with:) (or hitTest) so it also accepts points inside that child, which is also how you give a small button a bigger tap target. Overlapping siblings follow z-order: the one added later (or brought to front) wins.

Subviews are tried front to back, so the later one is on top and wins.

The parent’s bounds reject the point first, so its subviews are never asked, whatever clipsToBounds says.

Both conditions make the subview ineligible, so the touch falls to the parent.

Common traps

  • Putting a button partly outside its container. The outside part is dead. Enlarge the container or override point(inside:).
  • Setting alpha = 0 and expecting touches to pass through. Below about 0.01 the view is skipped, which can be what you want, but it also blocks hit testing for its whole subtree.

They might follow up with

  • How do you expand a tap target? Override point(inside:with:) to test a larger rectangle.
  • What does isUserInteractionEnabled = false do to children? The view and all its subviews are skipped, so none of them receive touches.

Related questions

  • 7.1.2 What is the responder chain, and what is the first responder for?
  • 7.2.2 What is the difference between frame and bounds, and what happens to them under a transform?
  • 6.3.1 What is a run loop, and what happens in one iteration?

What is the responder chain, and what is the first responder for? →