The Problem

I needed a full-screen image viewer for a document scanning app. Pinch to zoom in, drag to pan around while zoomed. Standard stuff. Surely SwiftUI can handle this natively, right?

The Journey

Attempt 1: MagnifyGesture Only

The straightforward approach:

Image(uiImage: uiImage)
    .scaleEffect(scale)
    .gesture(
        MagnifyGesture()
            .onChanged { value in
                scale = max(1.0, value.magnification)
            }
            .onEnded { _ in
                withAnimation { scale = 1.0 }
            }
    )

Releasing fingers snaps back to 1x. Adding lastScale to persist the zoom level works, but now there’s no way to pan the zoomed image.

Attempt 2: MagnifyGesture + DragGesture

So I added a DragGesture:

Image(uiImage: uiImage)
    .scaleEffect(scale)
    .offset(offset)
    .gesture(magnifyGesture)
    .gesture(dragGesture)

Looks like it works in theory. On a real device, it’s a different story:

  1. Two-finger zoom — works fine
  2. Switch to one-finger pan — image freezes
  3. Lift finger — image jumps to the new position

The transition between MagnifyGesture ending and DragGesture beginning creates a dead zone where neither gesture is fully active. Tried .simultaneousGesture — same issue, slightly different flavor.

The Root Cause

SwiftUI’s gesture system handles individual gestures well, but smooth simultaneous zoom + pan is outside its design scope. UIKit’s UIScrollView has had decades of optimization for exactly this — inertial scrolling, bounce, seamless transitions between zoom and pan.

The Fix: UIScrollView via UIViewRepresentable

Wrapped UIScrollView into SwiftUI:

struct ZoomableImageView: UIViewRepresentable {
    let imageData: Data

    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()
        scrollView.delegate = context.coordinator
        scrollView.minimumZoomScale = 1.0
        scrollView.maximumZoomScale = 5.0
        scrollView.bouncesZoom = true

        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.image = UIImage(data: imageData)
        imageView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(imageView)

        // Pin imageView to scrollView via Auto Layout
        NSLayoutConstraint.activate([
            imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
            imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
            imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
            imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
            imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor),
        ])

        return scrollView
    }
}

The Coordinator handles viewForZooming and centers the image after zoom:

class Coordinator: NSObject, UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        scrollView.subviews.first
    }

    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        guard let imageView = scrollView.subviews.first else { return }
        let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) / 2, 0)
        let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) / 2, 0)
        imageView.center = CGPoint(
            x: scrollView.contentSize.width / 2 + offsetX,
            y: scrollView.contentSize.height / 2 + offsetY
        )
    }
}

Double-tap zoom is a simple UITapGestureRecognizer addition.

Gotcha: Initial Layout

First version used manual frame setting — blank white screen. At makeUIView time, scrollView.bounds is still .zero. Switching to Auto Layout constraints fixed it immediately.

Takeaway

  • SwiftUI gestures are optimized for single interactions. When you need compound gestures like zoom + pan, UIKit wrapping is the better path.
  • Dropping the “pure SwiftUI” mindset actually speeds things up. UIViewRepresentable exists to patch SwiftUI’s gaps with UIKit’s strengths. About 30 lines of code buys you decades of UIKit gesture optimization — that’s a trade worth making.