home / skills / charleswiltgen / axiom / axiom-swiftui-gestures

This skill helps you implement and debug SwiftUI gestures, composing patterns, managing state, and ensuring accessibility across platforms.

npx playbooks add skill charleswiltgen/axiom --skill axiom-swiftui-gestures

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
22.7 KB
---
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<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

```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.

Overview

This skill is a compact, battle-tested guide for implementing SwiftUI gestures across xOS (iOS, iPadOS, macOS, watchOS, visionOS). It covers tap, drag, long press, magnification, rotation, custom gestures, composition patterns, gesture state management, accessibility integration, and cross-platform differences. Use it to design responsive, accessible, and conflict-free gesture interactions.

How this skill works

The skill inspects common gesture patterns and recommends concrete implementations using @GestureState and @State for temporary vs committed values. It explains composition operators (.simultaneously, .sequenced, .exclusively), gesture updating/onEnded flows, predicted end locations and velocity handling, and how to expose alternatives for VoiceOver and keyboard users. Examples include drag-to-move, long-press-then-drag, pinch-to-zoom, and custom directional swipe gestures.

When to use it

  • Implementing tap, drag, long press, magnification, or rotation interactions.
  • Composing multiple gestures that must run simultaneously, sequentially, or exclusively.
  • Managing transient gesture feedback vs persistent state using @GestureState and @State.
  • Debugging conflicts when one gesture blocks another or gestures are unresponsive.
  • Making gesture-only controls accessible to VoiceOver and keyboard users.
  • Creating reusable, custom gestures for app-wide behaviors (e.g., swipe directions).

Best practices

  • Prefer semantic controls (Button) for taps that trigger actions; use TapGesture only for location/count data.
  • Use @GestureState for temporary, auto-resetting feedback and @State to persist final values.
  • Compose gestures explicitly with .simultaneously, .sequenced, or .exclusively to avoid conflicts.
  • Provide accessibility alternatives (.accessibilityAction, .accessibilityAdjustableAction) and keyboard handlers for non-visual users.
  • Use predictedEndLocation or compute velocity for momentum and smooth throw animations.
  • Keep animations responsive by setting transaction.animation inside updating when appropriate.

Example use cases

  • Draggable card UI that shows live drag feedback with @GestureState and commits position on end.
  • Photo viewer supporting simultaneous drag and pinch-to-zoom using .simultaneously composition.
  • Long-press to enter edit mode then drag to reorder using .sequenced before a DragGesture.
  • Exclusive single vs double-tap handlers with .exclusively to avoid double-triggering.
  • Custom SwipeGesture that maps DragGesture translations to discrete directions for archive/delete actions.

FAQ

Why use @GestureState instead of updating @State during drag?

@GestureState provides temporary values that automatically reset when the gesture ends, avoids excessive view commits during high-frequency updates, and supports smoother animations.

How do I let VoiceOver users perform gesture-only actions?

Expose equivalent accessibility actions such as .accessibilityAdjustableAction or .accessibilityAction and provide labels/values so VoiceOver users can adjust controls without the gesture.