Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
Review, write, and fix SwiftUI gesture interactions. Apply modern gesture APIs with correct composition, state management, and conflict resolution using Swift 6.3 patterns.
@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.
@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
}
)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 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 }
).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)
.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))
Three modifiers control gesture priority in the view hierarchy:
| Modifier | Behavior |
|---|---|
.gesture() | Default priority. Child gestures win over parent. |
.highPriorityGesture() | Parent gesture takes precedence over child. |
.simultaneousGesture() | 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() })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 + subviewsCreate 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))
}
}// 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() })@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
}// 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 use var body: some Gesture (not View).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 insteadskills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references