4.6 — User input: touches, gestures, text

Opening scenario

A designer drops a Figma file: a swipeable card stack like Tinder, with a long-press to peek at full detail, a double-tap to like, and pinch-to-zoom on the image. The “save card” form below has 6 text fields, autocomplete on city, formatted phone number input, and the keyboard must not cover the active field.

You don’t write touch tracking from scratch. You compose gesture recognizers and lean on UITextField / UITextView / UIKeyboardLayoutGuide. This chapter is the toolbox.

NeedTool
Tap, double-tapUITapGestureRecognizer
Drag a viewUIPanGestureRecognizer
Pinch to zoomUIPinchGestureRecognizer
Long press / context menuUILongPressGestureRecognizer, UIContextMenuInteraction
Swipe in a cardinal directionUISwipeGestureRecognizer
Text inputUITextField, UITextView
Keyboard avoidanceUIKeyboardLayoutGuide (iOS 15+)

Concept → Why → How → Code

Hit testing recap

When a touch lands, UIKit walks the view tree from the root, calling point(inside:with:) on each subview. The deepest view that returns true becomes the touch target. From 4.2 you remember: views with isHidden, isUserInteractionEnabled = false, or alpha < 0.01 are skipped.

You almost never override touchesBegan/Moved/Ended directly. You attach a gesture recognizer.

Tap recognizers

let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.numberOfTapsRequired = 1
view.addGestureRecognizer(tap)

@objc private func handleTap(_ gr: UITapGestureRecognizer) {
    let location = gr.location(in: view)
    print("tapped at \(location)")
}

Modern (closure-based) — UIAction doesn’t fit gestures directly, but you can wrap:

private let tap = UITapGestureRecognizer()
tap.addTarget(self, action: #selector(handleTap))

Or use a small wrapper that holds a closure as an @objc target. Many teams have ClosureGestureRecognizer helpers; pick one or stay with @objc.

For double-tap that doesn’t compete with single-tap, set requirement:

let single = UITapGestureRecognizer(target: self, action: #selector(handleSingle))
let double = UITapGestureRecognizer(target: self, action: #selector(handleDouble))
double.numberOfTapsRequired = 2
single.require(toFail: double)   // single waits to confirm double didn't happen
view.addGestureRecognizer(single)
view.addGestureRecognizer(double)

Adds a small delay to single tap (~300ms) — only use this when you actually need both.

Pan — drag with state machine

Pan recognizer reports a state machine: began → changed (many) → ended/cancelled. Always switch on it:

let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
card.addGestureRecognizer(pan)

@objc private func handlePan(_ gr: UIPanGestureRecognizer) {
    let translation = gr.translation(in: view)
    switch gr.state {
    case .began:
        startCenter = card.center
    case .changed:
        card.center = CGPoint(
            x: startCenter.x + translation.x,
            y: startCenter.y + translation.y
        )
    case .ended, .cancelled:
        let velocity = gr.velocity(in: view)
        if abs(velocity.x) > 1000 || abs(card.center.x - startCenter.x) > 100 {
            // commit swipe
            animateOffScreen(direction: velocity.x > 0 ? .right : .left)
        } else {
            // snap back
            UIView.animate(withDuration: 0.3) { self.card.center = self.startCenter }
        }
    default: break
    }
}

Key API choices:

  • translation(in:) — total movement since .began
  • velocity(in:) — current velocity in points/sec, useful for fling detection
  • gr.setTranslation(.zero, in: view) — reset baseline mid-gesture (rare)

Pinch & rotation

let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch))
imageView.addGestureRecognizer(pinch)

@objc private func handlePinch(_ gr: UIPinchGestureRecognizer) {
    if gr.state == .began || gr.state == .changed {
        imageView.transform = imageView.transform.scaledBy(x: gr.scale, y: gr.scale)
        gr.scale = 1.0  // reset to delta, not absolute
    }
}

For pinch-to-zoom in a scroll view, prefer UIScrollView with minimumZoomScale / maximumZoomScale and viewForZooming(in:). Free hardware-accelerated zoom, momentum, bounce.

Long press & context menus

Old way: UILongPressGestureRecognizer. New way (iOS 13+): UIContextMenuInteraction. Adds the system long-press → preview → menu UI matching iOS conventions:

let interaction = UIContextMenuInteraction(delegate: self)
card.addInteraction(interaction)

extension CardVC: UIContextMenuInteractionDelegate {
    func contextMenuInteraction(
        _ interaction: UIContextMenuInteraction,
        configurationForMenuAtLocation location: CGPoint
    ) -> UIContextMenuConfiguration? {
        UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
            UIMenu(children: [
                UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { _ in
                    self.share()
                },
                UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
                    self.delete()
                },
            ])
        }
    }
}

UICollectionView and UITableView have built-in delegate methods (contextMenuConfigurationForItemAt) — use those for cell context menus.

Gesture conflicts & delegates

Multiple recognizers on the same view can conflict. UIGestureRecognizerDelegate resolves:

extension MyVC: UIGestureRecognizerDelegate {
    func gestureRecognizer(
        _ a: UIGestureRecognizer,
        shouldRecognizeSimultaneouslyWith b: UIGestureRecognizer
    ) -> Bool {
        true   // allow both pinch & rotation simultaneously
    }
}

Common need: pan inside a scroll view (without canceling scroll). Set:

panGR.delegate = self
// allow pan and scroll to fire together

For when one gesture should defer to another (e.g., your custom swipe shouldn’t activate until the system back-swipe fails):

mySwipe.require(toFail: navigationController!.interactivePopGestureRecognizer!)

Text input — UITextField vs UITextView

UITextFieldUITextView
LinesSingleMultiple
DelegateUITextFieldDelegateUITextViewDelegate
Return keyCloses / submitsInserts newline
PlaceholderBuilt-in .placeholderManual workaround
Common useForm inputs, searchComments, descriptions, long form text
let email = UITextField()
email.placeholder = "Email"
email.keyboardType = .emailAddress
email.autocapitalizationType = .none
email.autocorrectionType = .no
email.textContentType = .emailAddress     // enables AutoFill
email.returnKeyType = .next
email.delegate = self

extension SignupVC: UITextFieldDelegate {
    func textFieldShouldReturn(_ tf: UITextField) -> Bool {
        if tf == emailField { passwordField.becomeFirstResponder() }
        else { submit() }
        return true
    }

    func textField(_ tf: UITextField,
                   shouldChangeCharactersIn range: NSRange,
                   replacementString string: String) -> Bool {
        // input validation, formatting (e.g., phone number masking)
        true
    }
}

Critical: set textContentType correctly (.emailAddress, .password, .oneTimeCode, .streetAddressLine1). This unlocks AutoFill, password manager integration, SMS code suggestions. Users hate forms that don’t autofill.

Keyboard avoidance — UIKeyboardLayoutGuide (iOS 15+)

The old pattern: subscribe to UIResponder.keyboardWillShowNotification, parse the frame, compute inset, animate bottomConstraint.constant. Ten lines, easy to break.

The new pattern: one constraint.

NSLayoutConstraint.activate([
    submitButton.bottomAnchor.constraint(
        equalTo: view.keyboardLayoutGuide.topAnchor,
        constant: -16
    )
])

The button now floats above the keyboard automatically, with the right animation curve and duration when the keyboard appears/disappears. Replaces the entire notification-subscription pattern.

For scroll-view-based forms:

scrollView.keyboardDismissMode = .interactive  // user can swipe down to dismiss
scrollView.contentInsetAdjustmentBehavior = .always

For more complex needs (e.g., scrolling the active field into view), keep manual notification handling — but only for fields. Layout uses keyboardLayoutGuide.

Dismissing the keyboard

// Programmatic
view.endEditing(true)

// On tap outside
let tap = UITapGestureRecognizer(target: view, action: #selector(UIView.endEditing(_:)))
tap.cancelsTouchesInView = false
view.addGestureRecognizer(tap)

The cancelsTouchesInView = false is critical — without it, taps on buttons get swallowed.

Custom input views & accessory views

Replace the keyboard with a custom picker:

let picker = UIPickerView()
picker.dataSource = self
picker.delegate = self
field.inputView = picker

// Toolbar above keyboard
let toolbar = UIToolbar()
toolbar.sizeToFit()
toolbar.items = [
    UIBarButtonItem(systemItem: .flexibleSpace),
    UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in
        self?.view.endEditing(true)
    })
]
field.inputAccessoryView = toolbar

Search bars & search controllers

let search = UISearchController(searchResultsController: nil)
search.searchResultsUpdater = self
search.obscuresBackgroundDuringPresentation = false
search.searchBar.placeholder = "Search items"
navigationItem.searchController = search
navigationItem.hidesSearchBarWhenScrolling = false

extension MyVC: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        let query = searchController.searchBar.text ?? ""
        filter(query: query)
    }
}

For real search, debounce the query (you don’t want to hit the network on every keystroke). Combine debounce or a simple Task + cancellation:

private var searchTask: Task<Void, Never>?

func updateSearchResults(for sc: UISearchController) {
    searchTask?.cancel()
    let query = sc.searchBar.text ?? ""
    searchTask = Task { [weak self] in
        try? await Task.sleep(for: .milliseconds(300))
        guard !Task.isCancelled else { return }
        await self?.runSearch(query)
    }
}

Accessibility for input

  • accessibilityLabel — what VoiceOver reads
  • accessibilityHint — extra context (“Double tap to edit”)
  • accessibilityTraits.button, .searchField, etc.
  • For gesture-only UI, provide a tappable alternative (long-press shortcut won’t help VoiceOver users)
  • Test with Voice Control (“Show numbers” / “Tap 4”) — your buttons must have accessible names

In the wild

  • Tinder swipe deck: UIPanGestureRecognizer with velocity-based decision; cards stacked in a UICollectionView with custom layout. The “rewind” feature is a stack of past-card snapshots.
  • iMessage: UITextView with intrinsic-size growth, attached inputAccessoryView is the entire compose bar (Camera, App drawer, send).
  • Apple Camera: gesture-heavy app — pinch zooms, double-tap flips camera, drag adjusts exposure. All recognizers, all configured to fire simultaneously via the delegate.
  • Apple Maps: pinch + rotate + pan all simultaneous; long press drops a pin. Custom interactions on top of MKMapView’s built-in recognizers.
  • Robinhood chart cursor: long-press to show value, drag to scrub. UILongPressGestureRecognizer morphs into a pan on .began.

Common misconceptions

  1. “Override touchesBegan/Moved/Ended for custom interactions.” Almost never. Compose recognizers; they’re battle-tested and integrate with system behaviors.
  2. shouldRecognizeSimultaneouslyWith defaults to true.” It defaults to false. Two recognizers on the same view will exclude each other unless you say otherwise.
  3. textContentType is optional decoration.” It controls AutoFill, SMS code suggestions, and password manager integration. Critical for UX.
  4. UIKeyboardLayoutGuide is iOS 16+.” It’s iOS 15+. Use it. The old notification dance is legacy.
  5. “Disable autocorrect on every field.” Only on email, password, username, codes, URLs. Leave it on for actual text fields (names, addresses, comments) — users expect it.

Seasoned engineer’s take

Input UX is where apps feel polished or cheap. Lessons over years:

  • Match system conventions: long-press = context menu, pull-down = refresh, swipe-left = delete. Don’t reinvent them.
  • Form input deserves design care: AutoFill, smart keyboards, returnKeyType chains, formatted input (phone, currency, card number). Saves users seconds per field across millions of sessions.
  • Always handle gesture cancellation: .cancelled state happens (interruption from system alert, low memory, etc.). Restore visual state cleanly.
  • Test with a slow finger: many drag gestures only work right when fast. Real users include grandparents.

TIP: For one-handed reachability, place primary CTAs near the bottom (within thumb reach on a 6.7“ phone). keyboardLayoutGuide-anchored bottom buttons are a UX win.

WARNING: inputAccessoryView set on a UIViewController (via the override) is separate from inputAccessoryView set on a UITextField/UITextView. Pick one model; mixing them produces double accessory bars.

Interview corner

Junior-level: “How do you dismiss the keyboard when the user taps outside a text field?”

Add a UITapGestureRecognizer to the view that calls view.endEditing(true). Set cancelsTouchesInView = false so it doesn’t eat taps on buttons.

Mid-level: “How would you implement a swipeable card stack like Tinder?”

Stack of UIViews in z-order. Top card has a UIPanGestureRecognizer; track translation(in:) to drag and velocity(in:) to decide a fling commit. On .changed, also rotate slightly by translation.x / 1000 for a natural feel. On .ended, decide commit vs snap-back based on distance + velocity. Animate off-screen and reveal the next card. Use a custom UICollectionView layout if you want to manage many cards efficiently.

Senior-level: “Design the keyboard-handling for a chat app with an inputAccessoryView that contains a growing text view, attach button, and send button.”

Use inputAccessoryView at the UIViewController level (override inputAccessoryView and return your bar). Bar is a UIView subclass that returns intrinsicContentSize based on the UITextView’s content size, capped at ~5 lines. becomeFirstResponder returns true on the VC. For when the keyboard isn’t visible, the bar floats at the bottom anchored to view.keyboardLayoutGuide.topAnchor so it tracks keyboard up and down. The text view’s content size is observed via textViewDidChange; the bar’s height changes invalidate the intrinsic size, which animates. Send button is disabled when text is empty; on send, clear the text view and call becomeFirstResponder again to keep keyboard up. Test on rotation, on iPad floating keyboard, and on hardware keyboard (where inputAccessoryView becomes a small bar above no keyboard).

Red flag in candidates: Subscribing to keyboardWillShowNotification to adjust a constraint when keyboardLayoutGuide solves it in one line. Indicates outdated knowledge.

Lab preview

Lab 4.3 builds a form with validation, keyboard handling, and Keychain storage of the auth token.


Next: 4.7 — Data persistence