Swift-ios-skills swiftui-gestures
Implement, review, or improve SwiftUI gesture handling. Use when adding tap, long press, drag, magnify, or rotate gestures, composing gestures with simultaneously/sequenced/exclusively, managing transient state with @GestureState, resolving parent/child gesture conflicts with highPriorityGesture or simultaneousGesture, building custom Gesture protocol conformances, or migrating from deprecated MagnificationGesture to MagnifyGesture or using the newer RotateGesture.
git clone https://github.com/dpearson2699/swift-ios-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/dpearson2699/swift-ios-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/swiftui-gestures" ~/.claude/skills/dpearson2699-swift-ios-skills-swiftui-gestures && rm -rf "$T"
skills/swiftui-gestures/SKILL.mdSwiftUI 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.
Contents
- Gesture Overview
- TapGesture
- LongPressGesture
- DragGesture
- MagnifyGesture (iOS 17+)
- RotateGesture (iOS 17+)
- Gesture Composition
- @GestureState
- Adding Gestures to Views
- Custom Gesture Protocol
- Common Mistakes
- Review Checklist
- References
Gesture Overview
| Gesture | Type | Value | Since |
|---|---|---|---|
| Discrete | | iOS 13 |
| Discrete | | iOS 13 |
| Continuous | | iOS 13 |
| Continuous | | iOS 17 |
| Continuous | | iOS 17 |
| Discrete | | iOS 16 |
Discrete gestures fire once (
.onEnded). Continuous gestures stream
updates (.onChanged, .onEnded, .updating).
TapGesture
Recognizes one or more taps. Use the
count parameter for multi-tap.
// Single, double, and triple tap TapGesture() .onEnded { tapped.toggle() } TapGesture(count: 2) .onEnded { handleDoubleTap() } TapGesture(count: 3) .onEnded { handleTripleTap() } // Shorthand modifier Text("Tap me").onTapGesture(count: 2) { handleDoubleTap() }
LongPressGesture
Succeeds after the user holds for
minimumDuration. Fails if finger moves
beyond maximumDistance.
// Basic long press (0.5s default) LongPressGesture() .onEnded { _ in showMenu = true } // Custom duration and distance tolerance LongPressGesture(minimumDuration: 1.0, maximumDistance: 10) .onEnded { _ in triggerHaptic() }
With visual feedback via
@GestureState + .updating():
@GestureState private var isPressing = false Circle() .fill(isPressing ? .red : .blue) .scaleEffect(isPressing ? 1.2 : 1.0) .gesture( LongPressGesture(minimumDuration: 0.8) .updating($isPressing) { current, state, _ in state = current } .onEnded { _ in completedLongPress = true } )
Shorthand:
.onLongPressGesture(minimumDuration:perform:onPressingChanged:).
DragGesture
Tracks finger movement.
Value provides startLocation, location,
translation, velocity, and predictedEndTranslation.
@State private var offset = CGSize.zero RoundedRectangle(cornerRadius: 16) .fill(.blue) .frame(width: 100, height: 100) .offset(offset) .gesture( DragGesture() .onChanged { value in offset = value.translation } .onEnded { _ in withAnimation(.spring) { offset = .zero } } )
Configure minimum distance and coordinate space:
DragGesture(minimumDistance: 20, coordinateSpace: .global)
MagnifyGesture (iOS 17+)
Replaces the deprecated
MagnificationGesture. Tracks pinch-to-zoom scale.
@GestureState private var magnifyBy = 1.0 Image("photo") .resizable().scaledToFit() .scaleEffect(magnifyBy) .gesture( MagnifyGesture() .updating($magnifyBy) { value, state, _ in state = value.magnification } )
With persisted scale:
@State private var currentScale = 1.0 @GestureState private var gestureScale = 1.0 Image("photo") .scaleEffect(currentScale * gestureScale) .gesture( MagnifyGesture(minimumScaleDelta: 0.01) .updating($gestureScale) { value, state, _ in state = value.magnification } .onEnded { value in currentScale = min(max(currentScale * value.magnification, 0.5), 5.0) } )
RotateGesture (iOS 17+)
RotateGesture is the newer alternative to RotationGesture. Tracks two-finger rotation angle.
@State private var angle = Angle.zero Rectangle() .fill(.blue).frame(width: 200, height: 200) .rotationEffect(angle) .gesture( RotateGesture(minimumAngleDelta: .degrees(1)) .onChanged { value in angle = value.rotation } )
With persisted rotation:
@State private var currentAngle = Angle.zero @GestureState private var gestureAngle = Angle.zero Rectangle() .rotationEffect(currentAngle + gestureAngle) .gesture( RotateGesture() .updating($gestureAngle) { value, state, _ in state = value.rotation } .onEnded { value in currentAngle += value.rotation } )
Gesture Composition
.simultaneously(with:)
— both gestures recognized at the same time
.simultaneously(with:)let magnify = MagnifyGesture() .onChanged { value in scale = value.magnification } let rotate = RotateGesture() .onChanged { value in angle = value.rotation } Image("photo") .scaleEffect(scale) .rotationEffect(angle) .gesture(magnify.simultaneously(with: rotate))
The value is
SimultaneousGesture.Value with .first and .second optionals.
.sequenced(before:)
— first must succeed before second begins
.sequenced(before:)let longPressBeforeDrag = LongPressGesture(minimumDuration: 0.5) .sequenced(before: DragGesture()) .onEnded { value in guard case .second(true, let drag?) = value else { return } finalOffset.width += drag.translation.width finalOffset.height += drag.translation.height }
.exclusively(before:)
— only one succeeds (first has priority)
.exclusively(before:)let doubleTapOrLongPress = TapGesture(count: 2) .map { ExclusiveResult.doubleTap } .exclusively(before: LongPressGesture() .map { _ in ExclusiveResult.longPress } ) .onEnded { result in switch result { case .first(let val): handleDoubleTap() case .second(let val): handleLongPress() } }
@GestureState
@GestureState is a property wrapper that automatically resets to its
initial value when the gesture ends. Use for transient feedback; use @State
for values that persist.
@GestureState private var dragOffset = CGSize.zero // resets to .zero @State private var position = CGSize.zero // persists Circle() .offset( x: position.width + dragOffset.width, y: position.height + dragOffset.height ) .gesture( DragGesture() .updating($dragOffset) { value, state, _ in state = value.translation } .onEnded { value in position.width += value.translation.width position.height += value.translation.height } )
Custom reset with animation:
@GestureState(resetTransaction: Transaction(animation: .spring))
Adding Gestures to Views
Three modifiers control gesture priority in the view hierarchy:
| Modifier | Behavior |
|---|---|
| Default priority. Child gestures win over parent. |
| Parent gesture takes precedence over child. |
| Both parent and child gestures fire. |
// Problem: parent tap swallows child tap VStack { Button("Child") { handleChild() } // never fires } .gesture(TapGesture().onEnded { handleParent() }) // Fix 1: Use simultaneousGesture on parent VStack { Button("Child") { handleChild() } } .simultaneousGesture(TapGesture().onEnded { handleParent() }) // Fix 2: Give parent explicit priority VStack { Text("Child") .gesture(TapGesture().onEnded { handleChild() }) } .highPriorityGesture(TapGesture().onEnded { handleParent() })
GestureMask
Control which gestures participate when using
.gesture(_:including:):
.gesture(drag, including: .gesture) // only this gesture, not subviews .gesture(drag, including: .subviews) // only subview gestures .gesture(drag, including: .all) // default: this + subviews
Custom Gesture Protocol
Create reusable gestures by conforming to
Gesture:
struct SwipeGesture: Gesture { enum Direction { case left, right, up, down } let minimumDistance: CGFloat let onSwipe: (Direction) -> Void init(minimumDistance: CGFloat = 50, onSwipe: @escaping (Direction) -> Void) { self.minimumDistance = minimumDistance self.onSwipe = onSwipe } var body: some Gesture { DragGesture(minimumDistance: minimumDistance) .onEnded { value in let h = value.translation.width, v = value.translation.height if abs(h) > abs(v) { onSwipe(h > 0 ? .right : .left) } else { onSwipe(v > 0 ? .down : .up) } } } } // Usage Rectangle().gesture(SwipeGesture { print("Swiped \($0)") })
Wrap in a
View extension for ergonomic API:
extension View { func onSwipe(perform action: @escaping (SwipeGesture.Direction) -> Void) -> some View { gesture(SwipeGesture(onSwipe: action)) } }
Common Mistakes
1. Conflicting parent/child gestures
// DON'T: Parent .gesture() conflicts with child tap VStack { Button("Action") { doSomething() } } .gesture(TapGesture().onEnded { parentAction() }) // DO: Use .simultaneousGesture() or .highPriorityGesture() VStack { Button("Action") { doSomething() } } .simultaneousGesture(TapGesture().onEnded { parentAction() })
2. Using @State instead of @GestureState for transient state
// DON'T: @State doesn't auto-reset — view stays offset after gesture ends @State private var dragOffset = CGSize.zero DragGesture() .onChanged { value in dragOffset = value.translation } .onEnded { _ in dragOffset = .zero } // manual reset required // DO: @GestureState auto-resets when gesture ends @GestureState private var dragOffset = CGSize.zero DragGesture() .updating($dragOffset) { value, state, _ in state = value.translation }
3. Not using .updating() for intermediate feedback
// DON'T: No visual feedback during long press LongPressGesture(minimumDuration: 2.0) .onEnded { _ in showResult = true } // DO: Provide feedback while pressing @GestureState private var isPressing = false LongPressGesture(minimumDuration: 2.0) .updating($isPressing) { current, state, _ in state = current } .onEnded { _ in showResult = true }
4. Using deprecated gesture types on iOS 17+
// DON'T: Deprecated since iOS 17 MagnificationGesture() // deprecated — use MagnifyGesture() // PREFER: Newer gesture types MagnifyGesture() // iOS 17+ RotateGesture() // iOS 17+ (newer alternative to RotationGesture)
5. Heavy computation in onChanged
// DON'T: Expensive work called every frame (~60-120 Hz) DragGesture() .onChanged { value in let result = performExpensiveHitTest(at: value.location) let filtered = applyComplexFilter(result) updateModel(filtered) } // DO: Throttle or defer expensive work DragGesture() .onChanged { value in dragPosition = value.location // lightweight state update only } .onEnded { value in performExpensiveHitTest(at: value.location) // once at end }
Review Checklist
- Correct gesture type:
/MagnifyGesture
(not deprecatedRotateGesture
/Magnification
variants)Rotation -
used for transient values that should reset;@GestureState
for persisted values@State -
provides intermediate visual feedback during continuous gestures.updating() - Parent/child conflicts resolved with
or.highPriorityGesture().simultaneousGesture() -
closures are lightweight — no heavy computation every frameonChanged - Composed gestures use correct combinator:
,simultaneously
, orsequencedexclusively - Persisted scale/rotation clamped to reasonable bounds in
onEnded - Custom
conformances useGesture
(notvar body: some Gesture
)View - Gesture-driven animations use
or similar for natural deceleration.spring -
considered when mixing gestures across view hierarchy levelsGestureMask
References
- See references/gesture-patterns.md for drag-to-reorder, pinch-to-zoom, combined rotate+scale, velocity calculations, and SwiftUI/UIKit gesture interop.
- Gesture protocol
- TapGesture
- LongPressGesture
- DragGesture
- MagnifyGesture
- RotateGesture
- GestureState
- Composing SwiftUI gestures
- Adding interactivity with gestures