'Isn't there an easy way to pinch to zoom in an image in Swiftui?

I want to be able to resize and move an image in SwiftUI (like if it were a map) with pinch to zoom and drag it around.

With UIKit I embedded the image into a UIScrollView and it took care of it, but I don't know how to do it in SwiftUI. I tried using MagnificationGesture but I cannot get it to work smoothly.

I've been searching about this for a while, does anyone know if there's an easier way?



Solution 1:[1]

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
    }
  }
}

Solution 2:[2]

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 UIKit
import SwiftUI

class 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())
    }
}

Solution 3:[3]

Looks like there isn't native support in SwiftUI's ScrollView, however, there's still a pretty simple way to do it.

Create a MagnificationGesture like you were going for, but be sure to multiply your current scale by the value you get in the gesture's .onChanged closure. This closure is giving you the change in zoom rather than the current scale value.

When you're zoomed out and begin to zoom in it won't increase from the current scale (0.5 to 0.6 as an arbitrary example), it will increase from 1 to 1.1. That's why you were seeing weird behavior.

This answer will work if the MagnificationGesture is on the same view that has the .scaleEffect. Otherwise, James' answer will work better.

struct ContentView: View {
    @State var scale: CGFloat
    var body: some View {
        let gesture = MagnificationGesture(minimumScaleDelta: 0.1)
            .onChanged { scaleDelta in
                self.scale *= scaleDelta
        }
        return ScrollView {
            // Your ScrollView content here :)
        }
            .gesture(gesture)
            .scaleEffect(scale)
    }
}

P.S. You may find that using a ScrollView for this purpose is clunky and you aren't able to drag and zoom simultaneously. If this is the case & you aren't happy with it I would look into adding multiple gestures and adjusting your content's offset manually rather than using a ScrollView.

Solution 4:[4]

I am also struggle with this issue. But some working sample is made with the this video-(https://www.youtube.com/watch?v=p0SwXJYJp2U)

This is not completed. It's difficult to scale with anchor point. Hope this is hint to someone else.

struct ContentView: View {

    let maxScale: CGFloat = 3.0
    let minScale: CGFloat = 1.0

    @State var lastValue: CGFloat = 1.0
    @State var scale: CGFloat = 1.0
    @State var draged: CGSize = .zero
    @State var prevDraged: CGSize = .zero
    @State var tapPoint: CGPoint = .zero
    @State var isTapped: Bool = false

    var body: some View {
        let magnify = MagnificationGesture(minimumScaleDelta: 0.2)
            .onChanged { value in
                let resolvedDelta = value / self.lastValue
                self.lastValue = value
                let newScale = self.scale * resolvedDelta
                self.scale = min(self.maxScale, max(self.minScale, newScale))

                print("delta=\(value) resolvedDelta=\(resolvedDelta)  newScale=\(newScale)")
        }

        let gestureDrag = DragGesture(minimumDistance: 0, coordinateSpace: .local)
            .onChanged { (value) in
                self.tapPoint = value.startLocation
                self.draged = CGSize(width: value.translation.width + self.prevDraged.width,
                                     height: value.translation.height + self.prevDraged.height)
        }

        return GeometryReader { geo in
                Image("dooli")
                    .resizable().scaledToFit().animation(.default)
                    .offset(self.draged)
                    .scaleEffect(self.scale)
//                    .scaleEffect(self.isTapped ? 2 : 1,
//                                 anchor: UnitPoint(x: self.tapPoint.x / geo.frame(in: .local).maxX,
//                                                   y: self.tapPoint.y / geo.frame(in: .local).maxY))
                    .gesture(
                        TapGesture(count: 2).onEnded({
                            self.isTapped.toggle()
                            if self.scale > 1 {
                                self.scale = 1
                            } else {
                                self.scale = 2
                            }
                            let parent = geo.frame(in: .local)
                            self.postArranging(translation: CGSize.zero, in: parent)
                        })
                        .simultaneously(with: gestureDrag.onEnded({ (value) in
                            let parent = geo.frame(in: .local)
                            self.postArranging(translation: value.translation, in: parent)
                        })
                    ))
                    .gesture(magnify.onEnded { value in
                        // without this the next gesture will be broken
                        self.lastValue = 1.0
                        let parent = geo.frame(in: .local)
                        self.postArranging(translation: CGSize.zero, in: parent)
                    })
            }
            .frame(height: 300)
            .clipped()
            .background(Color.gray)

    }

    private func postArranging(translation: CGSize, in parent: CGRect) {
        let scaled = self.scale
        let parentWidth = parent.maxX
        let parentHeight = parent.maxY
        let offset = CGSize(width: (parentWidth * scaled - parentWidth) / 2,
                            height: (parentHeight * scaled - parentHeight) / 2)

        print(offset)
        var resolved = CGSize()
        let newDraged = CGSize(width: self.draged.width * scaled,
                               height: self.draged.height * scaled)
        if newDraged.width > offset.width {
            resolved.width = offset.width / scaled
        } else if newDraged.width < -offset.width {
            resolved.width = -offset.width / scaled
        } else {
            resolved.width = translation.width + self.prevDraged.width
        }
        if newDraged.height > offset.height {
            resolved.height = offset.height / scaled
        } else if newDraged.height < -offset.height {
            resolved.height = -offset.height / scaled
        } else {
            resolved.height = translation.height + self.prevDraged.height
        }
        self.draged = resolved
        self.prevDraged = resolved
    }

}

Solution 5:[5]

A extremely simple approach that I think deserves mention - use Apple's PDFKit.

import SwiftUI
import PDFKit

struct PhotoDetailView: UIViewRepresentable {
    let image: UIImage

    func makeUIView(context: Context) -> PDFView {
        let view = PDFView()
        view.document = PDFDocument()
        guard let page = PDFPage(image: image) else { return view }
        view.document?.insert(page, at: 0)
        view.autoScales = true
        return view
    }

    func updateUIView(_ uiView: PDFView, context: Context) {
        // empty
    }
}

Pros:

  • 0 logic required
  • Feels professional
  • Written by Apple (unlikely to break in the future)

If you're just presenting the image for viewing, this method might be perfect for you. But if you want to add image annotation, etc, I'd follow one of the other answers.

Edited to add view.autoScales = true at maka's suggestion.

Solution 6:[6]

Other answers are fine, here is an additional tip: if you are using a SwiftUI gesture you can use a @GestureState instead of a @State for storing gesture state. It will automatically reset the state to its initial value after the gesture ended, thus you can simplify this kind of code:

@State private var scale: CGFloat = 1.0

.gesture(MagnificationGesture().onChanged { value in
  // Anything with value
  scale = value
}.onEnded { value in
  scale = 1.0
})

with:

@GestureState private var scale: CGFloat = 1.0

.gesture(MagnificationGesture().updating($scale) { (newValue, scale, _) in
  // Anything with value
  scale = newValue
})

Solution 7:[7]

This is another solution, based on jtbandes' answer. It still wraps a UIScrollView in a UIViewRepresentable but with a few changes:

  • it is particularized for a UIImage, rather than generic SwiftUI content: it works for this case and it doesn't require to wrap the underlying UIImage into a SwiftUI Image
  • it lays out the image view based on Auto Layout constraints, instead of auto resizing masks
  • it centers the image in the middle of the view, by calculating the proper value for the top and leading constraints depending on the current zoom level

Use:

struct EncompassingView: View {
    let uiImage: UIImage

    var body: some View {
        GeometryReader { geometry in
            ZoomableView(uiImage: uiImage, viewSize: geometry.size)
        }
    }
}

Definition:

struct ZoomableView: UIViewRepresentable {
    let uiImage: UIImage
    let viewSize: CGSize

    private enum Constraint: String {
        case top
        case leading
    }
    
    private var minimumZoomScale: CGFloat {
        let widthScale = viewSize.width / uiImage.size.width
        let heightScale = viewSize.height / uiImage.size.height
        return min(widthScale, heightScale)
    }
    
    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()
        
        scrollView.delegate = context.coordinator
        scrollView.maximumZoomScale = minimumZoomScale * 50
        scrollView.minimumZoomScale = minimumZoomScale
        scrollView.bouncesZoom = true
        
        let imageView = UIImageView(image: uiImage)
        scrollView.addSubview(imageView)
        imageView.translatesAutoresizingMaskIntoConstraints = false
        
        let topConstraint = imageView.topAnchor.constraint(equalTo: scrollView.topAnchor)
        topConstraint.identifier = Constraint.top.rawValue
        topConstraint.isActive = true
        
        let leadingConstraint = imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
        leadingConstraint.identifier = Constraint.leading.rawValue
        leadingConstraint.isActive = true
        
        imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
        imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true

        return scrollView
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator()
    }
    
    func updateUIView(_ scrollView: UIScrollView, context: Context) {
        guard let imageView = scrollView.subviews.first as? UIImageView else {
            return
        }
        
        // Inject dependencies into coordinator
        context.coordinator.zoomableView = imageView
        context.coordinator.imageSize = uiImage.size
        context.coordinator.viewSize = viewSize
        let topConstraint = scrollView.constraints.first { $0.identifier == Constraint.top.rawValue }
        let leadingConstraint = scrollView.constraints.first { $0.identifier == Constraint.leading.rawValue }
        context.coordinator.topConstraint = topConstraint
        context.coordinator.leadingConstraint = leadingConstraint

        // Set initial zoom scale
        scrollView.zoomScale = minimumZoomScale
    }
}

// MARK: - Coordinator

extension ZoomableView {
    class Coordinator: NSObject, UIScrollViewDelegate {
        var zoomableView: UIView?
        var imageSize: CGSize?
        var viewSize: CGSize?
        var topConstraint: NSLayoutConstraint?
        var leadingConstraint: NSLayoutConstraint?

        func viewForZooming(in scrollView: UIScrollView) -> UIView? {
            zoomableView
        }
        
        func scrollViewDidZoom(_ scrollView: UIScrollView) {
            let zoomScale = scrollView.zoomScale
            print("zoomScale = \(zoomScale)")
            guard
                let topConstraint = topConstraint,
                let leadingConstraint = leadingConstraint,
                let imageSize = imageSize,
                let viewSize = viewSize
            else {
                return
            }
            topConstraint.constant = max((viewSize.height - (imageSize.height * zoomScale)) / 2.0, 0.0)
            leadingConstraint.constant = max((viewSize.width - (imageSize.width * zoomScale)) / 2.0, 0.0)
        }
    }
}

Solution 8:[8]

Here's an alternative approach to @James and @ethoooo 's. The final zoom state and the transient gesture state are kept separate (the transient will always return 1), so it's a state you can set from a button or stepper for example in addition to the gesture itself.

  @State var scrollContentZoom: CGFloat = 1
  @GestureState var scrollContentGestureZoom: CGFloat = 1
  var contentZoom: CGFloat { scrollContentZoom*scrollContentGestureZoom }
  
  var magnification: some Gesture {
    MagnificationGesture()
      .updating($scrollContentGestureZoom) { state, gestureState, transaction in
        print("Magnifed: \(state)")
        gestureState = state
      }
      .onEnded { (state) in
        scrollContentZoom = contentZoom*state
      }
  }

Solution 9:[9]

Here is a complete example of @James accepted response, which also features rudimentary support for scrolling around the newly zoomed image via adjusting a hidden rectangle that resizes the content of the scrollview in proportion with the image scale:

import SwiftUI

struct EnlargedImage: View {
    var image = UIImage(named: "YourImageName")
    @State var scale: CGFloat = 1.0
    @State var lastScaleValue: CGFloat = 1.0

    var body: some View {
   
        ScrollView([.vertical, .horizontal], showsIndicators: false){
            ZStack{
                
                Rectangle().foregroundColor(.clear).frame(width: image!.size.width * scale, height: image!.size.height * scale, alignment: .center)
                
                Image(uiImage: image!).scaleEffect(scale)
                .gesture(MagnificationGesture().onChanged { val in
                    let delta = val / self.lastScaleValue
                    self.lastScaleValue = val
                    var newScale = self.scale * delta
                    if newScale < 1.0
                    {
                        newScale = 1.0
                    }
                    scale = newScale
                }.onEnded{val in
                    lastScaleValue = 1
                })
                
               
            }
        }.background(Color(.systemBackground).edgesIgnoringSafeArea(.all))
    
    }
}

I have a better version of this in my GitHub.

Solution 10:[10]

struct DetailView: View {
    var item: MenuItem
    @State private var zoomed:Bool = false
    @State var scale: CGFloat = 1.0
    @State var isTapped: Bool = false
    @State var pointTaped: CGPoint = CGPoint.zero
    @State var draggedSize: CGSize = CGSize.zero
    @State var previousDraged: CGSize = CGSize.zero

    var width = UIScreen.main.bounds.size.width
    var height = UIScreen.main.bounds.size.height

    var body: some View {
        GeometryReader {  reader in
            VStack(alignment: .center) {
                ScrollView(){
                    HStack {
                        ScrollView(.vertical){
                            Image(self.item.mainImage)
                                .resizable()
                                .scaledToFill()
                                .animation(.default).offset(x: self.draggedSize.width, y: 0)
                                .scaleEffect(self.scale).scaleEffect(self.isTapped ? 2 : 1, anchor: UnitPoint(x : (self.pointTaped.x) / (reader.frame(in : .global).maxX),y: (self.pointTaped.y) / (reader.frame(in : .global).maxY )))
                                .gesture(TapGesture(count: 2)
                                    .onEnded({ value in
                                        self.isTapped = !self.isTapped
                                    })
                                    .simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: .global)  .onChanged { (value) in
                                        self.pointTaped = value.startLocation
                                        self.draggedSize = CGSize(width: value.translation.width + self.previousDraged.width, height: value.translation.height + self.previousDraged.height)
                                    }
                                    .onEnded({ (value) in
                                        let offSetWidth = (reader.frame(in :.global).maxX * self.scale) - (reader.frame(in :.global).maxX) / 2
                                        let newDraggedWidth = self.previousDraged.width * self.scale
                                        if (newDraggedWidth > offSetWidth){
                                            self.draggedSize = CGSize(width: offSetWidth / self.scale, height: value.translation.height + self.previousDraged.height)
                                        }
                                        else if (newDraggedWidth < -offSetWidth){
                                            self.draggedSize = CGSize(width:  -offSetWidth / self.scale, height: value.translation.height + self.previousDraged.height)
                                        }
                                        else{
                                            self.draggedSize = CGSize(width: value.translation.width + self.previousDraged.width, height: value.translation.height + self.previousDraged.height)
                                        }
                                        self.previousDraged =  self.draggedSize
                                    })))

                                .gesture(MagnificationGesture()
                                    .onChanged { (value) in
                                        self.scale = value.magnitude

                                }.onEnded { (val) in
                                    //self.scale = 1.0
                                    self.scale = val.magnitude
                                    }
                            )
                        }
                    }

                        HStack {
                            Text(self.item.description)
                                .foregroundColor(Color.black)
                                .multilineTextAlignment(.leading)
                                .padding(4)
                        }
                }
            }.navigationBarTitle("Menu Detail")
        }
    }
}

Solution 11:[11]

Implementation of Zoom and Drag of an image in SwiftUI

struct PhotoViewer: View {
    @State private var uiimage = UIImage(named: "leaf.png")

    @GestureState private var scaleState: CGFloat = 1
    @GestureState private var offsetState = CGSize.zero

    @State private var offset = CGSize.zero
    @State private var scale: CGFloat = 1

    var magnification: some Gesture {
        MagnificationGesture()
            .updating($scaleState) { currentState, gestureState, _ in
                gestureState = currentState
            }
            .onEnded { value in
                scale *= value
            }
    }

    var dragGesture: some Gesture {
        DragGesture()
            .updating($offsetState) { currentState, gestureState, _ in
                gestureState = currentState.translation
            }.onEnded { value in
                offset.height += value.translation.height
                offset.width += value.translation.width
            }
    }

    var body: some View {
        Image(uiImage: uiimage!)
            .resizable()
            .scaledToFit()
            .scaleEffect(self.scale * scaleState)
            .offset(x: offset.width + offsetState.width, y: offset.height + offsetState.height)
            .gesture(SimultaneousGesture(magnification, dragGesture))
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Avario
Solution 3
Solution 4 Brownsoo Han
Solution 5
Solution 6 Louis Lac
Solution 7
Solution 8 Cenk Bilgen
Solution 9
Solution 10 Shilpa
Solution 11 Learner