How to add a Tap Gesture in SwiftUI | SwiftUI Bootcamp #48
Button and onTapGesture both respond to taps, but they are not interchangeable. Knowing which to use — and how to require multiple taps with a count — is an important SwiftUI design decision. After this lesson you'll understand the difference, know how to attach a double-tap recognizer to any view, and see how they both connect to the same piece of state.
What You'll Learn
- The difference between
ButtonandonTapGestureand when to choose each - How to use
onTapGesture(count:)to require double-taps or triple-taps before the action fires - How both interactions can toggle the same
@Stateboolean to drive a visual change - How modifiers like
.frame(maxWidth: .infinity)affect the tappable hit area of a view
Mental Model
Think of Button as a labeled doorbell: it exists specifically to be pressed, it communicates that intent to the user through its semantic role (VoiceOver calls it a "button"), and it provides visual feedback (press state, highlighted appearance) automatically. Use it when the tap is a primary interactive action.
onTapGesture is more like a motion-sensor on a wall — it detects contact but has no inherent visual affordance. Any view can have it attached, which is useful when you need interactivity on a shape, image, or custom layout that shouldn't look like a button. The count parameter is like setting the sensitivity: count: 2 means the sensor only triggers on two rapid touches, not one.
Detailed Explanation
Button is a semantic control. It communicates interactivity to the system — VoiceOver announces it as a button, accessibilityLabel works naturally, and the system applies the correct highlighted state on press. Use Button for primary actions: "Save", "Delete", "Log In". It is the right choice whenever the tap is a meaningful user-initiated action.
onTapGesture is a modifier that can be added to any view, turning it into a touch target. It has no built-in accessibility role, no press state, and no visual feedback by default. This makes it the right choice for gesture-driven interactions that aren't semantically "buttons" — tapping a map annotation, selecting an item in a custom grid, or detecting a tap on a decorative shape. Adding onTapGesture to a view that users are supposed to tap as a main action is an accessibility anti-pattern.
onTapGesture(count: 2, perform:) fires only after the user taps the view the specified number of times in quick succession — the same behavior as UITapGestureRecognizer with numberOfTapsRequired = 2. This is perfect for Instagram-style "double-tap to like" interactions. The single-tap form onTapGesture { … } is equivalent to onTapGesture(count: 1, perform: { … }).
The .frame(maxWidth: .infinity) modifier on the "TAP Gesture" text is important: without it, only the text characters themselves would be tappable — the spaces on either side would not register touches. Expanding the frame to fill available width creates a large, easy-to-tap hit area, which is a critical accessibility consideration (Apple's HIG recommends a minimum 44×44 pt touch target).
Code Structure
TapGestureBootcamp.swift demonstrates three interactive elements driven by the same isSelected boolean. A RoundedRectangle changes color based on the state. A Button toggles the state on single tap. A styled text view demonstrates onTapGesture(count: 2) — requiring a double tap to trigger the same toggle.
Complete Code
TapGestureBootcamp.swift
import SwiftUI
struct TapGestureBootcamp: View {
@State var isSelected: Bool = false // single source of truth; both Button and tap gesture write to this
var body: some View {
VStack(spacing: 40) {
RoundedRectangle(cornerRadius: 25.0)
.frame(height: 200)
.foregroundColor(isSelected ? Color.green : Color.red) // visual output of the state — no explicit re-render needed
Button(action: {
isSelected.toggle() // semantic action: clearly communicates "this is a button"
}, label: {
Text("Button")
.font(.headline)
.foregroundColor(.white)
.frame(height: 55)
.frame(maxWidth: .infinity) // expands hit area across the full width
.background(Color.blue)
.cornerRadius(25)
})
Text("TAP Gesture")
.font(.headline)
.foregroundColor(.white)
.frame(height: 55)
.frame(maxWidth: .infinity) // without this, only the text characters are tappable — not the surrounding space
.background(Color.blue)
.cornerRadius(25)
// .onTapGesture { // single-tap version (count defaults to 1)
// isSelected.toggle()
// }
.onTapGesture(count: 2, perform: { // requires exactly 2 rapid taps to fire
isSelected.toggle()
})
Spacer()
}
.padding(40)
}
}
struct TapGestureBootcamp_Previews: PreviewProvider {
static var previews: some View {
TapGestureBootcamp()
}
}Code Walkthrough
@State var isSelected: Bool = false— The single boolean that both theButtonand theonTapGesturewrite to. TheRoundedRectanglereads it to determine its color. This is the core of reactive UI: one piece of state, multiple views that derive their appearance from it.RoundedRectangle…foregroundColor(isSelected ? Color.green : Color.red)— A ternary expression that maps the boolean to a color. BecauseisSelectedis@State, changing it automatically triggers a re-render and the color transitions. Adding.animation(.default, value: isSelected)would make the color change animate smoothly.Button(action: { isSelected.toggle() }, label: { … })— The semantically correct way to create a tappable action.isSelected.toggle()flips the boolean. VoiceOver announces this as "Button" and provides the appropriate activation affordance for assistive technologies..frame(maxWidth: .infinity)on the Button label — Without this, the button's hit area would only cover the text content. Filling the available width creates a full-width touch target. The height.frame(height: 55)ensures a touch target well above Apple's 44pt minimum.Text("TAP Gesture")withonTapGesture— Structurally identical to the button in appearance, but withonTapGestureinstead ofButton. This text does NOT announce itself as a button to VoiceOver — it has no implicit accessibility role. In a real accessibility-conscious app you would add.accessibilityAddTraits(.isButton)if you useonTapGesturefor what is functionally a button action.onTapGesture(count: 2, perform: { isSelected.toggle() })— The count-based form. SwiftUI waits to see if a second tap arrives within a short window after the first. If it does, the action fires. If only one tap is detected (the timeout expires), nothing happens. This is why users must double-tap this particular element while a single tap on theButtonworks.Commented-out single-tap
onTapGesture— Shows the simpler form. Uncomment this line (and comment out thecount: 2version) to compare behavior — the text becomes responsive to single taps like the button.
Common Mistakes
Mistake: Using onTapGesture instead of Button for interactive actions, breaking accessibilityonTapGesture has no accessibility role. Screen reader users won't know the view is interactive. Always use Button for primary tappable actions. Reserve onTapGesture for non-button interactions like selecting items in a custom picker or reacting to taps on graphical elements.
Mistake: Forgetting .frame(maxWidth: .infinity) and having a tiny tap target
A text view's hit area is exactly the size of its rendered text. "OK" on a button is about 20pt wide — far too small for reliable finger tapping. Always expand interactive views to at least 44×44 pt using .frame(minWidth: 44, minHeight: 44) or .frame(maxWidth: .infinity).
Mistake: Stacking both Button and onTapGesture on the same view, causing conflicting recognizers
If you add onTapGesture directly to a Button, the gesture recognizer intercepts the tap before the button's action fires. SwiftUI has specific gesture precedence rules, but the simplest fix is to choose one mechanism per interactive element.
Key Takeaways
- Use
Buttonfor any tap that is a meaningful user action — it provides accessibility semantics, press-state feedback, and system integration for free. - Use
onTapGesturewhen you need interactivity on a non-button view (shapes, images, custom graphics) or when you need a multi-tap threshold likecount: 2. - Always expand the hit area of interactive elements using
.frame(maxWidth: .infinity)or explicit minimum dimensions — text content alone is rarely a large enough touch target on a real device.
Last updated: June 27, 2026