SwiftUI Gestures
Comprehensive guide to SwiftUI gesture recognition with composition patterns, state management, and accessibility integration.
When to Use This Skill
-
Implementing tap, drag, long press, magnification, or rotation gestures
-
Composing multiple gestures (simultaneously, sequenced, exclusively)
-
Managing gesture state with GestureState
-
Creating custom gesture recognizers
-
Debugging gesture conflicts or unresponsive gestures
-
Making gestures accessible with VoiceOver
-
Cross-platform gesture handling (iOS, macOS, axiom-visionOS)
Example Prompts
These are real questions developers ask that this skill is designed to answer:
- "My drag gesture isn't working - the view doesn't move when I drag it. How do I debug this?"
→ The skill covers DragGesture state management patterns and shows how to properly update view offset with @GestureState
- "I have both a tap gesture and a drag gesture on the same view. The tap works but the drag doesn't. How do I fix this?"
→ The skill demonstrates gesture composition with .simultaneously, .sequenced, and .exclusively to resolve gesture conflicts
- "I want users to long press before they can drag an item. How do I chain gestures together?"
→ The skill shows the .sequenced pattern for combining LongPressGesture with DragGesture in the correct order
- "My gesture state isn't resetting when the gesture ends. The view stays in the wrong position."
→ The skill covers @GestureState automatic reset behavior and the updating parameter for proper state management
- "VoiceOver users can't access features that require gestures. How do I make gestures accessible?"
→ The skill demonstrates .accessibilityAction patterns and providing alternative interactions for VoiceOver users
Choosing the Right Gesture (Decision Tree)
What interaction do you need?
├─ Single tap/click? │ └─ Use Button (preferred) or TapGesture │ ├─ Drag/pan movement? │ └─ Use DragGesture │ ├─ Hold before action? │ └─ Use LongPressGesture │ ├─ Pinch to zoom? │ └─ Use MagnificationGesture │ ├─ Two-finger rotation? │ └─ Use RotationGesture │ ├─ Multiple gestures together? │ ├─ Both at same time? → .simultaneously │ ├─ One after another? → .sequenced │ └─ One OR the other? → .exclusively │ └─ Complex custom behavior? └─ Create custom Gesture conforming to Gesture protocol
Pattern 1: Basic Gesture Recognition
TapGesture
❌ WRONG (Custom tap on non-semantic view)
Text("Submit") .onTapGesture { submitForm() }
Problems:
-
Not announced as button to VoiceOver
-
No visual press feedback
-
Doesn't respect accessibility settings
✅ CORRECT (Use Button for tap actions)
Button("Submit") { submitForm() } .buttonStyle(.bordered)
When to use TapGesture: Only when you need tap data (location, count) or non-standard tap behavior:
Image("map") .onTapGesture(count: 2) { // Double-tap for details showDetails() } .onTapGesture { location in // Single tap to pin addPin(at: location) }
DragGesture
❌ WRONG (Direct state mutation in gesture)
@State private var offset = CGSize.zero
var body: some View { Circle() .offset(offset) .gesture( DragGesture() .onChanged { value in offset = value.translation // ❌ Updates every frame, causes jank } ) }
Problems:
-
View updates on every drag event (60-120 times per second)
-
No way to reset to original position
-
Loses intermediate state if drag cancelled
✅ CORRECT (Use GestureState for temporary state)
@GestureState private var dragOffset = CGSize.zero @State private var position = CGSize.zero
var body: some View { Circle() .offset(x: position.width + dragOffset.width, y: position.height + dragOffset.height) .gesture( DragGesture() .updating($dragOffset) { value, state, _ in state = value.translation // Temporary during drag } .onEnded { value in position.width += value.translation.width // Commit final position.height += value.translation.height } ) }
Why: GestureState automatically resets to initial value when gesture ends, preventing state corruption.
LongPressGesture
@GestureState private var isDetectingLongPress = false @State private var completedLongPress = false
var body: some View { Text("Press and hold") .foregroundStyle(isDetectingLongPress ? .red : .blue) .gesture( LongPressGesture(minimumDuration: 1.0) .updating($isDetectingLongPress) { currentState, gestureState, _ in gestureState = currentState // Visual feedback during press } .onEnded { _ in completedLongPress = true // Action after hold } ) }
Key parameters:
-
minimumDuration : How long to hold (default 0.5 seconds)
-
maximumDistance : How far finger can move before cancelling (default 10 points)
MagnificationGesture
@GestureState private var magnificationAmount = 1.0 @State private var currentZoom = 1.0
var body: some View { Image("photo") .scaleEffect(currentZoom * magnificationAmount) .gesture( MagnificationGesture() .updating($magnificationAmount) { value, state, _ in state = value.magnification } .onEnded { value in currentZoom *= value.magnification } ) }
Platform notes:
-
iOS: Pinch gesture with two fingers
-
macOS: Trackpad pinch
-
visionOS: Pinch gesture in 3D space
RotationGesture
@GestureState private var rotationAngle = Angle.zero @State private var currentRotation = Angle.zero
var body: some View { Rectangle() .fill(.blue) .frame(width: 200, height: 200) .rotationEffect(currentRotation + rotationAngle) .gesture( RotationGesture() .updating($rotationAngle) { value, state, _ in state = value.rotation } .onEnded { value in currentRotation += value.rotation } ) }
Pattern 2: Gesture Composition
Simultaneous Gestures
Use when: Two gestures should work at the same time
@GestureState private var dragOffset = CGSize.zero @GestureState private var magnificationAmount = 1.0
var body: some View { Image("photo") .offset(dragOffset) .scaleEffect(magnificationAmount) .gesture( DragGesture() .updating($dragOffset) { value, state, _ in state = value.translation } .simultaneously(with: MagnificationGesture() .updating($magnificationAmount) { value, state, _ in state = value.magnification } ) ) }
Use case: Photo viewer where you can drag AND pinch-zoom at the same time.
Sequenced Gestures
Use when: One gesture must complete before the next starts
@State private var isLongPressing = false @GestureState private var dragOffset = CGSize.zero
var body: some View { Circle() .offset(dragOffset) .gesture( LongPressGesture(minimumDuration: 0.5) .onEnded { _ in isLongPressing = true } .sequenced(before: DragGesture() .updating($dragOffset) { value, state, _ in state = value.translation } .onEnded { _ in isLongPressing = false } ) ) }
Use case: iOS Home Screen — long press to enter edit mode, then drag to reorder.
Exclusive Gestures
Use when: Only one gesture should win, not both
var body: some View { Rectangle() .gesture( TapGesture(count: 2) // Double-tap .onEnded { _ in zoom() } .exclusively(before: TapGesture(count: 1) // Single tap .onEnded { _ in select() } ) ) }
Why: Without .exclusively , double-tap triggers both single and double tap handlers.
How it works: SwiftUI waits to see if second tap comes. If yes → double tap wins. If no → single tap wins.
Pattern 3: GestureState vs State
When to Use Each
Use Case State Type Why
Temporary feedback during gesture @GestureState
Auto-resets when gesture ends
Final committed value @State
Persists after gesture
Animation during gesture @GestureState
Smooth transitions
Data persistence @State
Survives view updates
Full Example: Draggable Card
struct DraggableCard: View { @GestureState private var dragOffset = CGSize.zero // Temporary @State private var position = CGSize.zero // Permanent
var body: some View { RoundedRectangle(cornerRadius: 12) .fill(.blue) .frame(width: 300, height: 200) .offset( x: position.width + dragOffset.width, y: position.height + dragOffset.height ) .gesture( DragGesture() .updating($dragOffset) { value, state, transaction in state = value.translation
// Enable animation for smooth feedback
transaction.animation = .interactiveSpring()
}
.onEnded { value in
// Commit final position with animation
withAnimation(.spring()) {
position.width += value.translation.width
position.height += value.translation.height
}
}
)
} }
Key insight: GestureState's third parameter transaction lets you customize animation during the gesture.
Pattern 4: Custom Gestures
When to Create Custom Gestures
-
Need gesture behavior not provided by built-in gestures
-
Want to encapsulate complex gesture logic
-
Reusing gesture across multiple views
Example: Swipe Gesture with Direction
struct SwipeGesture: Gesture { enum Direction { case left, right, up, down }
let minimumDistance: CGFloat let coordinateSpace: CoordinateSpace
init(minimumDistance: CGFloat = 50, coordinateSpace: CoordinateSpace = .local) { self.minimumDistance = minimumDistance self.coordinateSpace = coordinateSpace }
// Value is the direction typealias Value = Direction
// Body builds on DragGesture var body: AnyGesture<Direction> { DragGesture(minimumDistance: minimumDistance, coordinateSpace: coordinateSpace) .map { value in let horizontal = value.translation.width let vertical = value.translation.height
if abs(horizontal) > abs(vertical) {
return horizontal < 0 ? .left : .right
} else {
return vertical < 0 ? .up : .down
}
}
.eraseToAnyGesture()
} }
// Usage Text("Swipe me") .gesture( SwipeGesture() .onEnded { direction in switch direction { case .left: deleteItem() case .right: archiveItem() default: break } } )
Pattern 5: Gesture Velocity and Prediction
Accessing Velocity
@State private var velocity: CGSize = .zero
var body: some View { Circle() .gesture( DragGesture() .onEnded { value in // value.velocity is deprecated in iOS 18+ // Use value.predictedEndLocation and time
let timeDelta = value.time.timeIntervalSince(value.startLocation.time)
let distance = value.translation
velocity = CGSize(
width: distance.width / timeDelta,
height: distance.height / timeDelta
)
// Animate with momentum
withAnimation(.interpolatingSpring(stiffness: 100, damping: 15)) {
applyMomentum(velocity: velocity)
}
}
)
}
Predicted End Location (iOS 16+)
DragGesture() .onChanged { value in // Where gesture will likely end based on velocity let predicted = value.predictedEndLocation
// Show preview of where item will land
showPreview(at: predicted)
}
Use case: Springy physics, momentum scrolling, throw animations.
Pattern 6: Accessibility Integration
Making Custom Gestures Accessible
❌ WRONG (Gesture-only, no VoiceOver support)
Image("slider") .gesture( DragGesture() .onChanged { value in updateVolume(value.translation.width) } )
Problem: VoiceOver users can't adjust the slider.
✅ CORRECT (Add accessibility actions)
@State private var volume: Double = 50
var body: some View { Image("slider") .gesture( DragGesture() .onChanged { value in volume = calculateVolume(from: value.translation.width) } ) .accessibilityElement() .accessibilityLabel("Volume") .accessibilityValue("(Int(volume))%") .accessibilityAdjustableAction { direction in switch direction { case .increment: volume = min(100, volume + 5) case .decrement: volume = max(0, volume - 5) @unknown default: break } } }
Why: VoiceOver users can now swipe up/down to adjust volume without seeing or using the gesture.
Keyboard Alternatives (macOS)
Rectangle() .gesture( DragGesture() .onChanged { value in move(by: value.translation) } ) .onKeyPress(.upArrow) { move(by: CGSize(width: 0, height: -10)) return .handled } .onKeyPress(.downArrow) { move(by: CGSize(width: 0, height: 10)) return .handled } .onKeyPress(.leftArrow) { move(by: CGSize(width: -10, height: 0)) return .handled } .onKeyPress(.rightArrow) { move(by: CGSize(width: 10, height: 0)) return .handled }
Pattern 7: Cross-Platform Gestures
iOS vs macOS vs visionOS
Gesture iOS macOS visionOS
TapGesture Tap with finger Click with mouse/trackpad Look + pinch
DragGesture Drag with finger Click and drag Pinch and move
LongPressGesture Long press Click and hold Long pinch
MagnificationGesture Two-finger pinch Trackpad pinch Pinch with both hands
RotationGesture Two-finger rotate Trackpad rotate Rotate with both hands
Platform-Specific Gestures
var body: some View { Image("photo") .gesture( #if os(iOS) DragGesture(minimumDistance: 10) // Smaller threshold for touch #elseif os(macOS) DragGesture(minimumDistance: 1) // Precise mouse control #else DragGesture(minimumDistance: 20) // Larger for spatial gestures #endif .onChanged { value in updatePosition(value.translation) } ) }
Common Pitfalls
Pitfall 1: Forgetting to Reset GestureState
❌ WRONG
@State private var offset = CGSize.zero // Should be GestureState
var body: some View { Circle() .offset(offset) .gesture( DragGesture() .onChanged { value in offset = value.translation } ) }
Problem: When drag ends, offset stays at last value instead of resetting.
Fix: Use @GestureState for temporary state, or manually reset in .onEnded .
Pitfall 2: Gesture Conflicts with ScrollView
❌ WRONG (Drag gesture blocks scrolling)
ScrollView { ForEach(items) { item in ItemView(item) .gesture( DragGesture() .onChanged { _ in // Prevents scroll! } ) } }
Fix: Use .highPriorityGesture() or .simultaneousGesture() appropriately:
ScrollView { ForEach(items) { item in ItemView(item) .simultaneousGesture( // Allows both scroll and drag DragGesture() .onChanged { value in // Only trigger if horizontal swipe if abs(value.translation.width) > abs(value.translation.height) { handleSwipe(value) } } ) } }
Pitfall 3: Using .gesture() Instead of Button
❌ WRONG (Reimplementing button)
Text("Submit") .padding() .background(.blue) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) .onTapGesture { submit() }
Problems:
-
No press animation
-
No accessibility traits
-
Doesn't respect system button styling
-
More code
✅ CORRECT
Button("Submit") { submit() } .buttonStyle(.borderedProminent)
When TapGesture is OK: When you need tap location or multiple tap counts:
Canvas { context, size in // Draw canvas } .onTapGesture { location in addShape(at: location) // Need location data }
Pitfall 4: Not Handling Gesture Cancellation
❌ WRONG (Assumes gesture always completes)
DragGesture() .onChanged { value in showPreview(at: value.location) } .onEnded { value in hidePreview() commitChange(at: value.location) }
Problem: If user drags outside bounds and gesture cancels, preview stays visible.
✅ CORRECT (GestureState auto-resets)
@GestureState private var isDragging = false
var body: some View { content .gesture( DragGesture() .updating($isDragging) { _, state, _ in state = true } .onChanged { value in if isDragging { showPreview(at: value.location) } } .onEnded { value in commitChange(at: value.location) } ) .onChange(of: isDragging) { _, newValue in if !newValue { hidePreview() // Cleanup when cancelled } } }
Pitfall 5: Forgetting coordinateSpace
❌ WRONG (Location relative to view, not screen)
DragGesture() .onChanged { value in // value.location is relative to the gesture's view addAnnotation(at: value.location) }
Problem: If view is offset/scrolled, coordinates are wrong.
✅ CORRECT (Specify coordinate space)
DragGesture(coordinateSpace: .named("container")) .onChanged { value in addAnnotation(at: value.location) // Relative to "container" }
// In parent: ScrollView { content } .coordinateSpace(name: "container")
Options:
-
.local — Relative to gesture's view (default)
-
.global — Relative to screen
-
.named("name") — Relative to named coordinate space
Performance Considerations
Minimize Work in .onChanged
❌ SLOW
DragGesture() .onChanged { value in // Called 60-120 times per second! let position = complexCalculation(value.translation) updateDatabase(position) // ❌ I/O in gesture reloadAllViews() // ❌ Heavy work }
✅ FAST
@GestureState private var dragOffset = CGSize.zero
var body: some View { content .offset(dragOffset) // Cheap - just layout .gesture( DragGesture() .updating($dragOffset) { value, state, _ in state = value.translation // Minimal work } .onEnded { value in // Heavy work once, not 120 times/second let finalPosition = complexCalculation(value.translation) updateDatabase(finalPosition) } ) }
Use Transaction for Smooth Animations
DragGesture() .updating($dragOffset) { value, state, transaction in state = value.translation
// Disable implicit animations during drag
transaction.animation = nil
} .onEnded { value in // Enable spring animation for final position withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { commitPosition(value.translation) } }
Why: Animations during gesture can feel sluggish. Disable during drag, enable for final snap.
Troubleshooting
Gesture Not Recognizing
Check:
-
Is view interactive? (Some views like Text ignore gestures unless wrapped)
-
Is another gesture taking priority? (Use .highPriorityGesture() or .simultaneousGesture() )
-
Is view clipped? (Use .contentShape() to define tap area)
-
Is gesture too restrictive? (Check minimumDistance , minimumDuration )
// Fix unresponsive gesture Text("Tap me") .frame(width: 100, height: 100) .contentShape(Rectangle()) // Define full tap area .onTapGesture { handleTap() }
Gesture Conflicts with Navigation
NavigationLink(destination: DetailView()) { ItemRow(item) .simultaneousGesture( // Don't block navigation LongPressGesture() .onEnded { _ in showContextMenu() } ) }
Gesture Breaking ScrollView
Use horizontal-only gesture detection:
ScrollView { ForEach(items) { item in ItemView(item) .simultaneousGesture( DragGesture() .onEnded { value in // Only trigger on horizontal swipe if abs(value.translation.width) > abs(value.translation.height) * 2 { if value.translation.width < 0 { deleteItem(item) } } } ) } }
Testing Gestures
UI Testing with Gestures
func testDragGesture() throws { let app = XCUIApplication() app.launch()
let element = app.otherElements["draggable"]
// Get start and end coordinates let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) let finish = element.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
// Perform drag start.press(forDuration: 0.1, thenDragTo: finish)
// Verify result XCTAssertTrue(app.staticTexts["Dragged"].exists) }
Manual Testing Checklist
-
Gesture works on first interaction (no "warmup" needed)
-
Gesture can be cancelled (drag outside bounds)
-
Multiple rapid gestures work correctly
-
Gesture works with VoiceOver enabled
-
Gesture works on all target platforms (iOS/macOS/visionOS)
-
Gesture doesn't block scrolling or navigation
-
Gesture provides visual feedback during interaction
-
Gesture respects accessibility settings (Reduce Motion)
Resources
WWDC: 2019-237, 2020-10043, 2021-10018
Docs: /swiftui/composing-swiftui-gestures, /swiftui/gesturestate, /swiftui/gesture
Skills: axiom-accessibility-diag, axiom-swiftui-performance, axiom-ui-testing
Remember: Prefer built-in controls (Button, Slider) over custom gestures whenever possible. Gestures should enhance interaction, not replace standard controls.