I have a custom modifier on a SwiftUI View to pan and zoom an Image. I need to get the location where the user long presses. The modifier uses an UIView (UIViewRepresentable) under the hood where the pinch-and-zoom gesture is added to the view. Adding the longPressGesture directly to the modifier (swiftUI View) conflicts with this pinch-and-zoom gesture. Thus I added a longPress gesture directly to the UIView. This works well. However, I need the location (CGPoint) up the chain in my ViewModifier to for a Binding to the calling View. I can't seem to work out how to do that...
SHORT: I need tapLocation on my ViewModifier to have the value of the. long press in the UIView!
Calling View:
let arrowPointUp = Image(systemName: "arrowtriangle.up.fill")
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)
}
.frame(width: mapImage.size.width, height: mapImage.size.height)
.PinchToZoomAndPan(contentSize: mapImage.size, tapLocation: $tapLocation)
}
}
}
The ViewModifier
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 = 0.75 // 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
print("ContentSize: \(self.contentSize)")
}
var doubleTapGesture: some Gesture {
TapGesture(count: 2).onEnded {
currentScale = 1.0
}
}
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))
.simultaneousGesture(doubleTapGesture)
}
.animation(.easeInOut, value: currentScale)
}
}
// THREE
class PinchZoomView: UIView {
let minScale: CGFloat
let maxScale: CGFloat
var isPinching: Bool = false
var scale: CGFloat = 1.0
let scaleChange: (CGFloat) -> Void
var longPressLocation = CGPoint.zero
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
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(longPress(gesture:)))
addGestureRecognizer(pinchGesture)
addGestureRecognizer(longPressGesture)
}
required init?(coder: NSCoder) {
fatalError()
}
@objc private func longPress(gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .ended:
longPressLocation = gesture.location(in: self)
print("Long Pressed in UIView on \(longPressLocation) with scale \(scale)")
default:
break
}
}
@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
}
}
}
// TWO
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) { }
}
// ONE
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))
}
}
}
TIA
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 = 0.75 // 1.0
private var max: CGFloat = 3.0
@State var currentScale: CGFloat = 1.0
// The location in the Image frame the user long pressed
// to send back to the calling View
@Binding var tapLocation: CGPoint
init(contentSize: CGSize, tapLocation: Binding<CGPoint>) {
self.contentSize = contentSize
self._tapLocation = tapLocation
}
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, longPressLocation: $tapLocation))
}
.animation(.easeInOut, value: currentScale)
}
}
// THREE; Pinch and zoom View to embed in SwiftUI View
class PinchZoomView: UIView {
let minScale: CGFloat
let maxScale: CGFloat
var isPinching: Bool = false
var scale: CGFloat = 1.0
let scaleChange: (CGFloat) -> Void
let location: (CGPoint) -> Void
private var longPressLocation = CGPoint.zero
init(minScale: CGFloat, maxScale: CGFloat, currentScale: CGFloat, scaleChange: @escaping (CGFloat) -> Void, location: @escaping (CGPoint) -> Void) {
self.minScale = minScale
self.maxScale = maxScale
self.scale = currentScale
self.scaleChange = scaleChange
self.location = location
super.init(frame: .zero)
// Gestures
let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:)))
pinchGesture.cancelsTouchesInView = false
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(longPress(gesture:)))
addGestureRecognizer(pinchGesture)
addGestureRecognizer(longPressGesture)
}
required init?(coder: NSCoder) {
fatalError()
}
// location where the user long pressed, to set a pin in the calling View
// Needs to be corrected for the current zoom scale!
@objc private func longPress(gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .ended:
longPressLocation = gesture.location(in: self)
let correctedLocation = CGPoint(x: longPressLocation.x / scale, y: longPressLocation.y / scale)
location(correctedLocation)
print("Long Pressed in UIView on \(longPressLocation) with scale \(scale)")
default:
break
}
}
@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
}
}
}
// TWO: Bridge UIView to SwiftUI
struct PinchZoom: UIViewRepresentable {
let minScale: CGFloat
let maxScale: CGFloat
@Binding var scale: CGFloat
@Binding var isPinching: Bool
@Binding var longPressLocation: CGPoint
func makeUIView(context: Context) -> PinchZoomView {
let pinchZoomView = PinchZoomView(minScale: minScale, maxScale: maxScale, currentScale: scale, scaleChange: { scale = $0 }, location: { longPressLocation = $0 })
return pinchZoomView
}
func updateUIView(_ pageControl: PinchZoomView, context: Context) { }
}
// ONE; Modifier to use the UIKit View
struct PinchToZoom: ViewModifier {
let minScale: CGFloat
let maxScale: CGFloat
@Binding var scale: CGFloat
@State var anchor: UnitPoint = .center
@State var isPinching: Bool = false
@Binding var longPressLocation: CGPoint
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, longPressLocation: $longPressLocation))
}
}
}
Closures did the job!