iosNudge

How do hit-testing and the responder chain work?

MediumUIKit 2 min read

TL;DR

Hit-testing answers "which view was touched?". UIKit walks down the view tree and finds the deepest view under the finger. The responder chain answers "who handles this event?". If a responder does not handle it, it passes it to its next responder, going up.

Hit-testing step by step

UIKit calls hitTest(_:with:) on the window. Each view:

  1. Ignores the touch if isHidden, !isUserInteractionEnabled, or alpha < 0.01.
  2. Checks point(inside:with:). If the point is outside its bounds, it returns nil.
  3. Asks its subviews, last subview first (the one on top).
  4. Returns the first subview that returns a view. If none, returns itself.

This is roughly what the default does:

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    guard isUserInteractionEnabled, !isHidden, alpha >= 0.01,
          self.point(inside: point, with: event) else { return nil }
    for subview in subviews.reversed() {
        let converted = subview.convert(point, from: self)
        if let hit = subview.hitTest(converted, with: event) {
            return hit
        }
    }
    return self
}

Classic use: a bigger tap area

A 24 pt icon is hard to tap. Make the touch area bigger without changing the look.

final class BigTapButton: UIButton {
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        bounds.insetBy(dx: -10, dy: -10).contains(point)
    }
}

Classic bug: a subview outside its parent

If a button sticks out of its superview, taps on the outside part do nothing. The parent's point(inside:) returns false, so UIKit never asks the button. clipsToBounds = false only changes drawing, not touches. Fix it by overriding hitTest in the parent.

The responder chain

UIResponder is the base class of UIView, UIViewController, UIWindow and UIApplication. Each one has a next property.

UIView -> superview ... -> UIViewController's root view -> UIViewController
  -> (parent view controllers) -> UIWindow -> UIApplication -> AppDelegate

Touches, motion events, key presses and menu actions travel up this chain.

Sending actions with a nil target

You can send an action to "whoever handles it" up the chain.

// Dismiss the keyboard from anywhere:
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
                                to: nil, from: nil, for: nil)

UIKit starts at the first responder and walks up until some object implements the method. This is how Edit menu actions like copy(_:) find the right text field.

First responder

The first responder gets keyboard input first. A text field becomes first responder when tapped.

textField.becomeFirstResponder() // show keyboard
textField.resignFirstResponder() // hide keyboard
view.endEditing(true)            // hide keyboard for any subview

Gesture recognizers

Gesture recognizers get touches before the hit view does. If a tap gesture recognizes, the view's touches are cancelled by default (cancelsTouchesInView = true). This is why a tap gesture on a parent can block taps on a button inside it.

Common follow-up questions

  • How do you find a view's view controller? Walk next until you find a UIViewController.
  • Why does a button inside a transparent overlay not get taps? The overlay is on top and returns itself in hitTest. Set isUserInteractionEnabled = false on the overlay, or override hitTest to return nil for itself.
  • Order of subviews in hit-testing? From top-most (last in subviews) to bottom.
  • What does SwiftUI use? contentShape(_:) changes the hit area. allowsHitTesting(false) turns it off.