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
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Comprehensive guide to SwiftUI gesture recognition with composition patterns, state management, and accessibility integration.
These are real questions developers ask that this skill is designed to answer:
→ The skill covers DragGesture state management patterns and shows how to properly update view offset with @GestureState
→ The skill demonstrates gesture composition with .simultaneously, .sequenced, and .exclusively to resolve gesture conflicts
→ The skill shows the .sequenced pattern for combining LongPressGesture with DragGesture in the correct order
→ The skill covers @GestureState automatic reset behavior and the updating parameter for proper state management
→ The skill demonstrates .accessibilityAction patterns and providing alternative interactions for VoiceOver users
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
Text("Submit")
.onTapGesture {
submitForm()
}
Problems:
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)
}
@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:
@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.
@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)@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:
@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
}
)
}
@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.
@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.
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.
| 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 |
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.
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
}
}
)
@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)
}
}
)
}
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.
Image("slider")
.gesture(
DragGesture()
.onChanged { value in
updateVolume(value.translation.width)
}
)
Problem: VoiceOver users can't adjust the slider.
@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.
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
}
| 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 |
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)
}
)
}
@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.
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)
}
}
)
}
}
Text("Submit")
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture {
submit()
}
Problems:
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
}
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.
@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
}
}
}
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.
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 spaceDragGesture()
.onChanged { value in
// Called 60-120 times per second!
let position = complexCalculation(value.translation)
updateDatabase(position) // ❌ I/O in gesture
reloadAllViews() // ❌ Heavy work
}
@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)
}
)
}
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.
Check:
Text ignore gestures unless wrapped).highPriorityGesture() or .simultaneousGesture()).contentShape() to define tap area)minimumDistance, minimumDuration)// Fix unresponsive gesture
Text("Tap me")
.frame(width: 100, height: 100)
.contentShape(Rectangle()) // Define full tap area
.onTapGesture {
handleTap()
}
NavigationLink(destination: DetailView()) {
ItemRow(item)
.simultaneousGesture( // Don't block navigation
LongPressGesture()
.onEnded { _ in
showContextMenu()
}
)
}
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)
}
}
}
)
}
}
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)
}
Remember: Prefer built-in controls (Button, Slider) over custom gestures whenever possible. Gestures should enhance interaction, not replace standard controls.