How to build a zoomable image in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: MagnificationGesture Updated: May 12, 2026
TL;DR

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

  1. Dual scale state (scale + lastScale) MagnificationGesture reports a relative multiplier each frame, not an absolute scale. By storing lastScale on onEnded, multiplying it by the live value in onChanged gives a stable, cumulative zoom level across multiple pinches.
  2. Clamping via clampedScale(_:) The helper enforces minScale = 1.0 and maxScale = 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×.
  3. Pan with simultaneousGesture Using .simultaneousGesture instead of .gesture lets the drag and magnification recognizers cooperate rather than compete. The drag handler guards on scale > 1.0 so the image stays locked in place when not zoomed.
  4. Double-tap reset A second simultaneousGesture(TapGesture(count: 2)) simultaneously listens for two-finger taps. On trigger it animates scale, lastScale, offset, and lastOffset all back to their default values in a single withAnimation block.
  5. Accessibility labels .accessibilityLabel and .accessibilityHint describe 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

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.