--- name: axiom-swiftui-gestures description: Use when implementing SwiftUI gestures (tap, drag, long press, magnification, rotation), composing gestures, managing gesture state, or debugging gesture conflicts - comprehensive patterns for gesture recognition, composition, accessibility, and cross-platform support license: MIT compatibility: iOS 13+, macOS 10.15+, iPadOS 13+, axiom-visionOS 1.0+. Xcode 16+ metadata: version: "1.0.0" last-updated: "2025-12-07" --- # 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: #### 1. "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 #### 2. "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 #### 3. "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 #### 4. "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 #### 5. "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) ```swift 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) ```swift Button("Submit") { submitForm() } .buttonStyle(.bordered) ``` **When to use TapGesture**: Only when you need tap *data* (location, count) or non-standard tap behavior: ```swift 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) ```swift @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) ```swift @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 ```swift @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 ```swift @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 ```swift @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* ```swift @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 ```swift @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 ```swift 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 ```swift 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 ```swift 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 { 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 ```swift @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+) ```swift 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) ```swift Image("slider") .gesture( DragGesture() .onChanged { value in updateVolume(value.translation.width) } ) ``` **Problem**: VoiceOver users can't adjust the slider. #### ✅ CORRECT (Add accessibility actions) ```swift @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) ```swift 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 ```swift 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 ```swift @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) ```swift ScrollView { ForEach(items) { item in ItemView(item) .gesture( DragGesture() .onChanged { _ in // Prevents scroll! } ) } } ``` **Fix**: Use `.highPriorityGesture()` or `.simultaneousGesture()` appropriately: ```swift 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) ```swift 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 ```swift Button("Submit") { submit() } .buttonStyle(.borderedProminent) ``` **When TapGesture is OK**: When you need tap *location* or multiple tap counts: ```swift 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) ```swift 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) ```swift @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) ```swift 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) ```swift 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 ```swift 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 ```swift @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 ```swift 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**: 1. Is view interactive? (Some views like `Text` ignore gestures unless wrapped) 2. Is another gesture taking priority? (Use `.highPriorityGesture()` or `.simultaneousGesture()`) 3. Is view clipped? (Use `.contentShape()` to define tap area) 4. Is gesture too restrictive? (Check `minimumDistance`, `minimumDuration`) ```swift // Fix unresponsive gesture Text("Tap me") .frame(width: 100, height: 100) .contentShape(Rectangle()) // Define full tap area .onTapGesture { handleTap() } ``` ### Gesture Conflicts with Navigation ```swift NavigationLink(destination: DetailView()) { ItemRow(item) .simultaneousGesture( // Don't block navigation LongPressGesture() .onEnded { _ in showContextMenu() } ) } ``` ### Gesture Breaking ScrollView **Use horizontal-only gesture detection**: ```swift 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 ```swift 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.