Isn't there an easy way to pinch to zoom in an image in Swiftui?
The other answers here are overly complicated with custom zooming logic. If you want the standard, battle-tested UIScrollView zooming behavior you can just use a UIScrollView!
SwiftUI allows you to put any UIView inside an otherwise SwiftUI view hierarchy using UIViewRepresentable
or UIViewControllerRepresentable
. Then to put more SwiftUI content inside that view, you can use UIHostingController
. Read more about SwiftUI–UIKit interop in Interfacing with UIKit and the API docs.
You can find a more complete example where I'm using this in a real app at: https://github.com/jtbandes/SpacePOD/blob/main/SpacePOD/ZoomableScrollView.swift (That example also includes more tricks for centering the image.)
var body: some View { ZoomableScrollView { Image("Your image here") }}
struct ZoomableScrollView<Content: View>: UIViewRepresentable { private var content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } func makeUIView(context: Context) -> UIScrollView { // set up the UIScrollView let scrollView = UIScrollView() scrollView.delegate = context.coordinator // for viewForZooming(in:) scrollView.maximumZoomScale = 20 scrollView.minimumZoomScale = 1 scrollView.bouncesZoom = true // create a UIHostingController to hold our SwiftUI content let hostedView = context.coordinator.hostingController.view! hostedView.translatesAutoresizingMaskIntoConstraints = true hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] hostedView.frame = scrollView.bounds scrollView.addSubview(hostedView) return scrollView } func makeCoordinator() -> Coordinator { return Coordinator(hostingController: UIHostingController(rootView: self.content)) } func updateUIView(_ uiView: UIScrollView, context: Context) { // update the hosting controller's SwiftUI content context.coordinator.hostingController.rootView = self.content assert(context.coordinator.hostingController.view.superview == uiView) } // MARK: - Coordinator class Coordinator: NSObject, UIScrollViewDelegate { var hostingController: UIHostingController<Content> init(hostingController: UIHostingController<Content>) { self.hostingController = hostingController } func viewForZooming(in scrollView: UIScrollView) -> UIView? { return hostingController.view } }}
The SwiftUI API is pretty unhelpful here: the onChanged gives number relative to start of current zoom gesture and no obvious way within a callback to get the initial value. And there is an onEnded callback but easy to miss/forget.
A work around, add:
@State var lastScaleValue: CGFloat = 1.0
Then in the callback:
.gesture(MagnificationGesture().onChanged { val in let delta = val / self.lastScaleValue self.lastScaleValue = val let newScale = self.scale * delta//... anything else e.g. clamping the newScale}.onEnded { val in // without this the next gesture will be broken self.lastScaleValue = 1.0}
where newScale is your own tracking of scale (perhaps state or a binding). If you set your scale directly it will get messed up as on each tick the amount will be relative to previous amount.
Here's one way of adding pinch zooming to a SwiftUI view. It overlays a UIView
with a UIPinchGestureRecognizer
in a UIViewRepresentable
, and forwards the relevant values back to SwiftUI with bindings.
You can add the behaviour like this:
Image("Zoom") .pinchToZoom()
This adds behaviour similar to zooming photos in the Instagram feed. Here's the full code:
import UIKitimport SwiftUIclass PinchZoomView: UIView { weak var delegate: PinchZoomViewDelgate? private(set) var scale: CGFloat = 0 { didSet { delegate?.pinchZoomView(self, didChangeScale: scale) } } private(set) var anchor: UnitPoint = .center { didSet { delegate?.pinchZoomView(self, didChangeAnchor: anchor) } } private(set) var offset: CGSize = .zero { didSet { delegate?.pinchZoomView(self, didChangeOffset: offset) } } private(set) var isPinching: Bool = false { didSet { delegate?.pinchZoomView(self, didChangePinching: isPinching) } } private var startLocation: CGPoint = .zero private var location: CGPoint = .zero private var numberOfTouches: Int = 0 init() { super.init(frame: .zero) let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:))) pinchGesture.cancelsTouchesInView = false addGestureRecognizer(pinchGesture) } required init?(coder: NSCoder) { fatalError() } @objc private func pinch(gesture: UIPinchGestureRecognizer) { switch gesture.state { case .began: isPinching = true startLocation = gesture.location(in: self) anchor = UnitPoint(x: startLocation.x / bounds.width, y: startLocation.y / bounds.height) numberOfTouches = gesture.numberOfTouches case .changed: if gesture.numberOfTouches != numberOfTouches { // If the number of fingers being used changes, the start location needs to be adjusted to avoid jumping. let newLocation = gesture.location(in: self) let jumpDifference = CGSize(width: newLocation.x - location.x, height: newLocation.y - location.y) startLocation = CGPoint(x: startLocation.x + jumpDifference.width, y: startLocation.y + jumpDifference.height) numberOfTouches = gesture.numberOfTouches } scale = gesture.scale location = gesture.location(in: self) offset = CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y) case .ended, .cancelled, .failed: isPinching = false scale = 1.0 anchor = .center offset = .zero default: break } }}protocol PinchZoomViewDelgate: AnyObject { func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize)}struct PinchZoom: UIViewRepresentable { @Binding var scale: CGFloat @Binding var anchor: UnitPoint @Binding var offset: CGSize @Binding var isPinching: Bool func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIView(context: Context) -> PinchZoomView { let pinchZoomView = PinchZoomView() pinchZoomView.delegate = context.coordinator return pinchZoomView } func updateUIView(_ pageControl: PinchZoomView, context: Context) { } class Coordinator: NSObject, PinchZoomViewDelgate { var pinchZoom: PinchZoom init(_ pinchZoom: PinchZoom) { self.pinchZoom = pinchZoom } func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) { pinchZoom.isPinching = isPinching } func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) { pinchZoom.scale = scale } func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) { pinchZoom.anchor = anchor } func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) { pinchZoom.offset = offset } }}struct PinchToZoom: ViewModifier { @State var scale: CGFloat = 1.0 @State var anchor: UnitPoint = .center @State var offset: CGSize = .zero @State var isPinching: Bool = false func body(content: Content) -> some View { content .scaleEffect(scale, anchor: anchor) .offset(offset) .animation(isPinching ? .none : .spring()) .overlay(PinchZoom(scale: $scale, anchor: $anchor, offset: $offset, isPinching: $isPinching)) }}extension View { func pinchToZoom() -> some View { self.modifier(PinchToZoom()) }}