Skip to content

Instantly share code, notes, and snippets.

@donpark
Last active April 30, 2025 22:39
Show Gist options
  • Save donpark/e0560c28ec5945711d319da7e4c93c54 to your computer and use it in GitHub Desktop.
Save donpark/e0560c28ec5945711d319da7e4c93c54 to your computer and use it in GitHub Desktop.
SwiftUI ZoomableImage

I needed a zoomable image view in SwiftUI that allows user to pan and zoom the image with useful constraints like:

  • snap back zoom in beyond 3x
  • snap back zoom out beyond entire image is visible
  • snap back pan beyond image bounds.

TODO:

  • Add rotation support
//
// ZoomableImageView.swift
//
// Created by Don Park on 4/30/25.
//
import SwiftUI
struct ZoomableContainer<Content: View>: View {
let content: Content
let contentSize: () -> CGSize
@State private var scale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var gestureScale: CGFloat = 1.0
@State private var gestureOffset: CGSize = .zero
private let minScale: CGFloat = 1.0
private var maxScale: CGFloat { 3.0 }
init(@ViewBuilder content: () -> Content, contentSize: @escaping () -> CGSize) {
self.content = content()
self.contentSize = contentSize
}
var body: some View {
GeometryReader { geo in
let viewSize = geo.size
let contentSize = contentSize()
let fitScale = min(viewSize.width / contentSize.width, viewSize.height / contentSize.height)
let minAllowedScale = fitScale
let maxAllowedScale = fitScale * maxScale
content
.frame(width: contentSize.width, height: contentSize.height)
.scaleEffect(scale * gestureScale, anchor: .center)
.offset(x: offset.width + gestureOffset.width, y: offset.height + gestureOffset.height)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
gestureScale = value
}
.onEnded { value in
let newScale = scale * value
let clampedScale = min(max(newScale, minAllowedScale), maxAllowedScale)
withAnimation(.spring()) {
scale = clampedScale
gestureScale = 1.0
}
},
DragGesture()
.onChanged { value in
gestureOffset = value.translation
}
.onEnded { value in
let totalScale = scale * gestureScale
let imageDisplaySize = CGSize(width: contentSize.width * totalScale, height: contentSize.height * totalScale)
let maxOffsetX = max(0, (imageDisplaySize.width - viewSize.width) / 2)
let maxOffsetY = max(0, (imageDisplaySize.height - viewSize.height) / 2)
var newOffset = CGSize(
width: offset.width + value.translation.width,
height: offset.height + value.translation.height
)
// Animate back if out of bounds
newOffset.width = min(max(newOffset.width, -maxOffsetX), maxOffsetX)
newOffset.height = min(max(newOffset.height, -maxOffsetY), maxOffsetY)
withAnimation(.spring()) {
offset = newOffset
gestureOffset = .zero
}
}
)
)
.onChange(of: scale) {
// Reset offset if scale changes and image shrinks
withAnimation(.spring()) {
offset = .zero
}
}
.frame(width: viewSize.width, height: viewSize.height)
.background(Color.black.opacity(0.8))
}
}
}
//
// ZoomableImage.swift
//
// Created by Don Park on 4/30/25.
//
import SwiftUI
struct ZoomableImage: View {
let uiImage: UIImage
var body: some View {
ZoomableContainer(
content: {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
},
contentSize: {
CGSize(
width: uiImage.size.width * uiImage.scale,
height: uiImage.size.height * uiImage.scale
)
})
}
}
import SwiftUI
struct ZoomableImageTest: View {
var body: some View {
if let uiImage = UIImage(named: "some-image-file") {
ZoomableImage(uiImage: uiImage)
.edgesIgnoringSafeArea(.all)
}
}
}
#Preview {
ZoomableImageTest()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment