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:
- Two-finger zoom — works fine
- Switch to one-finger pan — image freezes
- 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.
UIViewRepresentableexists 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.