74

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?

Ido
  • 473
  • 4
  • 16
Zheoni
  • 881
  • 1
  • 7
  • 11
  • 1
    Do the same thing, add the image into a Scroll view. a scroll view is still available with SwiftUI – Scriptable Oct 11 '19 at 13:05
  • I don't know if ScrollView in SwiftUI has native support for zooming. Or I just cannot find it. I got this, but it behaves weird. ScrollView(Axis.Set(arrayLiteral: [.horizontal, .vertical]), showsIndicators: false) { Image("building").resizable().scaledToFit().scaleEffect(self.scale) } .gesture(MagnificationGesture().onChanged {scale in self.scale = scale }) – Zheoni Oct 11 '19 at 13:55
  • 14
    For anyone still looking, the only working answer is from jtbandes - albeit I suggest you look at his working example in GitHub. I have looked at every article on the web for how to do this natively, and it's simply not possible. The state needed to properly calculate the various variables - centre, anchor point, boundaries, etc. - and the ability to merge both drag and zoom together, simply do not exist in SwiftUI as of version 2.0. I have implemented jtbandes solution for an image and it works really well, exactly like Apple Photos does. Pastebin link: https://pastebin.com/embed_js/rpSRTddm – RopeySim Oct 04 '20 at 08:47
  • Agreed with RPSM, SwiftUI still isn't flexible enough to implement this natively (in 2023). Especially with web images (where you really have to hack to get basic frame dimensions after an image loads). Wasted nearly a week trying. The UIScrollView approach was the only viable approach for a production ready product. – jusko Apr 21 '23 at 03:39

17 Answers17

86

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
    }
  }
}
General Grievance
  • 4,555
  • 31
  • 31
  • 45
jtbandes
  • 115,675
  • 35
  • 233
  • 266
  • 5
    This works and I know it, but I think you misunderstood the question. I was asking for an easy "native" way to do it with SwiftUI, without using UIKit. Thanks for the reply anyway! May be useful for many people. – Zheoni Sep 29 '20 at 13:36
  • 13
    I do hope that SwiftUI's ScrollView gets this feature soon, but in the meantime I don't see much reason to prefer a "native" (pure SwiftUI) solution over one that mixes in some UIKit. The escape hatch is there for a reason! I'll be using this in my project :) – jtbandes Sep 29 '20 at 17:30
  • 3
    Fantastic sample! I've been trying to get something working with native SwiftUI and even a UIKit view with gestures, and the scaleEffect is just broken. However, trying to use your code I get a very weird "jump" or "snap" when I put an image. I don't think the image has anything to do with it, but your code is very simple. Have you experienced anything like that? – RopeySim Oct 03 '20 at 18:01
  • 2
    Yes, I have actually made some significant changes based on that and other positioning problems. Found [this article](https://medium.com/@ssamadgh/designing-apps-with-scroll-views-part-i-8a7a44a5adf7) helpful. You can see my latest version at https://github.com/jtbandes/space-pics/blob/main/APOD/ZoomableScrollView.swift which includes a subclass of UIScrollView — it's not perfect, but now that I know about the snapping problems I'm actually seeing them in some other apps as well. I've been shocked to discover how hard this is to get right, and that UIScrollView doesn't easily do it by default. – jtbandes Oct 03 '20 at 21:00
  • 1
    There's actually an [old WWDC talk](https://asciiwwdc.com/2010/sessions/104) that covers centering, as well as some [sample code](https://developer.apple.com/library/archive/samplecode/PhotoScroller/Listings/Classes_ImageScrollView_m.html#//apple_ref/doc/uid/DTS40010080-Classes_ImageScrollView_m-DontLinkElementID_6) but both seem a bit outdated; the sample code has some problems on the latest iOS. – jtbandes Oct 03 '20 at 21:02
  • 3
    I can't thank you enough, this saved me completely! I took the code you posted above and made some modifications for my use, namely I added a UIImageView rather than the embedded SwiftUI View, which is all I needed. I had to cater for safe area, etc., but the result is FANTASTIC. I took a look at your Nasa photo project, and when I have a bit more time I'll look to integrate it into my other non-image projects. Again, THANK YOU. – RopeySim Oct 04 '20 at 08:33
  • If you are experiencing crashes on iOS15+ check this fix: https://stackoverflow.com/questions/69517128/zoomablescrollview-no-longer-works-in-swiftui – Deniss Fedotovs Jan 13 '22 at 12:48
  • Unfortunately, this does not work for iOS 16. Image not even appears. – Duck Sep 16 '22 at 06:26
  • 1
    I’ve been running [this app](https://github.com/jtbandes/SpacePOD) on iOS 16 for a while and it seems to be working fine. – jtbandes Sep 18 '22 at 23:35
  • Hi, were you able to solve the issue, when the zoomable content is an image and it jumps to the opposite side of the screen compared to the zooming finger when zooming starts? – Gergely Kovacs Feb 13 '23 at 16:56
  • 1
    This works well enough when the content that you want to zoom in on is not already scrollable, but when I supply a VStack with a series of images as the content (meaning regular ScrollView behavior when not zoomed), I can correctly scroll up and down, but as soon as I zoom in and then try to pan around, it won't pan beyond the "border" that was currently in the frame when the zoom started. Additionally, when I zoom back out, I am also locked to that "border" and I lose the rest of the content (can't scroll up and down in the regular ScrollView anymore). – Tim Fuqua Mar 01 '23 at 16:47
  • 1
    Still the best solution in 2023 on iOS 16. Works fine on a paged tabview of web images for me. Thanks. SwiftUI really sunk my time on this. – jusko Apr 21 '23 at 03:33
  • 1
    This almost works for me. The only problem is when I add an image zoom in and then try to pan, the image goes off the screen vertically. Do I need to somehow change the image size after zooming? – alionthego May 31 '23 at 22:49
  • Other than @alionthego is anybody else having the image going off screen vertically when panning while zoomed in? Solution is pretty much perfect otherwise but would like to remove this glitch. – Gerry Shaw Jun 20 '23 at 05:18
44

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.

iOSGeek
  • 5,115
  • 9
  • 46
  • 74
James
  • 1,985
  • 18
  • 19
38

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())
    }
}
Avario
  • 4,655
  • 3
  • 26
  • 19
  • 1
    Hi, I intent to do something more like Facebook pinch, witch stays zoomed in and you can scroll around. I tried to tweak your code first by in the `pinch` function separating the `.ended` from the `.canceled` and `.failed` and removed the `scale` from the `.ended` case. With this change I'm able to keep the scale but I can not scale down (using pinch) neither look around, can you point what I'm missing? I scanned your code a few times, and can't figure out what else is needed. In my mind at least the pinch out should work and then just add the drag gestures. – Pedro Cavaleiro May 04 '20 at 20:51
  • Hi @PedroCavaleiro. I don't think this code will work for this use case. I'd recommend you instead look into using `UIScrollView` and its zooming capabilities. – Avario May 05 '20 at 01:10
  • 2
    I really like this solution, but it blocks all SwiftUI gesture recognizers in the content view. If the content view is a representable, it also seems to block all of its UIGestureRecognizers. – hidden-username Sep 12 '20 at 22:48
  • 1
    You just saved my life!! Thank you :) – bhakti123 Feb 20 '21 at 02:27
  • Do you think you could add 2 fingers translation to your Class? That would allow zooming at a user-defined center. Very useful for a drawing app for instance! – Paul Ollivier Apr 28 '21 at 08:21
  • 3
    It's a nice solution if we need to scale single Image/view in a container. But if we've multiple images in VStack then interacted image is always displayed below the next image based on the VStack sequence. Does anyone notice it? – Sunil Targe Jul 19 '22 at 15:22
27

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.

JarWarren
  • 1,343
  • 2
  • 13
  • 18
  • 1
    You're right, this is perfect for me. Thanks! – toddler Sep 15 '21 at 00:11
  • Zoom seems OK, but it's not possible to drag, it will always back to center. – iaomw Sep 30 '21 at 07:43
  • 1
    Thanks for mentioning it. It solves my problem. Cheers! – Mahmud Ahsan Nov 16 '21 at 04:04
  • 1
    This solved my problem. Thanks – indra Nov 20 '21 at 19:42
  • 4
    This is seriously the best approach out there. Everything else feels clunky and is too much code... Thanks – Nico S. Feb 10 '22 at 20:53
  • 1
    Wanted to add that it solved it for me but this line helped, as my image started with a huge zoom (it's a hi res image): `view.autoScales = true` – maka Sep 28 '21 at 09:01
  • Actually, this is slow and doesn't help to close by gestures – kelalaka Sep 30 '22 at 18:44
  • I used this method at first and it does mostly work! One thing I did notice is when approaching the bounds of the screen when zooming (I think specifically the bottom but the image would always touch both the top and bottom at the same time since it was centered) there would sometimes be a stutter or jump, like it was shifting to meet the SafeAreaInsets or something. I eventually tried (and failed) with UIImageView.transform and CGAffineTransform and moved to UIScrollView which has lots of build in support for zooming including dealing with zooming to the sides of an image etc. – Zimm3r Dec 23 '22 at 20:28
15

Here is my solution with pinch-zooming in image exactly like Apple's photo app.

image

import SwiftUI

public struct SwiftUIImageViewer: View {

    let image: Image

    @State private var scale: CGFloat = 1
    @State private var lastScale: CGFloat = 1

    @State private var offset: CGPoint = .zero
    @State private var lastTranslation: CGSize = .zero

    public init(image: Image) {
        self.image = image
    }

    public var body: some View {
        GeometryReader { proxy in
            ZStack {
                image
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .scaleEffect(scale)
                    .offset(x: offset.x, y: offset.y)
                    .gesture(makeDragGesture(size: proxy.size))
                    .gesture(makeMagnificationGesture(size: proxy.size))
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .edgesIgnoringSafeArea(.all)
        }
    }

    private func makeMagnificationGesture(size: CGSize) -> some Gesture {
        MagnificationGesture()
            .onChanged { value in
                let delta = value / lastScale
                lastScale = value

                // To minimize jittering
                if abs(1 - delta) > 0.01 {
                    scale *= delta
                }
            }
            .onEnded { _ in
                lastScale = 1
                if scale < 1 {
                    withAnimation {
                        scale = 1
                    }
                }
                adjustMaxOffset(size: size)
            }
    }

    private func makeDragGesture(size: CGSize) -> some Gesture {
        DragGesture()
            .onChanged { value in
                let diff = CGPoint(
                    x: value.translation.width - lastTranslation.width,
                    y: value.translation.height - lastTranslation.height
                )
                offset = .init(x: offset.x + diff.x, y: offset.y + diff.y)
                lastTranslation = value.translation
            }
            .onEnded { _ in
                adjustMaxOffset(size: size)
            }
    }

    private func adjustMaxOffset(size: CGSize) {
        let maxOffsetX = (size.width * (scale - 1)) / 2
        let maxOffsetY = (size.height * (scale - 1)) / 2

        var newOffsetX = offset.x
        var newOffsetY = offset.y

        if abs(newOffsetX) > maxOffsetX {
            newOffsetX = maxOffsetX * (abs(newOffsetX) / newOffsetX)
        }
        if abs(newOffsetY) > maxOffsetY {
            newOffsetY = maxOffsetY * (abs(newOffsetY) / newOffsetY)
        }

        let newOffset = CGPoint(x: newOffsetX, y: newOffsetY)
        if newOffset != offset {
            withAnimation {
                offset = newOffset
            }
        }
        self.lastTranslation = .zero
    }
}

Also, I have this solution as Swift Package in my GitHub here.

fuzzzlove
  • 183
  • 1
  • 6
13

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
})
Louis Lac
  • 5,298
  • 1
  • 21
  • 36
7

my two cents. I did search and find a solution from: iOSCretor repo(https://github.com/ioscreator/ioscreator, thanks to Arthur Knopper!)

I did slightly modify and copied here, for convenience, adding reset method.

technically we:

  1. add image with scale and state.

  2. add 2 gestures that work simultaneously

  3. add also a "reset" via double tap

    import SwiftUI
    
     struct ContentView: View {
    
    
         @GestureState private var scaleState: CGFloat = 1
         @GestureState private var offsetState = CGSize.zero
    
         @State private var offset = CGSize.zero
         @State private var scale: CGFloat = 1
    
         func resetStatus(){
             self.offset = CGSize.zero
             self.scale = 1
         }
    
         init(){
             resetStatus()
         }
    
         var zoomGesture: 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 doubleTapGesture : some Gesture {
             TapGesture(count: 2).onEnded { value in
                 resetStatus()
             }
         }
    
    
         var body: some View {
             Image(systemName: "paperplane")
                 .renderingMode(.template)
                 .resizable()
                 .foregroundColor(.red)
                 .scaledToFit()
                 .scaleEffect(self.scale * scaleState)
                 .offset(x: offset.width + offsetState.width, y: offset.height + offsetState.height)
                 .gesture(SimultaneousGesture(zoomGesture, dragGesture))
                 .gesture(doubleTapGesture)
    
         }
    
     }
    

for Your convenience here is a GIST: https://gist.github.com/ingconti/124d549e2671fd91d86144bc222d171a

ingconti
  • 10,876
  • 3
  • 61
  • 48
  • can be nice to prevent dragging if not zoomed: simply put gestureState = ... and offset.height +=.... under condition: if scale>1.0{ – ingconti Jul 06 '22 at 05:58
  • This is really nice. How would you recommend passing a binding value to dismiss the image by dragging if not zoomed? – Robert Oct 26 '22 at 02:01
  • This is great, but it seems zoom always happens from center? e.g. not relative to _where_ you're pinching. Not sure if there's an easy solve for that... you'd likely have to adjust the `offsetState` as your `scaleState` changes but the math is beyond me... – Ruben Martinez Jr. Nov 10 '22 at 16:00
  • Update: To make the image scale from current offset, you can update the offset like so: `offset = CGSize(width: offset.width * (newScale / oldScale), height: offset.height * (newScale / oldScale))`. You'll have to update the gesture state in another `zoomGesture` `updating` block, and then update `offset` at `onEnded`. – Ruben Martinez Jr. Nov 14 '22 at 19:36
6

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.

ethoooo
  • 536
  • 4
  • 12
  • 3
    I don't think this will work. The scale in callback is relative so start of gesture. So multiplying on each callback by the delta will mess stuff up e.g. if you scale out to double size then on each tick it will double your scale here. Probably not what you want. – James Oct 19 '19 at 21:40
  • That's true in some cases. It depends how you have your heirarchy set up. If the gesture is not on the view that is scaling, you will need to go with your answer, if the gesture is on the same view that is scaling, my answer does the trick :) – ethoooo Oct 29 '19 at 00:33
6

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
    }

}
Brownsoo Han
  • 4,549
  • 3
  • 20
  • 20
  • 2
    Hope apple will provide a standard and simple way to do those dragging operation in future. Note, ```simultaneously ``` is renamed to ```simultaneousGesture``` in SwiftUI latest version. – Zhou Haibo Apr 16 '20 at 10:00
5

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.

smakus
  • 1,107
  • 10
  • 11
  • 1
    Thank you for the example. The changes I made was to remove the check for `newScale < 1` inside `onChanged` and add `if scale < 1 { scale = 1 }` inside `onEnded` before `lastScaleValue = 1`. This gives a nice bounce back to sclae 1 when zooming out. – s_diaconu Mar 24 '23 at 21:57
5

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)
        }
    }
}
atineoSE
  • 3,597
  • 4
  • 27
  • 31
4

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))
    }
}
Learner
  • 621
  • 6
  • 9
3

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
      }
  }
Cenk Bilgen
  • 1,330
  • 9
  • 8
3

Taking hints from several comments, other answers and my own UIKit version of a UIScrollView that centers content and sets the minimum zoom scale to fit the image, I created a UIViewRepresentable that handles zooming, panning of a uiImage. As long as ScrollView doesn't really support all this, I think this is the way to go. Hope it helps someone.

struct ZoomableUIImageView: UIViewRepresentable {
    var image: UIImage
    
    typealias UIViewType = InsetCenteredImageScrollView
    
    func makeUIView(context: Context) -> UIViewType {
        let imageView = UIImageView(image: image)
        let scrollView = UIViewType(imageView: imageView)
        
        scrollView.delegate = context.coordinator  // for viewForZooming(in:)
        scrollView.maximumZoomScale = 8
        scrollView.minimumZoomScale = 0.1
        scrollView.bouncesZoom = true
        scrollView.bounces = true
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.contentInsetAdjustmentBehavior = .never
        
        imageView.contentMode = .scaleAspectFill
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.setNeedsLayout()
        imageView.layoutIfNeeded()
        
        scrollView.addSubview(imageView)
        
        return scrollView
    }
    
    func makeCoordinator() -> Coordinator { return Coordinator() }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        uiView.imageView!.image = self.image
    }
    
    // MARK: - Coordinator
    class Coordinator: NSObject, UIScrollViewDelegate {
        func viewForZooming(in scrollView: UIScrollView) -> UIView? {
            let scrollView = scrollView as! UIViewType
            return scrollView.imageView
        }
    }
    
    // MARK: - UIScrollView
    class InsetCenteredImageScrollView: UIScrollView {
        weak var imageView: UIImageView?
        
        init(imageView: UIImageView) { self.imageView = imageView; super.init(frame: .zero) }
        required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
        
        private var minZoomScaleIsSet: Bool = false
        
        override var bounds: CGRect { didSet { centerContent() } }
        override var contentSize: CGSize { didSet { centerContent() } }
        override var zoomScale: CGFloat { didSet { centerContent() } }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            self.centerContent()
            
            guard bounds.size != .zero else { return }
            guard minZoomScaleIsSet == false else { return }
            guard let imageView else { return }
            guard let image = imageView.image else { return }
            
            let imageViewSize = image.size;
            let scrollViewSize = self.bounds.size
            
            var minZoomScale: CGFloat!
            let widthScale = scrollViewSize.width / imageViewSize.width
            let heightScale = scrollViewSize.height / imageViewSize.height
            minZoomScale = min(widthScale, heightScale)
            
            self.contentSize = imageViewSize
            self.zoomScale = minZoomScale
            
            self.minimumZoomScale = minZoomScale
            
            minZoomScaleIsSet = true
        }
        
        func centerContent() {
            guard self.contentSize != .zero, self.bounds.size != .zero else { return }
            var top: CGFloat = 0; var left: CGFloat = 0
            
            if (self.contentSize.width < self.bounds.size.width) {
                left = (self.bounds.size.width-self.contentSize.width) * 0.5
            }
            if (self.contentSize.height < self.bounds.size.height) {
                top = (self.bounds.size.height-self.contentSize.height) * 0.5
            }
            let newContentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left)
            if self.contentInset != newContentInset {
                self.contentInset = newContentInset
            }
        }
        
        override func didAddSubview(_ subview: UIView) {
            super.didAddSubview(subview)
            self.centerContent()
        }
        override var frame: CGRect {
            get { super.frame }
            set { super.frame = newValue; self.centerContent() }
        }
    }
}
Arjan
  • 16,210
  • 5
  • 30
  • 40
1
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")
        }
    }
}
Shilpa
  • 27
  • 2
  • 5
    While this code snippet may solve the question, [including an explanation](//meta.stackexchange.com/questions/114762/explaining-entirely-code-based-answers) really helps to improve the quality of your post. Remember that you are answering the question for readers in the future, and those people might not know the reasons for your code suggestion. Please also try not to crowd your code with explanatory comments, this reduces the readability of both the code and the explanations! – Waqar UlHaq Feb 21 '20 at 11:01
  • Your sample code contains a lot of code that's not relevant to the question. This only makes it harder for us to extract the important parts. – Zun Jun 09 '21 at 14:30
0

Using the pan gesture here caused some wonky snapping behavior (similar to what is pointed out here) and it doesn't have a nice springy/bounce effect that panning around a ScrollView does.

I wanted to use a SwiftUI ScrollView and just support the zoom gesture...

struct ExampleView: View {
    @State private var lastScale: CGFloat = 1.0
    @State private var scale: CGFloat = 1.0
    
    var body: some View {
        let contentSize: CGFloat = 1500 //testing on iPad
        ScrollView(
            [.horizontal, .vertical]
        ) {
            Text("My Content")
                .font(.system(size: 300))
                .frame(
                    width: contentSize,
                    height: contentSize
                )
                .scaleEffect(scale)
                .frame(
                    width: contentSize * scale,
                    height: contentSize * scale
                )
                .background(.red)
                .gesture(
                    MagnificationGesture()
                        .onChanged { val in
                            let delta = val / lastScale
                            lastScale = val
                            let newScale = scale * delta
                            if newScale <= 3 && newScale >= 1 {
                                scale = newScale
                            }
                        }.onEnded { val in
                            lastScale = 1
                        }
                )
        }
    }
}

It works "fine", but the main problem is that zooming shifts content towards the center, instead of zooming in where you make your gesture. This isn't a ScrollView specific issue, even without the ScrollView I had the same experience.

Example showing zooming shifting away from zoom area

However, to solve this... SwiftUI ScrollViews are not very flexible. If I want to track content offset and programmatically adjust offset while I scale it is a pretty huge effort, since there's no direct support for this in SwiftUI.

The workaround I found for this is to actually zoom the whole scrollview instead, not the content.

Example showing zooming remains centered on the zoom area

struct ExampleView: View {
    @State private var lastScale: CGFloat = 1.0
    @State private var scale: CGFloat = 1.0
    
    var body: some View {
        let contentSize: CGFloat = 1500 //testing on iPad
        ScrollView(
            [.horizontal, .vertical]
        ) {
            Text("My Content")
                .font(.system(size: 300))
                .frame(
                    width: contentSize,
                    height: contentSize
                )
                .background(.red)
                .padding(contentSize / 2)
        }
        .scaleEffect(scale)
        .gesture(
            MagnificationGesture()
                .onChanged { val in
                    let delta = val / lastScale
                    lastScale = val
                    let newScale = scale * delta
                    if newScale <= 3 && newScale >= 1 {
                        scale = newScale
                    }
                }.onEnded { val in
                    lastScale = 1
                }
        )
    }
}

Obviously, this is a bit of hack but works well when the ScrollView content covers a whole screen in a ZStack. You just have to be sure you have enough content padding to handle the zoom threshold and prevent shrinking below 1.0 scale.

This wont work for all scenarios but it worked great for mine (moving around a game board), just wanted to post just in case someone else is in the same boat.

William T.
  • 12,831
  • 4
  • 56
  • 53
0

The Most Efficient & Dynamic Way:

My experience with the MagnificationGesture was pretty bad, it was very laggy and consumed A LOT of CPU and RAM (like many other solutions). The best solution was to use a basic UIScrollView.

Based on another solution, I've implemented more dynamic struct that allows you to:

  • Use it anywhere you'd like with any kind of View you'd like
  • Scale in/out by double tap
  • Zoom to the specific point that you've double tapped on

And most important thing - it has been tested to guarantee you that it won't eat up your CPU & RAM!

How to use it:

//  ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        ZoomableContainer{
            // Put here any `View` you'd like (e.g. `Image`, `Text`)
        }
    }
}

The implementation:

//  ZoomableContainer.swift

import SwiftUI

fileprivate let maxAllowedScale = 4.0

struct ZoomableContainer<Content: View>: View {
    let content: Content

    @State private var currentScale: CGFloat = 1.0
    @State private var tapLocation: CGPoint = .zero

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    func doubleTapAction(location: CGPoint) {
        tapLocation = location
        currentScale = currentScale == 1.0 ? maxAllowedScale : 1.0
    }

    var body: some View {
        ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) {
            content
        }
        .onTapGesture(count: 2, perform: doubleTapAction)
    }

    fileprivate struct ZoomableScrollView<Content: View>: UIViewRepresentable {
        private var content: Content
        @Binding private var currentScale: CGFloat
        @Binding private var tapLocation: CGPoint

        init(scale: Binding<CGFloat>, tapLocation: Binding<CGPoint>, @ViewBuilder content: () -> Content) {
            _currentScale = scale
            _tapLocation = tapLocation
            self.content = content()
        }

        func makeUIView(context: Context) -> UIScrollView {
            // Setup the UIScrollView
            let scrollView = UIScrollView()
            scrollView.delegate = context.coordinator // for viewForZooming(in:)
            scrollView.maximumZoomScale = maxAllowedScale
            scrollView.minimumZoomScale = 1
            scrollView.bouncesZoom = true
            scrollView.showsHorizontalScrollIndicator = false
            scrollView.showsVerticalScrollIndicator = false
            scrollView.clipsToBounds = false

            // 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: content), scale: $currentScale)
        }

        func updateUIView(_ uiView: UIScrollView, context: Context) {
            // Update the hosting controller's SwiftUI content
            context.coordinator.hostingController.rootView = content

            if uiView.zoomScale > uiView.minimumZoomScale { // Scale out
                uiView.setZoomScale(currentScale, animated: true)
            } else if tapLocation != .zero { // Scale in to a specific point
                uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true)
                // Reset the location to prevent scaling to it in case of a negative scale (manual pinch)
                // Use the main thread to prevent unexpected behavior
                DispatchQueue.main.async { tapLocation = .zero }
            }

            assert(context.coordinator.hostingController.view.superview == uiView)
        }

        // MARK: - Utils

        func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect {
            let scrollViewSize = scrollView.bounds.size

            let width = scrollViewSize.width / scale
            let height = scrollViewSize.height / scale
            let x = center.x - (width / 2.0)
            let y = center.y - (height / 2.0)

            return CGRect(x: x, y: y, width: width, height: height)
        }

        // MARK: - Coordinator

        class Coordinator: NSObject, UIScrollViewDelegate {
            var hostingController: UIHostingController<Content>
            @Binding var currentScale: CGFloat

            init(hostingController: UIHostingController<Content>, scale: Binding<CGFloat>) {
                self.hostingController = hostingController
                _currentScale = scale
            }

            func viewForZooming(in scrollView: UIScrollView) -> UIView? {
                return hostingController.view
            }

            func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
                currentScale = scale
            }
        }
    }
}

Ido
  • 473
  • 4
  • 16