This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
956 lines
23 KiB
Markdown
956 lines
23 KiB
Markdown
---
|
|
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.
|