Igor Skvortsov/Guide - Zoomable SwiftUI View Modifier

Created Tue, 03 Jun 2025 00:00:00 +0000 Modified Tue, 08 Jul 2025 22:46:25 +0000

The guide on how to create modifier that adds zoom functionality to any SwiftUI View.

Problem Statement

In modern iOS apps, it’s often necessary to let users zoom in on content—whether it’s an image, map, or any SwiftUI View. In this guide, we will build a reusable ViewModifier step by step to add support for:

  • Pinch-to-zoom
  • Dragging
  • Double-tap zoom

Result illustration

By the end, our finished code will look like this:

struct ZoomableModifier: ViewModifier {
    // ... full code as shown earlier ...
}

public extension View {
    func zoomable(...) -> some View { ... }
}

We’ll break down each part of the implementation, explain why it’s needed, and cover relevant SwiftUI and iOS platform details.


Step 1. Create the Base ViewModifier

  1. Why use a **ViewModifier**?
    ViewModifier lets you encapsulate complex view transformations or behavior in a reusable component. You can apply it via .modifier(...) or via a custom view extension.

  2. Define the structure:

    import SwiftUI
    
    struct ZoomableModifier: ViewModifier {
        let minZoomScale: CGFloat
        let maxZoomScale: CGFloat
    
        func body(content: Content) -> some View {
            // For now, just display the content
            content
        }
    }
    
  3. Explanation:

    • minZoomScale and maxZoomScale let you configure the zoom limits.
    • The body(content:) method returns the modified view.

Step 2. Add State for Transform and Content Size

  1. What state do we need?

    • transform (CGAffineTransform) for scale and translation.
    • lastTransform to accumulate changes across gestures.
    • contentSize to know the view’s real size via GeometryReader.
  2. Add properties:

    @State private var lastTransform: CGAffineTransform = .identity
    @State private var transform: CGAffineTransform   = .identity
    @State private var contentSize: CGSize            = .zero
    
  3. Why?

    • @State lets SwiftUI track and animate changes.
    • Initial values .identity and .zero start from a neutral state.

Step 3. Capture the Actual Content Size

  1. Using **GeometryReader**
    Wrap the content in a transparent background to measure its size:

    content
        .background(alignment: .topLeading) {
            GeometryReader { proxy in
                Color.clear
                    .onAppear {
                        contentSize = proxy.size
                    }
            }
        }
    
  2. Explanation:

    • GeometryReader expands to match the content.
    • proxy.size gives the content’s width and height.
    • Store it in a @State variable for zoom calculations.

Step 4. Apply Affine Transform to the View

  1. Create a helper extension:

    private extension View {
        @ViewBuilder
        func animatableTransformEffect(_ transform: CGAffineTransform) -> some View {
            scaleEffect(
                x: transform.scaleX,
                y: transform.scaleY,
                anchor: .zero
            )
            .offset(x: transform.tx, y: transform.ty)
        }
    }
    
  2. Why?

    • scaleEffect and offset apply the transform visually.
    • Anchoring at .zero (top-left) and then adjusting offset manually.
  3. Use in **body**:

    content
        .background { /* GeometryReader */ }
        .animatableTransformEffect(transform)
    

Step 5. Implement Pinch Gestures for Zooming

iOS 16’s MagnificationGesture behaves differently than iOS 17+. We’ll support both.

5.1 Old API (iOS 16)

@available(iOS, introduced: 16.0, deprecated: 17.0)
private var oldMagnificationGesture: some Gesture {
    MagnificationGesture()
        .onChanged { value in
            let zoomFactor: CGFloat = 0.5
            let scale = value * zoomFactor
            transform = lastTransform.scaledBy(x: scale, y: scale)
        }
        .onEnded { _ in onEndGesture() }
}
  • Here value grows as fingers spread, and we apply a linear scaling.

5.2 New API (iOS 17+)

@available(iOS 17.0, *)
private var magnificationGesture: some Gesture {
    MagnifyGesture(minimumScaleDelta: 0)
        .onChanged { value in
            let newTransform = CGAffineTransform.anchoredScale(
                scale: value.magnification,
                anchor: value.startAnchor.scaledBy(contentSize)
            )
            transform = lastTransform.concatenating(newTransform)
        }
        .onEnded { _ in
            withAnimation(.interactiveSpring()) { onEndGesture() }
        }
}

Key points:

  • value.startAnchor tells where the pinch began.
  • Convert UnitPoint to CGPoint by scaling with contentSize.
  • Use concatenating to apply new transform onto the previous one.

Step 6. Add Drag and Double-Tap Gestures

  1. DragGesture for panning the zoomed content:

    private var dragGesture: some Gesture {
        DragGesture()
            .onChanged { value in
                withAnimation(.interactiveSpring) {
                    transform = lastTransform.translatedBy(
                        x: value.translation.width / transform.scaleX,
                        y: value.translation.height / transform.scaleY
                    )
                }
            }
            .onEnded { _ in onEndGesture() }
    }
    
  2. Double-Tap to toggle between minimum and maximum zoom:

    private var doubleTapGesture: some Gesture {
        SpatialTapGesture(count: 2)
            .onEnded { value in
                let newTransform: CGAffineTransform =
                    transform.isIdentity
                        ? .anchoredScale(scale: maxZoomScale, anchor: value.location)
                        : .identity
    
                withAnimation(.linear(duration: 0.15)) {
                    transform = newTransform
                    lastTransform = newTransform
                }
            }
    }
    

Step 7. Limit Scale and Translation

  1. Why?
    Without constraints, users can zoom too little, too much, or pan content off-screen.

  2. **limitTransform(_:)** method:

    private func limitTransform(_ transform: CGAffineTransform) -> CGAffineTransform {
        let sx = transform.scaleX
        let sy = transform.scaleY
    
        // Constrain scale
        if sx < minZoomScale || sy < minZoomScale { return .identity }
        if sx > maxZoomScale || sy > maxZoomScale {
            let center = CGPoint(x: contentSize.width / 2, y: contentSize.height / 2)
            return .anchoredScale(scale: maxZoomScale, anchor: center)
        }
    
        // Compute max offsets
        let maxX = contentSize.width * (sx - 1)
        let maxY = contentSize.height * (sy - 1)
    
        // Clamp translation
        let clampedTx = (-maxX ... 0).clamp(transform.tx)
        let clampedTy = (-maxY ... 0).clamp(transform.ty)
    
        var t = transform
        t.tx = clampedTx; t.ty = clampedTy
        return t
    }
    
  3. Additional details:

    • ClosedRange<CGFloat>.clamp(_:) neatly clamps a value.
    • In onEndGesture(), we apply limitTransform and animate snapping back into bounds.

Step 8. Create a Convenient View Extension

  1. Public modifiers:

    public extension View {
        func zoomable(
            minZoomScale: CGFloat = 1,
            maxZoomScale: CGFloat = 3
        ) -> some View {
            modifier(ZoomableModifier(
                minZoomScale: minZoomScale,
                maxZoomScale: maxZoomScale
            ))
        }
    
        func zoomable(
            minZoomScale: CGFloat = 1,
            maxZoomScale: CGFloat = 3,
            outOfBoundsColor: Color = .clear
        ) -> some View {
            GeometryReader { _ in
                ZStack {
                    outOfBoundsColor
                    self.zoomable(
                        minZoomScale: minZoomScale,
                        maxZoomScale: maxZoomScale
                    )
                }
            }
        }
    }
    
  2. Explanation:

    • The first method applies the modifier.
    • The second wraps the content with a background outOfBoundsColor to fill empty areas during panning.

Complete Code

The full final implementation can be found in my github by the link in the Related Links section. Copy it into your project and apply .zoomable() to any SwiftUI view or add as SPM package.


Conclusions

  • EncapsulationViewModifier and extensions make your code reusable.
  • Flexibility: Configurable minZoomScale and maxZoomScale suit any use case.
  • UX: Smooth animations and bounds checking ensure a great user experience.

You can now effortlessly add zooming to images, maps, or any SwiftUI content. Happy coding!