What is UIResponder?

Follow a single tap from the button that was pressed up to the app delegate, and use that walk to explain first responder, nil-targeted actions, and why hit-testing is a separate step

Answer

The core idea

Every UIKit project starts with a line almost nobody reads closely:

swift 5.2
class AppDelegate: UIResponder, UIApplicationDelegate {
    // ...
}

The app delegate does not draw anything and has no frame, so why does it inherit from UIResponder? Because UIResponder is what makes an object eligible to be handed an event at all — and the app delegate is the last object UIKit tries before giving up on that event.

UIResponder is the superclass of UIView, UIViewController, UIWindow, UIScene, and UIApplication. It contributes two things to each of them: the callbacks that receive events (touchesBegan(_:with:), pressesBegan(_:with:), motionEnded(_:with:), the editing actions behind cut and paste) and a next property pointing at whoever should get the event if this object does not deal with it.

A UIEvent is one of those deliveries: a type (touch, motion, presses, remote control) and sometimes a subtype (shake). UIKit creates the event and pushes it through UIApplication.sendEvent(_:). The chain after that is the same; the starting object is not. A touch starts at the hit-tested view. Shake, remote-control, and presses start at whoever currently holds first-responder status — which is why shake-to-undo talks to the focused text field, not the view under your finger.

1. What is the responder chain?

The responder chain is the ordered list of responders that an event can travel through, built by following next from one object to the one behind it. It is not a data structure UIKit keeps somewhere — it is just what you get by walking next repeatedly, and it changes as the view hierarchy and presentation stack change.

Say a Delete button lives inside a table view cell in an inbox screen. When the tap lands, UIKit does not start at the view controller: it starts at the button and works outward. Printing the chain shows every object that could still get a say:

swift 5.2
extension UIResponder {
    var chainDescription: [String] {
        sequence(first: self, next: { $0.next }).map { "\(type(of: $0))" }
    }
}

print(deleteButton.chainDescription)
// ["UIButton", "UITableViewCellContentView", "UITableViewCell", "UITableView",
//  "UIView", "InboxViewController", "UIDropShadowView", "UITransitionView",
//  "UIWindow", "UIWindowScene", "UIApplication", "AppDelegate"]

Apple documents the rules that produce most of that list:

  • a UIView returns its superview, unless it is a view controller's root view — then it returns the view controller
  • a UIViewController sits between its root view and that view's superview. Apple's short list still says the window (if this is the window's root) or the presenting controller (if presented); the dump follows the superview rule, so you see UIDropShadowView instead of the presenting controller
  • a UIWindow returns the UIApplication object
  • UIApplication returns the app delegate, but only when the delegate is a UIResponder and is not itself a view, a view controller, or the app object

Two things about that dump are worth knowing. Private wrapper views such as UITableViewCellContentView and UITransitionView appear even in a plain setup, and their names change between iOS versions, so read them while debugging but never write code that depends on them. next is a getter you never assign; UIKit fills the default from the view hierarchy and the presentation stack, which is why moving a view to a different parent silently changes who can respond. Overriding next is the documented way to splice an extra object in.

Where the scene and its delegate fit

UIScene is itself a UIResponder, and on current iOS the window's next is the UIWindowScene, whose next is the UIApplication. Apple's written rule still says the window points straight at the application, so treat the exact position of the scene as an implementation detail rather than something to branch on.

The scene delegate is a different story, and it surprises people:

swift 5.2
// The Xcode template — note the UIResponder inheritance
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
}

Despite that inheritance, the scene delegate is not in the responder chain. UIWindowScene does not forward to its delegate; it forwards to UIApplication, which forwards to the app delegate. The UIResponder base class here is inherited from the older app delegate template, not a sign that the object participates.

The practical consequence: if you moved lifecycle code out of the app delegate into a scene delegate, put app-wide fallback command handling in the app delegate, which really is the last link. If you want the scene delegate to receive commands, you have to splice it in yourself by subclassing UIWindowScene and overriding next to return the delegate.

2. Overriding a touch method without super

The default UIResponder implementations of the touch methods are what forward the event to next. Override one and forget super, and the chain ends at your subclass:

swift 5.2
final class BadgeView: UIView {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        highlight() // ❌ The event stops here — nothing above ever sees it
    }
}

Nothing crashes, and the badge still highlights. What stops is the chain walk: a superview or view controller that still handles touchesBegan never gets the call. Gesture recognizers on the cell or table already received the touch before that method ran, so swipe-to-delete is a different path and can keep working. A UIControl's action does not ride this forwarding either: addTarget(self, action: #selector(saveTapped), for: .touchUpInside) names the object, and sendAction calls it directly.

swift 5.2
final class BadgeView: UIView {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event) // ✅ Keeps forwarding up the chain
        highlight()
    }
}

If you deliberately want to swallow the touch, omitting super is a valid choice — but then override the whole family (touchesMoved, touchesEnded, touchesCancelled) so the sequence stays consistent.

3. First responder and the keyboard

"First responder" is two jobs. For a touch, Apple means the hit-tested view — the start of that event's chain. For the keyboard and for nil-targeted actions, it means the object that called becomeFirstResponder() and currently holds that status. A text field becomes the input target because it asks to:

swift 5.2
final class SearchViewController: UIViewController {
    private let searchField = UITextField()

    override func viewDidLoad() {
        super.viewDidLoad()
        searchField.becomeFirstResponder() // ❌ Returns false: no window yet
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        searchField.becomeFirstResponder() // ✅ The field is in a window now
    }
}

becomeFirstResponder() only succeeds once the view is in a window (window != nil), canBecomeFirstResponder is true, and the current first responder agrees to resign. The software keyboard also needs that window to be key — viewDidAppear is the usual fix for viewDidLoad, not a guarantee in a second scene or an overlay. Once the field holds first-responder status, its inputView is the system keyboard. That is also why view.endEditing(true) works without knowing which field is focused — it finds the first responder in that view tree and asks it to resignFirstResponder().

4. Actions with a nil target

Long-press text and iOS shows Cut / Copy / Paste. The text view is first responder; UIKit sends copy(_:) with no target. sendAction(_:to:from:for:)) with to: nil starts at the first responder and walks next until something implements the selector. UITextField and UITextView do, so the item appears. A plain custom view never starts that menu: it is not first responder.

Pass nil as a button's target and you get the same walk — first responder, then next — not the view controller under the button. If nothing is focused, it starts at the key window and skips every controller below it. For a nested view with no focus, walk next from self:

swift 5.2
final class MessageView: UIView {
    @objc private func archiveTapped() {
        var responder: UIResponder? = self
        while let current = responder {
            if current.responds(to: #selector(MessageActions.archiveMessage(_:))) {
                current.perform(#selector(MessageActions.archiveMessage(_:)), with: self)
                return
            }
            responder = current.next
        }
    }
}

@objc protocol MessageActions {
    func archiveMessage(_ sender: Any?)
}

extension InboxViewController: MessageActions {
    func archiveMessage(_ sender: Any?) {
        // Found by walking next from the view, not by sendAction(to: nil)
    }
}

The view never holds a pointer to a specific controller, so the same cell works on any screen that implements the command. A presented modal on the chain can still intercept it. canPerformAction(_:withSender:) is how a text view hides Paste when the pasteboard is empty: it implements the selector but returns false for now, and a responder further up can still say yes.

5. Hit-testing decides where the chain starts

This is the distinction interviewers push on, and it is easiest to see as two separate steps that answer two different questions:

Question it answersMechanismDirection
Which view did the user touch?Hit-testing — hitTest(_:with:), point(inside:with:)Down, from the window to the deepest subview containing the point
Who handles the event?Responder chain — nextUp, from that view through its controllers to the app delegate

One tap runs them in this order:

  1. The touch arrives at the window as a UIEvent.
  2. The window hit-tests: hitTest(_:with:) recurses down the hierarchy and returns the deepest view whose bounds contain the point. That view is stored on the UITouch and is where this touch's chain starts. It is not the same object as the keyboard's first responder unless that view also called becomeFirstResponder().
  3. Gesture recognizers on that view and its ancestors get the touches first. If one recognizes, it can cancel delivery to the view entirely.
  4. Otherwise UIKit calls touchesBegan(_:with:) on the hit view.
  5. If that view does not consume the touch, the event moves up through next, one responder at a time, until something handles it or the chain runs out.

So hit-testing runs once per touch to choose a starting point, and the chain is what happens afterwards. That split is also a debugging shortcut: if a tap does nothing at all, step 2 failed and no chain walk ever started, so inspect geometry and interaction flags rather than your touchesBegan code.

A view is skipped during hit-testing when isUserInteractionEnabled is false, when it is hidden, when its alpha is below 0.01, or when the point is outside its bounds. The last one causes the classic bug: a button drawn outside its parent's bounds stays fully visible when clipsToBounds is false, but taps on it are never delivered, because hit-testing rejected the parent before it ever looked at children.

Extending a small control's tappable area is the same rule used deliberately:

swift 5.2
final class CloseButton: UIButton {
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let target = CGSize(width: 44, height: 44)
        let dx = min(0, bounds.width - target.width) / 2
        let dy = min(0, bounds.height - target.height) / 2
        return bounds.insetBy(dx: dx, dy: dy).contains(point)
    }
}

Why it matters

  • A button that is plainly visible but ignores every tap is almost always a hit-testing problem — an ancestor with isUserInteractionEnabled = false, or a child sitting outside its parent's bounds.
  • Wiring addTarget with a nil target on a button does not walk from that button. It walks from the first responder (or the key window if nothing is focused), so the view controller sitting under the button never sees the action unless it already holds first-responder status.
  • A search screen that opens without a keyboard usually called becomeFirstResponder() in viewDidLoad, before the view was in a window.
  • A long-press on a custom view offers no Copy item until that view is first responder (or the current first responder already implements copy(_:)), because the menu searches from the first responder, not from the view you pressed.
  • One subclass that overrides touchesBegan without super can starve a superview that still handles touches that way. Table swipe is usually a gesture recognizer and may keep working.

Interview angle

UIResponder is the hook that lets an object take an event or pass it on. Hit-testing picks the starting view for a touch; the keyboard and shake start at whatever called becomeFirstResponder(). Keeping those two apart is most of the answer. The payoff in real code is a cell that walks next to reach whichever controller is showing it, instead of threading a delegate through three levels of view. The usual follow-up is why a visible button ignores taps — that is hit-testing, not the chain. The responder chain guide is the reference for both directions.