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

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
-
Why use a
**ViewModifier**?
AViewModifierlets you encapsulate complex view transformations or behavior in a reusable component. You can apply it via.modifier(...)or via a custom view extension. -
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 } } -
Explanation:
minZoomScaleandmaxZoomScalelet you configure the zoom limits.- The
body(content:)method returns the modified view.
Step 2. Add State for Transform and Content Size
-
What state do we need?
transform(CGAffineTransform) for scale and translation.lastTransformto accumulate changes across gestures.contentSizeto know the view’s real size viaGeometryReader.
-
Add properties:
@State private var lastTransform: CGAffineTransform = .identity @State private var transform: CGAffineTransform = .identity @State private var contentSize: CGSize = .zero -
Why?
@Statelets SwiftUI track and animate changes.- Initial values
.identityand.zerostart from a neutral state.
Step 3. Capture the Actual Content Size
-
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 } } } -
Explanation:
GeometryReaderexpands to match the content.proxy.sizegives the content’s width and height.- Store it in a
@Statevariable for zoom calculations.
Step 4. Apply Affine Transform to the View
-
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) } } -
Why?
scaleEffectandoffsetapply the transform visually.- Anchoring at
.zero(top-left) and then adjusting offset manually.
-
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
valuegrows 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.startAnchortells where the pinch began.- Convert
UnitPointtoCGPointby scaling withcontentSize. - Use
concatenatingto apply new transform onto the previous one.
Step 6. Add Drag and Double-Tap Gestures
-
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() } } -
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
-
Why?
Without constraints, users can zoom too little, too much, or pan content off-screen. -
**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 } -
Additional details:
ClosedRange<CGFloat>.clamp(_:)neatly clamps a value.- In
onEndGesture(), we applylimitTransformand animate snapping back into bounds.
Step 8. Create a Convenient View Extension
-
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 ) } } } } -
Explanation:
- The first method applies the modifier.
- The second wraps the content with a background
outOfBoundsColorto 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
- Encapsulation:
ViewModifierand extensions make your code reusable. - Flexibility: Configurable
minZoomScaleandmaxZoomScalesuit 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!