dpearson2699/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.
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-gestures
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.
@GestureState| Gesture | Type | Value | Since |
|---|---|---|---|
| TapGesture | Discrete | Void | iOS 13 |
| LongPressGesture | Discrete | Bool | iOS 13 |
| DragGesture | Continuous | DragGesture.Value | iOS 13 |
| MagnifyGesture | Continuous | MagnifyGesture.Value | iOS 17 |
| RotateGesture | Continuous | RotateGesture.Value | iOS 17 |
| SpatialTapGesture | Discrete | SpatialTapGesture.Value | iOS 16 |
Discrete gestures fire once (.onEnded). Continuous gestures stream
updates (.onChanged, .onEnded, .updating).
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() }
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:).
Tracks finger movement. Value provides startLocation, location,
translation, velocity, and predictedEndTranslation.
DragGesture.Value.velocity is available with DragGesture from iOS 13+;
do not confuse it with iOS 17+ gesture types such as MagnifyGesture and
RotateGesture.
@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)
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
}
)
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 }
)
For persisted, clamped magnification and combined rotation examples, load
references/gesture-patterns.md.
.simultaneously(with:) — both gestures recognized at the same timelet 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 beginslet 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)let doubleTapOrLongPress = TapGesture(count: 2)
.exclusively(before:
LongPressGesture()
)
.onEnded { result in
switch result {
case .first(_): handleDoubleTap()
case .second(_): 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))
Three modifiers control gesture priority in the view hierarchy:
| Modifier | Behavior |
|---|---|
| .gesture() | Lower precedence than gestures already defined by the view or its children. |
| .highPriorityGesture() | Added gesture takes precedence over existing gestures. |
| .simultaneousGesture() | Added gesture processes at the same priority as existing gestures. |
let parentTap = TapGesture().onEnded { handleParent() }
VStack {
Image(systemName: "star.fill")
.onTapGesture { handleChild() }
}
.simultaneousGesture(parentTap) // Both handlers run on child content.
Use .gesture(parentTap) for the default lower-precedence parent gesture, or
.highPriorityGesture(parentTap) when the added parent gesture should win.
Control which gestures participate when using .gesture(_:including:):
.gesture(drag, including: .gesture) // added gesture; disables subview gestures
.gesture(drag, including: .subviews) // subview gestures; disables added gesture
.gesture(drag, including: .all) // default: added + subview gestures
.gesture(drag, including: .none) // disables added + subview gestures
Create reusable gestures by conforming to Gesture:
struct SwipeGesture: Gesture {
enum Direction { case left, right, up, down }
typealias Value = Direction
let minimumDistance: CGFloat
init(minimumDistance: CGFloat = 50) {
self.minimumDistance = minimumDistance
}
var body: AnyGesture<Direction> {
AnyGesture(
DragGesture(minimumDistance: minimumDistance)
.map { value in
let h = value.translation.width, v = value.translation.height
if abs(h) > abs(v) {
return h > 0 ? .right : .left
} else {
return v > 0 ? .down : .up
}
}
)
}
}
// Usage
Rectangle().gesture(SwipeGesture().onEnded { 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().onEnded(action))
}
}
Do not assume a parent .gesture() overrides child gestures. Choose the
relationship explicitly as shown in Adding Gestures to Views.
@State instead of @GestureState for transient stateUse @GestureState for values that should reset when recognition ends; keep
persistent results in @State. See @GestureState.
// 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 }
// DON'T: Deprecated since iOS 17
MagnificationGesture() // deprecated — use MagnifyGesture()
// DO: Use newer gesture types
MagnifyGesture() // iOS 17+
RotateGesture() // iOS 17+ (newer alternative to RotationGesture)
// 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
}
// DON'T: onTapGesture has no accessibility traits, VoiceOver role,
// Voice Control targeting, Switch Control scanning, or keyboard activation
Text("Delete")
.onTapGesture { deleteItem() }
// DO: Button provides all of these automatically
Button("Delete", role: .destructive) { deleteItem() }
// DO: For custom visuals, use ButtonStyle instead of onTapGesture
Button { toggleExpanded() } label: {
CardView()
}
.buttonStyle(.plain)
Reserve onTapGesture for multi-tap (count: 2+), tap-location-dependent
behavior, or adding tap recognition to non-interactive content that already
has appropriate accessibility traits.
MagnifyGesture/RotateGesture (not deprecated Magnification/Rotation variants)@GestureState used for transient values that should reset; @State for persisted values.updating() provides intermediate visual feedback during continuous gestures.highPriorityGesture() or .simultaneousGesture()onChanged closures are lightweight — no heavy computation every framesimultaneously, sequenced, or exclusivelyonEndedGesture conformances return a gesture body; use AnyGesture<Value> when mapping to a custom Value.spring or similar for natural decelerationGestureMask considered when mixing gestures across view hierarchy levelsonTapGesture only used where count > 1, tap location, or coordinate space matters — plain single-tap actions use Button insteadTake dpearson2699/swiftui-gestures from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.