How to build a zoomable image in SwiftUI
Attach a MagnificationGesture to an Image and pipe its value into scaleEffect. Clamp the final value in onEnded and add a double-tap gesture to snap back to 1×.
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
Image("landscape")
.resizable()
.scaledToFit()
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { value in
scale = lastScale * value
}
.onEnded { _ in
lastScale = max(1.0, min(scale, 5.0))
withAnimation(.spring(duration: 0.25)) {
scale = lastScale
}
}
)
Full implementation
The complete view below combines MagnificationGesture with a DragGesture using simultaneousGesture so the user can pan after pinching in. A double-tap resets both scale and offset with a spring animation. All gestures are composed with simultaneousGesture rather than chained, which prevents SwiftUI's gesture recognizer from suppressing sibling recognizers. The image is clipped inside a GeometryReader-backed container so zoomed content doesn't bleed out of bounds.
import SwiftUI
struct ZoomableImageView: View {
let imageName: String
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
private let minScale: CGFloat = 1.0
private let maxScale: CGFloat = 5.0
var body: some View {
GeometryReader { geometry in
Image(imageName)
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(offset)
.frame(
width: geometry.size.width,
height: geometry.size.height
)
// Pinch-to-zoom
.gesture(
MagnificationGesture()
.onChanged { value in
scale = clampedScale(lastScale * value)
}
.onEnded { _ in
lastScale = scale
snapBackIfNeeded()
}
)
// Pan while zoomed
.simultaneousGesture(
DragGesture()
.onChanged { value in
guard scale > 1.0 else { return }
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
.onEnded { _ in
lastOffset = offset
}
)
// Double-tap to reset
.simultaneousGesture(
TapGesture(count: 2)
.onEnded {
withAnimation(.spring(duration: 0.3)) {
scale = 1.0
lastScale = 1.0
offset = .zero
lastOffset = .zero
}
}
)
.clipped()
.accessibilityLabel("Zoomable image: \(imageName)")
.accessibilityHint("Pinch to zoom, drag to pan, double-tap to reset")
}
}
private func clampedScale(_ value: CGFloat) -> CGFloat {
min(maxScale, max(minScale, value))
}
private func snapBackIfNeeded() {
if scale < minScale {
withAnimation(.spring(duration: 0.25)) {
scale = minScale
lastScale = minScale
offset = .zero
lastOffset = .zero
}
}
}
}
#Preview {
ZoomableImageView(imageName: "examplePhoto")
.frame(height: 400)
.padding()
}
How it works
-
Dual scale state (
scale+lastScale)MagnificationGesturereports a relative multiplier each frame, not an absolute scale. By storinglastScaleononEnded, multiplying it by the livevalueinonChangedgives a stable, cumulative zoom level across multiple pinches. -
Clamping via
clampedScale(_:)The helper enforcesminScale = 1.0andmaxScale = 5.0, preventing the image from shrinking below its natural size or zooming in so far that it becomes unusable.snapBackIfNeeded()runs after the gesture ends to spring-reset if somehow the user lands below 1×. -
Pan with
simultaneousGestureUsing.simultaneousGestureinstead of.gesturelets the drag and magnification recognizers cooperate rather than compete. The drag handler guards onscale > 1.0so the image stays locked in place when not zoomed. -
Double-tap reset A second
simultaneousGesture(TapGesture(count: 2))simultaneously listens for two-finger taps. On trigger it animatesscale,lastScale,offset, andlastOffsetall back to their default values in a singlewithAnimationblock. -
Accessibility labels
.accessibilityLabeland.accessibilityHintdescribe the image and the gestures for VoiceOver users, meeting WCAG 2.5.3 and Apple's HIG guidance for interactive media.
Variants
AsyncImage (remote URL) with zoom
Drop-in replacement that fetches from a URL and shows a progress spinner while loading before enabling the zoom gesture.
struct ZoomableAsyncImage: View {
let url: URL
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { scale = min(5, max(1, lastScale * $0)) }
.onEnded { _ in lastScale = scale }
)
case .failure:
Image(systemName: "photo.badge.exclamationmark")
.font(.largeTitle)
.foregroundStyle(.secondary)
default:
ProgressView()
}
}
.accessibilityLabel("Remote zoomable image")
}
}
Zoom to double-tap location (optical zoom feel)
For a more native Photos-app feel, capture the tap location from DragGesture(minimumDistance: 0) before the double-tap fires, then compute an offset that centers the tapped point on screen. Store the tap location with a @State var tapLocation: CGPoint = .zero and update it in a simultaneousGesture(DragGesture(minimumDistance: 0).onEnded { tapLocation = $0.location }). On double-tap, animate to scale 2.5 and set offset to (containerCenter - tapLocation) * (targetScale - 1). This gives the zoomed-to-point illusion without UIKit's UIScrollView.
Common pitfalls
-
iOS 16 and older:
MagnificationGestureexists back to iOS 13, but the#Previewmacro requires Xcode 15 / iOS 17 SDK. If you must support iOS 16, swap to thePreviewProviderprotocol only in the preview target — the gesture code itself is backward-compatible. -
Gesture conflicts with ScrollView: Placing
ZoomableImageViewinside aScrollViewcauses the scroll and magnification recognizers to fight. Wrap in.scrollDisabled(scale > 1.0)on the parentScrollViewto hand off gesture priority when zoomed in. -
Offset drift after scale change: Resetting only
scalebut notlastOffsetleaves the image visually centered but with a stale offset state. Always reset bothoffsetandlastOffsettogether — as shown in the double-tap handler — to avoid a sudden jump on the next drag gesture.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a zoomable image viewer in SwiftUI for iOS 17+. Use MagnificationGesture for pinch-to-zoom, DragGesture for pan-while-zoomed, and a double-tap TapGesture to reset. Clamp scale between 1.0 and 5.0 with spring animations. Make it accessible (VoiceOver labels and hints). Add a #Preview with realistic sample data.
This prompt fits neatly into the Build phase of the Soarias workflow — paste it directly into a Soarias session after generating your screen mockup to get production-ready zoom gesture code scaffolded in seconds.
Related
FAQ
Does this work on iOS 16?
Yes — MagnificationGesture, scaleEffect, and simultaneousGesture all back-deploy to iOS 13. The only iOS 17-specific piece in this article is the #Preview macro in the preview block. Replace it with a struct ZoomableImageView_Previews: PreviewProvider conformance and the runtime code compiles and runs on iOS 16 without any changes.
How do I constrain panning so the image can't be dragged off-screen?
Inside the DragGesture handler, clamp the offset using the container's geometry and the current scale. The maximum allowable offset in each axis is (imageSize * scale - containerSize) / 2. Capture the container dimensions from GeometryReader and store them in a @State var containerSize: CGSize = .zero set via .onAppear. Then apply min(max(newOffset, -maxOffset), maxOffset) to both axes before assigning to offset.
What is the UIKit equivalent?
In UIKit the canonical approach is a UIScrollView with minimumZoomScale / maximumZoomScale set and a UIImageView returned from viewForZooming(in:). SwiftUI's MagnificationGesture approach gives you finer control over animation curves and composability but requires you to manually handle offset clamping that UIScrollView provides for free. If you need pixel-perfect scroll physics (rubber-band bouncing at edges), wrap a UIScrollView with a UIViewRepresentable instead.
Last reviewed: 2026-05-12 by the Soarias team.