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.
| Need | Tool |
|---|---|
| Tap, double-tap | UITapGestureRecognizer |
| Drag a view | UIPanGestureRecognizer |
| Pinch to zoom | UIPinchGestureRecognizer |
| Long press / context menu | UILongPressGestureRecognizer, UIContextMenuInteraction |
| Swipe in a cardinal direction | UISwipeGestureRecognizer |
| Text input | UITextField, UITextView |
| Keyboard avoidance | UIKeyboardLayoutGuide (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.beganvelocity(in:)— current velocity in points/sec, useful for fling detectiongr.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
UITextField | UITextView | |
|---|---|---|
| Lines | Single | Multiple |
| Delegate | UITextFieldDelegate | UITextViewDelegate |
| Return key | Closes / submits | Inserts newline |
| Placeholder | Built-in .placeholder | Manual workaround |
| Common use | Form inputs, search | Comments, 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 readsaccessibilityHint— 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:
UIPanGestureRecognizerwith velocity-based decision; cards stacked in aUICollectionViewwith custom layout. The “rewind” feature is a stack of past-card snapshots. - iMessage:
UITextViewwith intrinsic-size growth, attachedinputAccessoryViewis 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.
UILongPressGestureRecognizermorphs into a pan on.began.
Common misconceptions
- “Override
touchesBegan/Moved/Endedfor custom interactions.” Almost never. Compose recognizers; they’re battle-tested and integrate with system behaviors. - “
shouldRecognizeSimultaneouslyWithdefaults totrue.” It defaults to false. Two recognizers on the same view will exclude each other unless you say otherwise. - “
textContentTypeis optional decoration.” It controls AutoFill, SMS code suggestions, and password manager integration. Critical for UX. - “
UIKeyboardLayoutGuideis iOS 16+.” It’s iOS 15+. Use it. The old notification dance is legacy. - “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,
returnKeyTypechains, formatted input (phone, currency, card number). Saves users seconds per field across millions of sessions. - Always handle gesture cancellation:
.cancelledstate 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:
inputAccessoryViewset on aUIViewController(via the override) is separate frominputAccessoryViewset on aUITextField/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