# Gesture Patterns

Worked interactions for the `swiftui-gestures` skill. The SKILL.md explains the
gesture types, composition and `@GestureState`; this file assumes that
material.

## Contents

- [Pinch-to-zoom with MagnifyGesture](#pinch-to-zoom-with-magnifygesture)
- [Combined rotate and scale](#combined-rotate-and-scale)
- [Drag-to-reorder](#drag-to-reorder)
- [Gesture velocity calculations](#gesture-velocity-calculations)
- [Long-press then drag](#long-press-then-drag)
- [SwiftUI and UIKit gesture interop](#swiftui-and-uikit-gesture-interop)
- [Accessibility considerations](#accessibility-considerations)

## Pinch-to-zoom with MagnifyGesture

A zoomable image that can also be panned once it is enlarged, and double-tapped
back to its original size.

```swift
struct ZoomableImage: View {
    let image: Image

    private let smallestZoom: CGFloat = 0.5
    private let largestZoom: CGFloat = 5.0

    @State private var zoom: CGFloat = 1
    @State private var pan = CGSize.zero
    @GestureState private var livePinch: CGFloat = 1
    @GestureState private var livePan = CGSize.zero

    var body: some View {
        image
            .resizable()
            .scaledToFit()
            .scaleEffect(zoom * livePinch)
            .offset(x: pan.width + livePan.width, y: pan.height + livePan.height)
            .gesture(pinch)
            .simultaneousGesture(drag)
            .onTapGesture(count: 2) {
                withAnimation(.spring) {
                    zoom = 1
                    pan = .zero
                }
            }
    }

    private var pinch: some Gesture {
        MagnifyGesture()
            .updating($livePinch) { current, live, _ in live = current.magnification }
            .onEnded { final in
                let proposed = zoom * final.magnification
                withAnimation(.spring) {
                    zoom = min(max(proposed, smallestZoom), largestZoom)
                }
            }
    }

    private var drag: some Gesture {
        DragGesture()
            .updating($livePan) { current, live, _ in
                if zoom > 1 { live = current.translation }
            }
            .onEnded { final in
                guard zoom > 1 else { return }
                pan.width += final.translation.width
                pan.height += final.translation.height
            }
    }
}
```

Panning only applies while `zoom` is above 1, so at normal size a drag does
nothing and the image cannot wander off screen. The double tap uses
`onTapGesture(count:)`, which is the legitimate use of that modifier.

## Combined rotate and scale

```swift
struct TurnAndScaleSticker: View {
    @State private var angle: Angle = .zero
    @State private var size: CGFloat = 1
    @GestureState private var liveAngle = Angle.zero
    @GestureState private var liveSize: CGFloat = 1

    var body: some View {
        let sticker = Image(systemName: "star.fill")
        return sticker
            .font(.system(size: 80))
            .rotationEffect(angle + liveAngle)
            .scaleEffect(size * liveSize)
            .gesture(twist.simultaneously(with: stretch))
    }

    private var twist: some Gesture {
        RotateGesture()
            .updating($liveAngle) { current, live, _ in live = current.rotation }
            .onEnded { final in angle += final.rotation }
    }

    private var stretch: some Gesture {
        MagnifyGesture()
            .updating($liveSize) { current, live, _ in live = current.magnification }
            .onEnded { final in size = min(max(size * final.magnification, 0.3), 5.0) }
    }
}
```

## Drag-to-reorder

A short press lifts a row; dragging then moves it. The press and the drag are
sequenced so a normal scroll does not start a reorder.

```swift
struct ReorderableStack: View {
    @State private var stops = ["Depot", "Market", "School", "Clinic", "Harbour"]
    @State private var lifted: String?
    @State private var lift = CGFloat.zero

    private let rowPitch: CGFloat = 50

    var body: some View {
        VStack(alignment: .center, spacing: 0) {
            ForEach(stops, id: \.self) { stop in
                Text(stop)
                    .frame(maxWidth: .infinity, minHeight: rowPitch)
                    .background(lifted == stop ? Color.accentColor.opacity(0.2) : Color.clear)
                    .shadow(radius: lifted == stop ? 6 : 0)
                    .scaleEffect(lifted == stop ? 1.05 : 1)
                    .offset(y: lifted == stop ? lift : 0)
                    .zIndex(lifted == stop ? 1 : 0)
                    .gesture(reorder(stop))
            }
        }
    }

    private func reorder(_ stop: String) -> some Gesture {
        let liftThenSlide = LongPressGesture(minimumDuration: 0.35).sequenced(before: DragGesture())
        return liftThenSlide
            .onChanged { phase in
                switch phase {
                case .first(true):
                    withAnimation(.spring) { lifted = stop }
                case .second(true, let drag?):
                    lift = drag.translation.height
                default:
                    return
                }
            }
            .onEnded { _ in
                commitMove(of: stop)
                withAnimation(.spring) {
                    lifted = nil
                    lift = 0
                }
            }
    }

    private func commitMove(of stop: String) {
        guard let from = stops.firstIndex(of: stop) else { return }
        let steps = Int((lift / rowPitch).rounded())
        let to = min(max(from + steps, 0), stops.count - 1)
        guard to != from else { return }
        stops.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to)
    }
}
```

`move(fromOffsets:toOffset:)` inserts before the destination offset, which is
why a downward move adds one.

## Gesture velocity calculations

`DragGesture.Value.velocity` (iOS 17 and later) reports points per second as a
`CGSize`. A sheet-like panel that dismisses on a long drag or a fast flick:

```swift
struct FlickToClosePanel: View {
    @Binding var isShown: Bool
    @State private var pull: CGFloat = 0

    private let closeDistance: CGFloat = 200
    private let closeSpeed: CGFloat = 800

    var body: some View {
        let panel = RoundedRectangle(cornerRadius: 24)
        return panel
            .fill(.background)
            .frame(height: 360)
            .offset(y: pull)
            .opacity(1 - min(pull / closeDistance, 1) * 0.5)
            .gesture(
                DragGesture()
                    .onChanged { drag in pull = max(drag.translation.height, 0) }
                    .onEnded { drag in
                        if pull > closeDistance || drag.velocity.height > closeSpeed {
                            isShown = false
                        } else {
                            withAnimation(.spring) { pull = 0 }
                        }
                    }
            )
    }
}
```

Only downward movement counts; upward drags clamp to 0. The panel fades toward
half opacity as it approaches the threshold.

Before iOS 17 there is no `velocity`. Estimate momentum from how far the
system predicts the drag would coast:

```swift
let coastX = drag.predictedEndTranslation.width - drag.translation.width
let coastY = drag.predictedEndTranslation.height - drag.translation.height
let momentum = (coastX * coastX + coastY * coastY).squareRoot()
let isFlick = momentum > 500
```

## Long-press then drag

Model the combined gesture as an explicit state machine held in `@GestureState`.

```swift
enum PressDrag {
    case idle
    case armed
    case moving(CGSize)

    var offset: CGSize {
        if case .moving(let amount) = self { return amount }
        return .zero
    }

    var engaged: Bool {
        if case .idle = self { return false }
        return true
    }
}

struct PressThenDragChip: View {
    @GestureState private var phase = PressDrag.idle
    @State private var spot = CGPoint(x: 150, y: 150)

    var body: some View {
        let chip = Circle()
        return chip
            .fill(phase.engaged ? .orange : .blue)
            .frame(width: 70, height: 70)
            .scaleEffect(phase.engaged ? 1.2 : 1)
            .position(x: spot.x + phase.offset.width, y: spot.y + phase.offset.height)
            .animation(.spring, value: phase.engaged)
            .gesture(
                LongPressGesture(minimumDuration: 0.6)
                    .sequenced(before: DragGesture())
                    .updating($phase) { step, machine, _ in
                        if case .second(true, let drag) = step {
                            machine = .moving(drag?.translation ?? .zero)
                        } else if case .first(true) = step {
                            machine = .armed
                        } else {
                            machine = .idle
                        }
                    }
                    .onEnded { outcome in
                        guard case .second(true, let drag?) = outcome else { return }
                        spot.x += drag.translation.width
                        spot.y += drag.translation.height
                    }
            )
    }
}
```

## SwiftUI and UIKit gesture interop

### A UIKit recognizer inside SwiftUI (iOS 18+)

`UIGestureRecognizerRepresentable` brings any `UIGestureRecognizer` into a
SwiftUI view and is attached with `.gesture(_:)`.

```swift
struct IncrementalPinch: UIGestureRecognizerRepresentable {
    var onStep: (CGFloat) -> Void

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

    func handleUIGestureRecognizerAction(_ recognizer: UIPinchGestureRecognizer, context: Context) {
        if recognizer.state == .changed { onStep(recognizer.scale) }
        if recognizer.state == .changed || recognizer.state == .ended {
            recognizer.scale = 1   // report each step, not the running total
        }
    }
}

// Usage
mapLayer.gesture(IncrementalPinch { step in zoom *= step })
```

### SwiftUI gestures inside UIKit

Host the SwiftUI view with `UIHostingController`; its gestures work unchanged.

```swift
final class CanvasViewController: UIViewController {
    private let planName = "floorplan"

    override func viewDidLoad() {
        super.viewDidLoad()
        let content = ZoomableImage(image: Image(planName))
        let host = UIHostingController(rootView: content)
        addChild(host)
        view.addSubview(host.view)
        host.view.frame = view.bounds
        host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        host.didMove(toParent: self)
    }
}
```

### Letting both sides recognize

When a SwiftUI gesture and a UIKit recognizer overlap, allow both on each side:
use `.simultaneousGesture` in SwiftUI, and have the UIKit delegate agree to run
together.

```swift
extension CanvasViewController: UIGestureRecognizerDelegate {
    func gestureRecognizer(
        _ recognizer: UIGestureRecognizer,
        shouldRecognizeSimultaneouslyWith partner: UIGestureRecognizer
    ) -> Bool {
        true
    }
}
```

## Accessibility considerations

Every gesture-only interaction needs an alternative for VoiceOver, Switch
Control and keyboard users. Expose the same effect as accessibility actions:

```swift
Text(stop)
    .gesture(reorder(stop))
    .accessibilityAction(.default) { openDetails(for: stop) }
    .accessibilityAction(named: "Move earlier") { shift(stop, by: -1) }
    .accessibilityAction(named: "Move later") { shift(stop, by: 1) }
```
