Stop Using Just Button: Better Ways to Handle Taps in SwiftUI
Button styles, tap gestures, custom hit areas, and gesture modifiers that make your SwiftUI views interactive

🔘 Button Basics and Styles
Lets start with the simples of Button.
The simplest button:
Button("Press Me") {
print("tapped")
}Action closure runs when tapped. Easy.
But you usually want more control over appearance:
Button(action: {
print("tapped")
}) {
Text("Custom Button")
.font(.headline)
.foregroundColor(.white)
.padding()
.background(.blue)
.cornerRadius(8)
}The label closure lets you design whatever you want. Text, images, complex layouts — all work.
SwiftUI ships with button styles you should know about:
Button("Default") { }
.buttonStyle(.automatic)
Button("Bordered") { }
.buttonStyle(.bordered)
Button("Prominent") { }
.buttonStyle(.borderedProminent)
Button("Plain") { }
.buttonStyle(.plain).borderedProminent is the modern iOS look - filled background with your tint color. .bordered gives you an outline. .plain removes all styling.

You can set tint color:
Button("Tinted") { }
.buttonStyle(.borderedProminent)
.tint(.purple)iOS 15+ added role-based styling:
Button("Delete", role: .destructive) { }
.buttonStyle(.borderedProminent)Automatically gets red styling. Also available: .cancel which gets special keyboard handling.

Custom button styles for consistency:
struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(configuration.isPressed ? Color.blue.opacity(0.8) : Color.blue)
.cornerRadius(10)
}
}
// usage
Button("Login") { }
.buttonStyle(PrimaryButtonStyle())The isPressed property lets you change appearance when the button's being tapped. This gives you that tactile feedback.
Disabled buttons:
Button("Submit") {
// action
}
.disabled(username.isEmpty)Automatically grays out and ignores taps when disabled.
Real example — a form button:
Button(action: {
submitForm()
}) {
HStack {
if isLoading {
ProgressView()
.progressViewStyle(.circular)
}
Text(isLoading ? "Submitting..." : "Submit")
}
.frame(maxWidth: .infinity)
.padding()
.background(isValid ? Color.blue : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!isValid || isLoading)Shows loading state, changes color based on validation, disables during submission. That’s how real apps work.
👆 Making Any View Tappable with .onTapGesture
Buttons are great but sometimes you want to make a card, image, or custom view tappable without button semantics.
.onTapGesture turns any view into a tap target:
Text("Tap me")
.onTapGesture {
print("tapped")
}Works on anything:
Image("photo")
.resizable()
.frame(width: 200, height: 200)
.onTapGesture {
showFullscreen.toggle()
}Circle, Rectangle, custom shapes — all tappable:
Circle()
.fill(.blue)
.frame(width: 60, height: 60)
.onTapGesture {
isSelected.toggle()
}The whole view becomes the tap area. Unlike buttons, there’s no built-in pressed state or accessibility improvements. It just handles the tap.
You can specify tap count:
Text("Double tap me")
.onTapGesture(count: 2) {
print("double tapped")
}count: 2 requires double tap. count: 3 for triple tap, etc.
Practical example — a selectable card:
VStack(alignment: .leading, spacing: 8) {
Text("Premium Plan")
.font(.headline)
Text("$9.99/month")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.blue.opacity(0.2) : Color(.systemGray6))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
.onTapGesture {
isSelected.toggle()
}
Entire card is tappable, visual feedback with background color and border.
One gotcha — .onTapGesture on containers affects the whole container:
VStack {
Text("First")
Text("Second")
}
.onTapGesture {
print("tapped anywhere in VStack")
}Tapping either text triggers the action. If you want individual tap handling, put .onTapGesture on each child instead.
Another pattern — gallery thumbnails:
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(photos) { photo in
Image(photo.name)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 100, height: 100)
.clipped()
.cornerRadius(8)
.onTapGesture {
selectedPhoto = photo
}
}
}Each thumbnail independently tappable. Simple, clean.
🎯 Expanding Hit Areas (The Invisible Tap Zone)
Small tap targets are the worst UX mistake in mobile apps. Apple’s Human Interface Guidelines recommend minimum 44x44pt touch targets.
But sometimes your design has a 20pt icon. Here’s how to fix it:
Image(systemName: "xmark")
.font(.system(size: 12))
.foregroundColor(.gray)
.frame(width: 44, height: 44) // expand hit area
.contentShape(Rectangle())
.onTapGesture {
dismiss()
}.frame() adds invisible space. .contentShape(Rectangle()) makes that space tappable. Without contentShape, only the visible icon responds to taps.
This is CRITICAL for small interactive elements:
Button(action: { isLiked.toggle() }) {
Image(systemName: isLiked ? "heart.fill" : "heart")
.foregroundColor(isLiked ? .red : .gray)
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())Icon might be 20pt but tap area is 44pt. Way easier to hit on a real device.
You can also use padding to expand hit areas:
Image(systemName: "ellipsis")
.padding(12) // adds 12pt all around
.contentShape(Rectangle())
.onTapGesture {
showMenu.toggle()
}Padding is visible (unless you make the background transparent), frame is invisible. Pick based on your design.
.contentShape() works with any shape:
Image(systemName: "plus")
.padding(16)
.contentShape(Circle())
.onTapGesture {
addItem()
}Now the tap area is circular instead of rectangular. Matches the visual if you have a circular button background.
List row actions are another place this matters:
HStack {
Text("Item name")
Spacer()
Button(action: { delete() }) {
Image(systemName: "trash")
.foregroundColor(.red)
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}Delete icon gets proper touch target even though the icon itself is small.
⏱️ Long Press, Double Tap, and Gesture Variations
.onTapGesture handles single taps. For more complex interactions, use gesture modifiers.
Long press:
Text("Hold me")
.onLongPressGesture {
print("long pressed")
}Default minimum duration is 0.5 seconds. You can customize:
Text("Hold longer")
.onLongPressGesture(minimumDuration: 2.0) {
print("held for 2 seconds")
}Long press with performing closure for continuous feedback:
Circle()
.fill(isPressed ? .blue : .gray)
.frame(width: 100, height: 100)
.onLongPressGesture(minimumDuration: 1.0, pressing: { inProgress in
withAnimation {
isPressed = inProgress
}
}) {
print("completed")
}The pressing closure gets called when the gesture starts and ends. inProgress is true while holding, false when released. Perfect for visual feedback.
Double tap vs single tap:
You can’t reliably combine single and double tap on the same view — the system can’t tell if you’re about to double tap or done tapping. But you can handle one or the other:
// either single tap
Text("Single tap")
.onTapGesture {
print("single")
}
// or double tap
Text("Double tap")
.onTapGesture(count: 2) {
print("double")
}If you need both, you have to build your own detection with a timer. Usually not worth it — pick one interaction pattern.
Combining tap and long press:
Image("photo")
.onTapGesture {
showDetail()
}
.onLongPressGesture {
showContextMenu()
}Tap for one action, hold for another. Works great. The system handles disambiguation automatically.
Drag gestures are different but related:
Circle()
.fill(.blue)
.frame(width: 60, height: 60)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { _ in
withAnimation {
offset = .zero
}
}
)Drag the circle around, springs back when released. This uses .gesture() not .onTapGesture.
🔀 Combining Gestures (simultaneousGesture vs highPriorityGesture)
Sometimes views have multiple gesture handlers and they conflict. SwiftUI has modifiers to control priority.
Basic conflict — a tappable view inside a tappable container:
VStack {
Text("Child")
.onTapGesture {
print("child tapped")
}
}
.onTapGesture {
print("parent tapped")
}Tapping the text triggers both closures — child first, then parent. Usually not what you want.
**.simultaneousGesture** makes gestures run together:
Text("Simultaneous")
.onTapGesture {
print("first")
}
.simultaneousGesture(
TapGesture()
.onEnded { _ in
print("second")
}
)Both actions fire on tap. Useful when you need multiple independent handlers.
**.highPriorityGesture** takes precedence:
VStack {
Text("Child")
.highPriorityGesture(
TapGesture()
.onEnded { _ in
print("child tapped")
}
)
}
.onTapGesture {
print("parent tapped")
}Child gesture blocks parent. Parent never fires. Try running the code yourself.
Another pattern — swipe to delete with tap to select:
HStack {
Text(item.name)
.onTapGesture {
selectedItem = item
}
Spacer()
}
.padding()
.gesture(
DragGesture(minimumDistance: 20)
.onEnded { value in
if value.translation.width < -50 {
deleteItem(item)
}
}
)Tap selects, swipe left deletes. The minimumDistance prevents accidental drags when tapping.
Gesture masking for complex interactions:
struct ContentView: View {
@State private var scale = 1.0
var body: some View {
Image("photo")
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { value in
scale = value
}
)
.simultaneousGesture(
TapGesture(count: 2)
.onEnded {
withAnimation {
scale = scale > 1.0 ? 1.0 : 2.0
}
}
)
}
}Pinch to zoom, double-tap to reset or zoom in. Both gestures work together without conflict.
One more trick — conditional gestures:
Image("photo")
.gesture(
isEditing ? DragGesture().onChanged { _ in
// handle drag
} : nil
)Gesture only active when in editing mode. Cleaner than adding/removing the whole view.
📺 Want to see these gesture techniques in action? Check out the Swift Pal YouTube channel at https://youtube.com/@swift-pal for video tutorials on SwiftUI interactions and iOS development.
The key to good touch interactions isn’t using every gesture modifier — it’s picking the right one for each situation. Buttons for obvious actions, tap gestures for custom views, long press for secondary actions, and gesture priorities when things overlap. Get these right and your app feels natural instead of fighting the user.
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read