All skills
dpearson2699 avatar

/swiftui-gestures

@45c9085

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.

Use this Skill: https://skilld.dev/gh/dpearson2699/swift-ios-skills/swiftui-gestures

This session only. Nothing lands on disk.

referencesgesture-patterns.md

≈3.5k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Gesture Patterns — Advanced Reference

Extended patterns for SwiftUI gesture handling. See the main SKILL.md for core APIs and common mistakes.

Contents

Pinch-to-Zoom with MagnifyGesture

Full implementation with clamped scale, double-tap reset, and smooth animation:

struct PinchToZoomView: View {
    @State private var currentScale = 1.0
    @State private var currentOffset = CGSize.zero
    @GestureState private var gestureScale = 1.0
    @GestureState private var dragOffset = CGSize.zero

    private let minScale = 0.5
    private let maxScale = 5.0

    var body: some View {
        Image("photo")
            .resizable()
            .scaledToFit()
            .scaleEffect(currentScale * gestureScale)
            .offset(
                x: currentOffset.width + dragOffset.width,
                y: currentOffset.height + dragOffset.height
            )
            .gesture(magnifyGesture)
            .simultaneousGesture(panGesture)
            .onTapGesture(count: 2) {
                withAnimation(.spring) {
                    currentScale = 1.0
                    currentOffset = .zero
                }
            }
    }

    private var magnifyGesture: some Gesture {
        MagnifyGesture()
            .updating($gestureScale) { value, state, _ in
                state = value.magnification
            }
            .onEnded { value in
                let newScale = currentScale * value.magnification
                withAnimation(.spring) {
                    currentScale = min(max(newScale, minScale), maxScale)
                }
            }
    }

    private var panGesture: some Gesture {
        DragGesture()
            .updating($dragOffset) { value, state, _ in
                guard currentScale > 1.0 else { return }
                state = value.translation
            }
            .onEnded { value in
                guard currentScale > 1.0 else { return }
                currentOffset.width += value.translation.width
                currentOffset.height += value.translation.height
            }
    }
}

Combined Rotate + Scale

Simultaneous rotation and magnification for image editing:

struct RotateScaleView: View {
    @State private var currentAngle = Angle.zero
    @State private var currentScale = 1.0
    @GestureState private var gestureAngle = Angle.zero
    @GestureState private var gestureScale = 1.0

    var body: some View {
        Image("sticker")
            .resizable()
            .scaledToFit()
            .frame(width: 200, height: 200)
            .rotationEffect(currentAngle + gestureAngle)
            .scaleEffect(currentScale * gestureScale)
            .gesture(
                RotateGesture()
                    .updating($gestureAngle) { value, state, _ in
                        state = value.rotation
                    }
                    .onEnded { value in
                        currentAngle += value.rotation
                    }
                    .simultaneously(with:
                        MagnifyGesture()
                            .updating($gestureScale) { value, state, _ in
                                state = value.magnification
                            }
                            .onEnded { value in
                                currentScale *= value.magnification
                                currentScale = min(max(currentScale, 0.3), 5.0)
                            }
                    )
            )
    }
}

Drag-to-Reorder

Drag gesture with haptic feedback for list reordering:

struct ReorderableList: View {
    @State private var items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]
    @State private var draggingItem: String?
    @State private var dragOffset = CGSize.zero

    var body: some View {
        VStack {
            ForEach(items, id: \.self) { item in
                ItemRow(title: item, isDragging: draggingItem == item)
                    .offset(y: draggingItem == item ? dragOffset.height : 0)
                    .zIndex(draggingItem == item ? 1 : 0)
                    .gesture(
                        LongPressGesture(minimumDuration: 0.3)
                            .sequenced(before: DragGesture())
                            .onChanged { value in
                                switch value {
                                case .first(true):
                                    withAnimation(.spring) {
                                        draggingItem = item
                                    }
                                case .second(true, let drag):
                                    dragOffset = drag?.translation ?? .zero
                                    updateOrder(for: item, translation: dragOffset.height)
                                default:
                                    break
                                }
                            }
                            .onEnded { _ in
                                withAnimation(.spring) {
                                    draggingItem = nil
                                    dragOffset = .zero
                                }
                            }
                    )
            }
        }
    }

    private func updateOrder(for item: String, translation: CGFloat) {
        guard let sourceIndex = items.firstIndex(of: item) else { return }
        let rowHeight: CGFloat = 50
        let offset = Int(translation / rowHeight)
        let destinationIndex = min(max(sourceIndex + offset, 0), items.count - 1)
        if sourceIndex != destinationIndex {
            withAnimation(.spring) {
                items.move(
                    fromOffsets: IndexSet(integer: sourceIndex),
                    toOffset: destinationIndex > sourceIndex
                        ? destinationIndex + 1 : destinationIndex
                )
            }
        }
    }
}

struct ItemRow: View {
    let title: String
    let isDragging: Bool

    var body: some View {
        Text(title)
            .frame(maxWidth: .infinity, minHeight: 46)
            .background(isDragging ? Color.blue.opacity(0.2) : Color(.secondarySystemBackground))
            .clipShape(.rect(cornerRadius: 8))
            .shadow(radius: isDragging ? 4 : 0)
            .scaleEffect(isDragging ? 1.05 : 1.0)
    }
}

Gesture Velocity Calculations

Use DragGesture.Value.velocity for the current drag velocity. Use predictedEndTranslation when you need a projection of where the drag would end if the user stopped now.

struct FlickDismissView: View {
    @State private var offset = CGSize.zero
    @State private var isDismissed = false

    private let dismissThreshold: CGFloat = 200
    private let velocityThreshold: CGFloat = 800

    var body: some View {
        if !isDismissed {
            CardView()
                .offset(y: offset.height)
                .opacity(opacity)
                .gesture(
                    DragGesture()
                        .onChanged { value in
                            if value.translation.height > 0 {
                                offset = value.translation
                            }
                        }
                        .onEnded { value in
                            let velocity = value.velocity.height
                            let distance = value.translation.height

                            if distance > dismissThreshold
                                || velocity > velocityThreshold
                            {
                                withAnimation(.spring) {
                                    isDismissed = true
                                }
                            } else {
                                withAnimation(.spring) {
                                    offset = .zero
                                }
                            }
                        }
                )
        }
    }

    private var opacity: Double {
        let progress = min(offset.height / dismissThreshold, 1.0)
        return 1.0 - (progress * 0.5)
    }
}

Projection heuristic from predicted end translation:

DragGesture()
    .onEnded { value in
        // Approximate velocity from predicted vs actual
        let predictedDelta = CGSize(
            width: value.predictedEndTranslation.width - value.translation.width,
            height: value.predictedEndTranslation.height - value.translation.height
        )
        let speed = sqrt(
            predictedDelta.width * predictedDelta.width
            + predictedDelta.height * predictedDelta.height
        )
        if speed > 500 { handleFlick() }
    }

Long-Press then Drag (Sequenced Gesture with State Enum)

Model complex gesture states with an enum for clarity:

enum DragState {
    case inactive
    case pressing
    case dragging(translation: CGSize)

    var translation: CGSize {
        switch self {
        case .inactive, .pressing: return .zero
        case .dragging(let t): return t
        }
    }

    var isActive: Bool {
        switch self {
        case .inactive: return false
        case .pressing, .dragging: return true
        }
    }
}

struct LongPressDragView: View {
    @GestureState private var dragState = DragState.inactive
    @State private var position = CGSize.zero

    var body: some View {
        Circle()
            .fill(dragState.isActive ? .red : .blue)
            .frame(width: 80, height: 80)
            .shadow(radius: dragState.isActive ? 8 : 0)
            .offset(
                x: position.width + dragState.translation.width,
                y: position.height + dragState.translation.height
            )
            .animation(.spring, value: dragState.isActive)
            .gesture(
                LongPressGesture(minimumDuration: 0.5)
                    .sequenced(before: DragGesture())
                    .updating($dragState) { value, state, _ in
                        switch value {
                        case .first(true):
                            state = .pressing
                        case .second(true, let drag):
                            state = .dragging(
                                translation: drag?.translation ?? .zero
                            )
                        default:
                            state = .inactive
                        }
                    }
                    .onEnded { value in
                        guard case .second(true, let drag?) = value else {
                            return
                        }
                        position.width += drag.translation.width
                        position.height += drag.translation.height
                    }
            )
    }
}

SwiftUI + UIKit Gesture Interop

UIGestureRecognizerRepresentable (iOS 18+)

Bridge UIKit gesture recognizers into SwiftUI:

struct PinchGestureView: UIGestureRecognizerRepresentable {
    @Binding var scale: CGFloat

    func makeUIGestureRecognizer(context: Context) -> UIPinchGestureRecognizer {
        UIPinchGestureRecognizer()
    }

    func handleUIGestureRecognizerAction(
        _ recognizer: UIPinchGestureRecognizer,
        context: Context
    ) {
        switch recognizer.state {
        case .changed:
            scale = recognizer.scale
        case .ended:
            scale = recognizer.scale
            recognizer.scale = 1.0
        default:
            break
        }
    }
}

// Usage in SwiftUI
struct ContentView: View {
    @State private var scale: CGFloat = 1.0

    var body: some View {
        Image("photo")
            .scaleEffect(scale)
            .gesture(PinchGestureView(scale: $scale))
    }
}

Using SwiftUI gestures in UIKit via UIHostingController

class GestureHostingController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let swiftUIView = InteractiveCard()
        let hostingController = UIHostingController(rootView: swiftUIView)
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.view.frame = view.bounds
        hostingController.didMove(toParent: self)
    }
}

struct InteractiveCard: View {
    @GestureState private var dragOffset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue.gradient)
            .frame(width: 200, height: 300)
            .offset(dragOffset)
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation
                    }
            )
    }
}

Coordinating UIKit and SwiftUI gestures

When mixing gesture recognizers, use simultaneousGesture on the SwiftUI side and UIGestureRecognizerDelegate on the UIKit side to prevent conflicts:

// In your UIViewRepresentable coordinator
func gestureRecognizer(
    _ gestureRecognizer: UIGestureRecognizer,
    shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
) -> Bool {
    true  // allow both UIKit and SwiftUI gestures to fire
}

Accessibility Considerations

Always provide accessible alternatives for gesture-driven interactions:

Image("draggable")
    .offset(offset)
    .gesture(dragGesture)
    .accessibilityAction(.default) { showAccessibleUI() }
    .accessibilityAction(named: "Move up") {
        withAnimation { offset.height -= 50 }
    }
    .accessibilityAction(named: "Move down") {
        withAnimation { offset.height += 50 }
    }

Source: SKILL.md on GitHub

No alerts16d4 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides comprehensive documentation and patterns for SwiftUI gesture handling. No security issues were detected.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 45c9085. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Steadyupdated 3 months ago
  • swiftui
  • gestures
  • ios
  • tap
  • drag
  • long-press
  • magnify
  • rotate
  • state-management

README badge

README badge for dpearson2699/swift-ios-skills/swiftui-gestures

Implement, review, and fix SwiftUI gesture handling using modern APIs including TapGesture, LongPressGesture, DragGesture, MagnifyGesture, and RotateGesture. Covers gesture composition (simultaneously, sequenced, exclusively), transient state management with @GestureState, and parent/child gesture conflict resolution using highPriorityGesture or simultaneousGesture.

Generated from the current SKILL.md.

Does this skill cover iOS 13 gestures like TapGesture and DragGesture, or only iOS 17+?
It covers both. The skill documents TapGesture, LongPressGesture, DragGesture, and SpatialTapGesture (iOS 13–16 baseline), plus newer iOS 17+ gestures like MagnifyGesture and RotateGesture, with clear deprecation notes on old types like MagnificationGesture.
How does @GestureState differ from @State for gesture handling?
@GestureState automatically resets to its initial value when the gesture ends, making it ideal for transient feedback like drag offsets. @State persists across gesture cycles and requires manual reset.
How do I resolve conflicts between parent and child gestures?
Use .simultaneousGesture() to let both fire, .highPriorityGesture() to give the parent precedence, or .gesture() with GestureMask to control which gestures participate in the hierarchy.
Can I build custom gestures?
Yes. Conform to the Gesture protocol and compose existing gestures like DragGesture in the body property, then optionally wrap it in a View extension for an ergonomic API.
What should I do about heavy computation during drag or continuous gestures?
Update lightweight state in onChanged and defer expensive work like hit tests or filtering to onEnded, or throttle updates to avoid frame-rate performance hits.

Generated from the current SKILL.md. These answers refresh after source changes.