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

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

Forums > SwiftUI

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

2      

I think this video from Nick will give you a hint how you can tackle that challenge. It is not so lengthy but his explanation is just awesome! https://youtu.be/1GYKyQHVDWw

3      

Gonna watch it, hope it helps. Thanks. But wouldn't mind a solution, been at it way to long. ...

2      

Was enough to steer me in the right direction, didn't even have to watch the whole thing:

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!

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.