SwiftUI Gestures (iOS 26+)
Review, write, and fix SwiftUI gesture interactions. Apply modern gesture APIs with correct composition, state management, and conflict resolution using Swift 6.3 patterns.
Scope boundary: This skill owns SwiftUI gesture recognition, composition, gesture state, and gesture-specific accessibility alternatives. Broader SwiftUI architecture/state ownership belongs in swiftui-patterns; list, scroll, form, and control layout belongs in swiftui-layout-components; broad UIKit bridging belongs in swiftui-uikit-interop.
When correcting Apple API availability, deprecation, or behavior claims, cite the relevant Sosumi or official Apple documentation URL in the response.
Contents
- Gesture Overview
- Core Gestures
- Gesture Composition
- Transient vs Persisted State
- Hierarchy and Precedence
- Common Mistakes
- Review Checklist
- References
Gesture Overview
| Gesture | Type | Value | Since |
|---|---|---|---|
TapGesture |
Discrete | Void |
iOS 13 |
SpatialTapGesture |
Discrete | SpatialTapGesture.Value |
iOS 16 |
LongPressGesture |
Discrete | Bool |
iOS 13 |
DragGesture |
Continuous | DragGesture.Value |
iOS 13 |
MagnifyGesture |
Continuous | MagnifyGesture.Value |
iOS 17 |
RotateGesture |
Continuous | RotateGesture.Value |
iOS 17 |
Discrete gestures fire once (.onEnded). Continuous gestures stream updates (.onChanged, .onEnded, .updating).
Core Gestures
// Tap: Use Button for standard actions; reserve TapGesture for multi-tap or location tracking
Text("Double tap").onTapGesture(count: 2) { handleDoubleTap() }
// Long press with transient pressing feedback
@GestureState private var isPressing = false
Circle()
.fill(isPressing ? .red : .blue)
.gesture(
LongPressGesture(minimumDuration: 0.8)
.updating($isPressing) { current, state, _ in state = current }
.onEnded { _ in triggerAction() }
)
// Drag with translation tracking
@State private var offset: CGSize = .zero
RoundedRectangle(cornerRadius: 16)
.offset(offset)
.gesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in offset = value.translation }
.onEnded { _ in withAnimation(.spring) { offset = .zero } }
)
// Magnify and Rotate (iOS 17+; replaces deprecated MagnificationGesture / RotationGesture)
MagnifyGesture().onChanged { value in currentScale = value.magnification }
RotateGesture().onChanged { value in currentAngle = value.rotation }
Gesture Composition
Combine gestures using the three composition operators:
.simultaneously(with:): Both gestures evaluate together (e.g. pinch-to-zoom + rotate)..sequenced(before:): First gesture must succeed before second starts (e.g. long press before drag)..exclusively(before:): Only one succeeds; first has precedence (e.g. double tap before long press).
let combined = MagnifyGesture()
.simultaneously(with: RotateGesture())
.onChanged { value in
if let mag = value.first { currentScale = mag.magnification }
if let rot = value.second { currentAngle = rot.rotation }
}
Transient vs Persisted State
@GestureState: Automatically resets to initial value when gesture ends or cancels. Always use with.updating(&$state).@State: Persists values between interactions. Update in.onChanged(keep lightweight) and.onEnded.
@GestureState private var dragOffset = CGSize.zero // resets on release
@State private var accumulatedOffset = CGSize.zero // persists across gestures
Hierarchy and Precedence
Control gesture arbitration across view hierarchy layers:
.gesture(_:): Lower precedence than child gestures..highPriorityGesture(_:): Parent gesture takes precedence over child gestures..simultaneousGesture(_:): Runs alongside child gestures without blocking them.GestureMask: Pass to.gesture(g, including: mask)(.all,.gesture,.subviews,.none).
Common Mistakes
- Using
onTapGestureinstead ofButton:onTapGesturelacks VoiceOver accessibility traits, focus, and keyboard activation. UseButtonwith.buttonStyle(.plain)for custom visuals. - Using deprecated gesture names: Use
MagnifyGestureandRotateGestureon iOS 17+, notMagnificationGestureorRotationGesture. - Heavy computation in
onChanged: KeeponChangedclosures frame-rate lightweight (60-120Hz). Defer heavy work or hit testing to.onEnded. - Missing visual feedback for
LongPressGesture: Always pair long-press with@GestureStateand.updating()to provide visual indication of press progress. - Assuming parent gestures override child gestures by default: Use
.highPriorityGesture()explicitly when parent gestures should win.
Review Checklist
- Correct modern gesture types used (
MagnifyGesture/RotateGestureon iOS 17+) - Accessible
Buttonpreferred overonTapGesturefor single-tap actionable controls -
@GestureStateused for transient offset/scale that resets automatically - Continuous
onChangedupdates are lightweight without allocations or heavy queries - Parent/child gesture conflicts resolved using
.highPriorityGestureor.simultaneousGesture - Composed gestures correctly handle enum results (
.first,.second) - Bounds clamped on persisted pinch/rotation values in
.onEnded
References
- Full recipes, pinch-zoom-pan, drag-to-reorder, and UIKit interop: references/gesture-patterns.md
- Gesture protocol
- TapGesture
- LongPressGesture
- DragGesture
- DragGesture.Value.velocity
- MagnifyGesture
- RotateGesture
- GestureState
- Composing SwiftUI gestures
- Adding interactivity with gestures