TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

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

Forums > SwiftUI

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

MapImage

2      

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!

2      

Solved!

2      

Hacking with Swift is sponsored by Blaze.

SPONSORED Still waiting on your CI build? Speed it up ~3x with Blaze - change one line, pay less, keep your existing GitHub workflows. First 25 HWS readers to use code HACKING at checkout get 50% off the first year. Try it now for free!

Reserve your spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.