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

huangapple go评论59阅读模式
英文:

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 taplocation 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))
        }
    }
}
英文:

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: &quot;arrowtriangle.up.fill&quot;)
struct ContentView: View {
@State private var tapLocation = CGPoint.zero
var body: some View {
GeometryReader { proxy in
ZStack {
Image(&quot;worldMap&quot;)
.resizable()
arrowPointUp
.foregroundColor(.green)
.position(tapLocation)
arrowPointUp
.foregroundColor(.blue)
.position(x: 670, y: 389)
arrowPointUp
.foregroundColor(.blue)
.position(x: 1246, y: 467)
}
.coordinateSpace(name: &quot;mapImage&quot;)
.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&lt;CGPoint&gt;) -&gt; 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&lt;CGPoint&gt;) {
self.contentSize = contentSize
self._tapLocation = tapLocation
}
var doubleTapGesture: some Gesture {
TapGesture(count: 2).onEnded {
if currentScale &lt;= min { currentScale = max } else
if currentScale &gt;= max { currentScale = min } else {
currentScale = ((max - min) * 0.5 + min) &lt; currentScale ? max : min
}
}
}
func body(content: Content) -&gt; 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(&quot;Tapped at \(location)&quot;, &quot;Current scale: \(currentScale)&quot;)
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) -&gt; Void
init(minScale: CGFloat,
maxScale: CGFloat,
currentScale: CGFloat,
scaleChange: @escaping (CGFloat) -&gt; 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 &lt;= minScale {
scale = minScale
} else if gesture.scale &gt;= 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) -&gt; 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) -&gt; 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 ...

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

答案1

得分: 0

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 Pin symbols to a pan and zoomable Image @ fixed location. Pins incorrectly placed when zoomed in

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 the device or changing the split size on iPad!


<details>
<summary>英文:</summary>
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&lt;CGPoint&gt;) {
self.contentSize = contentSize
self._tapLocation = tapLocation
}
var doubleTapGesture: some Gesture {
TapGesture(count: 2).onEnded {
if currentScale &lt;= min { currentScale = max } else
if currentScale &gt;= max { currentScale = min } else {
currentScale = ((max - min) * 0.5 + min) &lt; currentScale ? max : min
}
}
}
func body(content: Content) -&gt; 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(&quot;Tapped at \(location)&quot;, &quot;Current scale: \(currentScale)&quot;)
print(&quot;Corrected location for Scale: \(correctedLocation)&quot;)
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(&quot;image&quot;, 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!
</details>

huangapple
  • 本文由 发表于 2023年6月8日 18:26:12
  • 转载请务必保留本文链接:https://go.coder-hub.com/76430912.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定