英文:
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: "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 ...
答案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
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<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!
</details>
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论