swiftuilocationmapkitswiftui-scrollviewswiftui-image

Pin symbols to a pan and zoomable Image @ fixed location. Pins incorrectly placed when zoomed in


I want to be able to place pins (symbols for now) on a user selected Image. The image should be pan-able and zoomable. The pins should stick to their location (pixels) relative to the underlying image. I got this working using a ScrollView by way of an ViewModifier (adapted by me, original from someone on Github). That is, on loading the view the Image is fit to the screen. Pins placed (tapLocation) stay at the correction location when I zoom in and or out. HOWEVER. if I first zoom in and then tap on a location, the pin is not placed at the tapLocation but somewhere else, often offscreen even. It does however stay at the postion it shows up when zooming in or out!

Note; the (sample) image ("mapImage") is around 2200x2000 pixels, but the taploaction is always around half that. I Assume that's a points versus pixel thing?

**The question; how do I get the pin placed at the correct position when zoomed in??? (I assume it has something to do with the scale factor?) **

Tge (test) View:

import SwiftUI

let arrowPointUp = Image(systemName: "arrowtriangle.up.fill")

struct ContentView: View {
    @State private var tapLocation = CGPoint.zero

    var body: some View {
        GeometryReader { proxy in
            ZStack {
                Image("worldMap")
                    .resizable()
                
                arrowPointUp
                    .foregroundColor(.green)
                    .position(tapLocation)
                
                arrowPointUp
                    .foregroundColor(.blue)
                    .position(x: 670, y: 389)

                arrowPointUp
                    .foregroundColor(.blue)
                    .position(x: 1246, y: 467)
            }
            .coordinateSpace(name: "mapImage")
            .frame(width: proxy.size.width, height: proxy.size.height)
            .scaledToFit()
            .clipShape(Rectangle())
            .PinchToZoomAndPan(contentSize: CGSize(width: proxy.size.width, height: proxy.size.height), tapLocation: $tapLocation)
        }
    }
}

The ViewModifier for Pinch and Zoom:

import SwiftUI
import UIKit

extension View {
    func PinchToZoomAndPan(contentSize: CGSize, tapLocation: Binding<CGPoint>) -> some View {
        modifier(PinchAndZoomModifier(contentSize: contentSize, tapLocation: tapLocation))
    }
}

struct PinchAndZoomModifier: ViewModifier {
    private var contentSize: CGSize
    private var min: CGFloat = 1.0
    private var max: CGFloat = 3.0
    @State var currentScale: CGFloat = 1.0
    @Binding var tapLocation: CGPoint

    init(contentSize: CGSize, tapLocation: Binding<CGPoint>) {
        self.contentSize = contentSize
        self._tapLocation = tapLocation
    }
    
    var doubleTapGesture: some Gesture {
        TapGesture(count: 2).onEnded {
            if currentScale <= min { currentScale = max } else
            if currentScale >= max { currentScale = min } else {
                currentScale = ((max - min) * 0.5 + min) < currentScale ? max : min
            }
        }
    }
    
    func body(content: Content) -> some View {
        ScrollView([.horizontal, .vertical]) {
            content
                .frame(width: contentSize.width * currentScale, height: contentSize.height * currentScale, alignment: .center)
                .modifier(PinchToZoom(minScale: min, maxScale: max, scale: $currentScale))
        }
        .gesture(doubleTapGesture)
        .onTapGesture { location in
            print("Tapped at \(location)", "Current scale: \(currentScale)")
            tapLocation = location
        }
        .animation(.easeInOut, value: currentScale)
    }
}

class PinchZoomView: UIView {
    let minScale: CGFloat
    let maxScale: CGFloat
    var isPinching: Bool = false
    var scale: CGFloat = 1.0
    let scaleChange: (CGFloat) -> Void
    
    init(minScale: CGFloat,
           maxScale: CGFloat,
         currentScale: CGFloat,
         scaleChange: @escaping (CGFloat) -> Void) {
        self.minScale = minScale
        self.maxScale = maxScale
        self.scale = currentScale
        self.scaleChange = scaleChange
        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
            
        case .changed, .ended:
            if gesture.scale <= minScale {
                scale = minScale
            } else if gesture.scale >= maxScale {
                scale = maxScale
            } else {
                scale = gesture.scale
            }
            scaleChange(scale)
        case .cancelled, .failed:
            isPinching = false
            scale = 1.0
        default:
            break
        }
    }
}

struct PinchZoom: UIViewRepresentable {
    let minScale: CGFloat
    let maxScale: CGFloat
    @Binding var scale: CGFloat
    @Binding var isPinching: Bool
    
    func makeUIView(context: Context) -> PinchZoomView {
        let pinchZoomView = PinchZoomView(minScale: minScale, maxScale: maxScale, currentScale: scale, scaleChange: { scale = $0 })
        return pinchZoomView
    }
    
    func updateUIView(_ pageControl: PinchZoomView, context: Context) { }
}

struct PinchToZoom: ViewModifier {
    let minScale: CGFloat
    let maxScale: CGFloat
    @Binding var scale: CGFloat
    @State var anchor: UnitPoint = .center
    @State var isPinching: Bool = false
    
    func body(content: Content) -> some View {
        ZStack {
            content
                .scaleEffect(scale, anchor: anchor)
                .animation(.spring(), value: isPinching)
                .overlay(PinchZoom(minScale: minScale, maxScale: maxScale, scale: $scale, isPinching: $isPinching))
        }
    }
}

The test project on Github: https://github.com/Gakkienl/PinImageToImageTest

Any help appreciated, been at it for days ...

enter image description here


Solution

  • I seem to have fixed it, just a small change to the modifier: Instead of getting the location on the ScrollView, put it directly on the content and divide by the scalefactor :)

    struct PinchAndZoomModifier: ViewModifier {
        private var contentSize: CGSize
        private var min: CGFloat = 1.0
        private var max: CGFloat = 3.0
        @State var currentScale: CGFloat = 1.0
        @Binding var tapLocation: CGPoint
    
        init(contentSize: CGSize, tapLocation: Binding<CGPoint>) {
            self.contentSize = contentSize
            self._tapLocation = tapLocation
        }
        
        var doubleTapGesture: some Gesture {
            TapGesture(count: 2).onEnded {
                if currentScale <= min { currentScale = max } else
                if currentScale >= max { currentScale = min } else {
                    currentScale = ((max - min) * 0.5 + min) < currentScale ? max : min
                }
            }
        }
        
        func body(content: Content) -> some View {
            ScrollView([.horizontal, .vertical]) {
                content
                    .frame(width: contentSize.width * currentScale, height: contentSize.height * currentScale, alignment: .center)
                    .modifier(PinchToZoom(minScale: min, maxScale: max, scale: $currentScale))
                    .onTapGesture { location in
                        let correctedLocation = CGPoint(x: location.x / currentScale, y: location.y / currentScale)
                        print("Tapped at \(location)", "Current scale: \(currentScale)")
                        print("Corrected location for Scale: \(correctedLocation)")
                        tapLocation = correctedLocation
                    }
            }
            .gesture(doubleTapGesture)
            .animation(.easeInOut, value: currentScale)
        }
    }
    
    

    In the calling View, pass in the Image size, Not the proxy size! And set frame size to image size.

    struct ContentView: View {
        @State private var mapImage = UIImage(named: "worldMap")!
        @State private var tapLocation = CGPoint.zero
        @State private var height = 0.0
        @State private var width = 0.0
    
        var body: some View {
            GeometryReader { proxy in
                ZStack {
                    Image(uiImage: mapImage)
                        .resizable()
                        .fixedSize()
                    
                    arrowPointUp
                        .foregroundColor(.green)
                        .position(tapLocation)
                    
                    arrowPointUp
                        .foregroundColor(.red)
                        .position(x: 776, y: 1150)
    
                    arrowPointUp
                        .foregroundColor(.blue)
                        .position(x: 1178, y: 1317)
                }
                .onAppear {
                    height = Double(mapImage.size.height)
                    width = Double(mapImage.size.width)
                    print("image", height, width)
                }
                .frame(width: mapImage.size.width, height: mapImage.size.height)
                .scaledToFit()
                .clipShape(Rectangle())
                .PinchToZoomAndPan(contentSize: mapImage.size, tapLocation: $tapLocation)
            }
        }
    }
    

    This seems to work, even when rotating device or changing split size on iPad!