swiftuiuiviewrepresentableswiftui-viewviewmodifier

SWIFTUI ViewModifier using UIViewRepresentable; getting a value from the UIView to my SwiftUI View


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))
        }
    }
}

GitHub

TIA


Solution

  • 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!