8

I'm trying to implement a custom context menu that will appear after a long press at the location the user touched. I have been unable to find a way to capture the XY location of a touch down event for onLongPressGesture.

This is where I started

struct ExampleView: View {
    @State var showCustomContextMenu = false
    @State var longPressLocation = CGPoint.zero
    
    var body: some View {
        Rectangle()
            .foregroundColor(Color.green)
            .frame(width: 100.0, height: 100.0)
            .onLongPressGesture {
                print("OnLongPressGesture")
                self.showCustomContextMenu = true
            }
            .overlay(
                Rectangle()
                    .foregroundColor(Color.red)
                    .frame(width: 50.0, height: 50.0)
                    .position(longPressLocation) // <----- this is what I need to capture.
                    .opacity( (showCustomContextMenu) ? 1 : 0 )
        )
    }
}

After looking at this question (and the other SO questions linked in the answer) I tried the following.

How do you detect a SwiftUI touchDown event with no movement or duration?

struct ExampleView: View {
    @State var showCustomContextMenu = false
    @State var longPressLocation = CGPoint.zero
    
    var body: some View {
        ZStack{
            Rectangle()
                .foregroundColor(Color.green)
                .frame(width: 100.0, height: 100.0)
                .onLongPressGesture {
                    print("OnLongPressGesture")
                    self.showCustomContextMenu = true
                }
                .overlay(
                    Rectangle()
                        .foregroundColor(Color.red)
                        .frame(width: 50.0, height: 50.0)
                        .position(longPressLocation)
                        .opacity( (showCustomContextMenu) ? 1 : 0 )
            )
            TapView { point in
                self.longPressLocation = point
                print("Point: \(point)")
            }.background(Color.gray).opacity(0.5)
        }
    }
}

struct TapView: UIViewRepresentable {
    var tappedCallback: ((CGPoint) -> Void)

    func makeUIView(context: UIViewRepresentableContext<TapView>) -> TapView.UIViewType {
        let v = UIView(frame: .zero)
        let gesture = SingleTouchDownGestureRecognizer(target: context.coordinator,
                                                       action: #selector(Coordinator.tapped))
        v.addGestureRecognizer(gesture)
        return v
    }

    class Coordinator: NSObject {
        var tappedCallback: ((CGPoint) -> Void)

        init(tappedCallback: @escaping ((CGPoint) -> Void)) {
            self.tappedCallback = tappedCallback
        }

        @objc func tapped(gesture:UITapGestureRecognizer) {
            self.tappedCallback(gesture.location(in: gesture.view))
        }
    }

    func makeCoordinator() -> TapView.Coordinator {
        return Coordinator(tappedCallback:self.tappedCallback)
    }

    func updateUIView(_ uiView: UIView,
                      context: UIViewRepresentableContext<TapView>) {
    }
}

class SingleTouchDownGestureRecognizer: UIGestureRecognizer {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        if self.state == .possible {
            self.state = .recognized
        }
    }
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        self.state = .failed
    }
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        self.state = .failed
    }
}

This almost works, however, the problem this leaves me with is that since Rectangle() and TapView() are in a ZStack depending on where I place them in code I get either the touchDown location or the onLongPressGesture but not both.

Other SO questions I've looked at but ran into similar problems are linked below

How to detect a tap gesture location in SwiftUI?

Swift: Long Press Gesture Recognizer - Detect taps and Long Press This one might be what I'm looking for but I'm not sure how to adapt it to SwiftUI.

Adam VZ
  • 178
  • 1
  • 7

1 Answers1

10

Here is a demo of possible approach. It needs a combination of two gestures: LongPress to detect long press and Drag to detect location.

Tested with Xcode 12 / iOS 14. (on below systems it might be needed to add self. to some properties usage)

demo

struct ExampleView: View {
    @State var showCustomContextMenu = false
    @State var longPressLocation = CGPoint.zero

    var body: some View {
        Rectangle()
            .foregroundColor(Color.green)
            .frame(width: 100.0, height: 100.0)
            .onTapGesture { showCustomContextMenu = false } // just for demo
            .gesture(LongPressGesture(minimumDuration: 1).sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
                .onEnded { value in
                    switch value {
                        case .second(true, let drag):
                            longPressLocation = drag?.location ?? .zero   // capture location !!
                            showCustomContextMenu = true
                        default:
                            break
                    }
            })
            .overlay(
                Rectangle()
                    .foregroundColor(Color.red)
                    .frame(width: 50.0, height: 50.0)
                    .position(longPressLocation)
                    .opacity( (showCustomContextMenu) ? 1 : 0 )
                    .allowsHitTesting(false)
        )
    }
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • For some reason it is only working on canvas preview. In the simulator when I long press, the location is `.zero` so the Rectangle() is shown in the `.zero` `CGPoint` – Dennis Mar 18 '21 at 09:08
  • 3
    This doesn't work - `drag` is nil until the user moves the finger! If she lifts it without moving, there is nothing to read the location from. I really really dislike how SwiftUI left out useful info from the gesture events. – Tomáš Kafka Jul 22 '22 at 17:13