bob_mosh
bob_mosh

Reputation: 335

Using SwiftUI with multiple UIGestureRecognizers?

I am currently trying to write an app that uses a navigation similar to apps like Affinity, or Procreate, where you use a single touch/drag to draw, retouch and interact, and two-finger gestures to navigate on the canvas.

I am building the app using SwiftUI as the main framework and include UIKit as necessary.

Unfortunately, SwiftUI does not yet allow for complex gestures like the ones described, but UIKit usually does. So I reverted to using UIKit as my gesture recognisers instead of relying on SwiftUI gestures. This however is causing the issue, that only the topmost gesture recogniser will be called. I was hoping for recognition of multiple simultaneous gestures like demonstrated here, but unfortunately, SwiftUI seems to cause issues with the UIViewRepresentables.

Can someone help me figure out a solution to this?

Important: I am doing this with two separate views because in the long run, they will be used on different views. In the example, however, I have them on the same view for demonstration purposes.

Usage:

ZStack {
    DragGestureView { point in
        print("One Finger")
    } dragEndedCallback: {
        print("One Finger Ended")
    }

    TwoFingerNavigationView { point in
        viewStore.send(.dragChanged(point))
        print("Two Fingers")
    } dragEndedCallback: {
        viewStore.send(.dragEnded)
        print("Two Fingers Ended")
    } pinchedCallback: { value in
        viewStore.send(.magnificationChanged(value))
    } pinchEndedCallback: {
        viewStore.send(.magnificationEnded)
    }

    content()
        .position(viewStore.location)
        .scaleEffect(viewStore.scale * viewStore.offsetScale)
}

DragGestureView

public struct DragGestureView: UIViewRepresentable {
    let delegate = GestureRecognizerDelegate()
    var draggedCallback: ((CGPoint) -> Void)
    var dragEndedCallback: (() -> Void)

    public init(draggedCallback: @escaping ((CGPoint) -> Void), dragEndedCallback: @escaping (() -> Void)) {
        self.draggedCallback = draggedCallback
        self.dragEndedCallback = dragEndedCallback
    }

    public class Coordinator: NSObject {
        var draggedCallback: ((CGPoint) -> Void)
        var dragEndedCallback: (() -> Void)

        public init(draggedCallback: @escaping ((CGPoint) -> Void),
             dragEndedCallback: @escaping (() -> Void)) {
            self.draggedCallback = draggedCallback
            self.dragEndedCallback = dragEndedCallback
        }

        @objc func dragged(gesture: UIPanGestureRecognizer) {
            if gesture.state == .ended {
                self.dragEndedCallback()
            } else {
                self.draggedCallback(gesture.location(in: gesture.view))
            }
        }
    }

    class GestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate {
        func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
            return true
        }
    }

    public func makeUIView(context: UIViewRepresentableContext<DragGestureView>) -> DragGestureView.UIViewType {
        let view = UIView(frame: .zero)
        let gesture = UIPanGestureRecognizer(target: context.coordinator,
                                             action: #selector(Coordinator.dragged))
        gesture.minimumNumberOfTouches = 1
        gesture.maximumNumberOfTouches = 1
        gesture.delegate = delegate
        view.addGestureRecognizer(gesture)
        return view
    }

    public func makeCoordinator() -> DragGestureView.Coordinator {
        return Coordinator(draggedCallback: self.draggedCallback,
                           dragEndedCallback: self.dragEndedCallback)
    }

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

TwoFingerNavigationView

struct TwoFingerNavigationView: UIViewRepresentable {
    let delegate = GestureRecognizerDelegate()

    var draggedCallback: ((CGPoint) -> Void)
    var dragEndedCallback: (() -> Void)
    var pinchedCallback: ((CGFloat) -> Void)
    var pinchEndedCallback: (() -> Void)

    class Coordinator: NSObject {
        var draggedCallback: ((CGPoint) -> Void)
        var dragEndedCallback: (() -> Void)
        var pinchedCallback: ((CGFloat) -> Void)
        var pinchEndedCallback: (() -> Void)

        var startingDistance: CGFloat? = nil
        var isMagnifying = false
        var startingMagnification: CGFloat? = nil
        var newMagnification: CGFloat = 1.0

        init(draggedCallback: @escaping ((CGPoint) -> Void),
             dragEndedCallback: @escaping (() -> Void),
             pinchedCallback: @escaping ((CGFloat) -> Void),
             pinchEndedCallback: @escaping (() -> Void)) {
            self.draggedCallback = draggedCallback
            self.dragEndedCallback = dragEndedCallback
            self.pinchedCallback = pinchedCallback
            self.pinchEndedCallback = pinchEndedCallback
        }

        @objc func dragged(gesture: UIPanGestureRecognizer) {
            if gesture.state == .ended {
                self.dragEndedCallback()
                self.pinchEndedCallback()
                startingDistance = nil
                isMagnifying = false
                startingMagnification = nil
                newMagnification = 1.0
            } else {
                self.draggedCallback(gesture.translation(in: gesture.view) / (newMagnification))
            }

            var touchLocations: [CGPoint] = []
            for i in 0..<gesture.numberOfTouches{
                touchLocations.append(gesture.location(ofTouch: i, in: gesture.view))
            }

            if touchLocations.count == 2 {
                let distanceVector = (touchLocations[0] - touchLocations[1])
                let distance = sqrt(distanceVector.x * distanceVector.x + distanceVector.y * distanceVector.y)

                guard startingDistance != nil else { startingDistance = distance; return }
                guard distance - startingDistance! > 30 || distance - startingDistance! < -30 || isMagnifying else { return }
                isMagnifying = true;

                if startingMagnification == nil {
                    startingMagnification = distance / 100
                    pinchedCallback(1)
                } else {
                    let magnification = distance / 100
                    newMagnification = magnification / startingMagnification!
                    pinchedCallback(newMagnification)
                }
            }
        }
    }

    class GestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate {
        func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
            return true
        }
    }

    func makeUIView(context: UIViewRepresentableContext<TwoFingerNavigationView>) -> TwoFingerNavigationView.UIViewType {
        let view = UIView(frame: .zero)
        let gesture = UIPanGestureRecognizer(target: context.coordinator,
                                             action: #selector(Coordinator.dragged))
        gesture.minimumNumberOfTouches = 2
        gesture.maximumNumberOfTouches = 2
        gesture.delegate = delegate
        view.addGestureRecognizer(gesture)
        return view
    }

    func makeCoordinator() -> TwoFingerNavigationView.Coordinator {
        return Coordinator(draggedCallback: self.draggedCallback,
                           dragEndedCallback: self.dragEndedCallback,
                           pinchedCallback: self.pinchedCallback,
                           pinchEndedCallback: self.pinchEndedCallback)
    }

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

Upvotes: 5

Views: 2953

Answers (1)

Alexander Volkov
Alexander Volkov

Reputation: 8387

I didn't find any confirmation about my findings in official documentation, but seems SwiftUI gestures work differently than UIKit. In general - for complex cases you can either use one or another.

  • If you have UIKit elements, e.g. buttons in a view, then adding SwiftUI gestures they "receive all touches" and UIKit elements stop working (receive nothing).
  • If you didn't add any SwiftUI gestures on a view that uses UIKit elements, then all UIKit stuff works fine. This is why custom UIKit views work fine in SwiftUI.

If you add SwiftUI views one over another with any kind of gestures, then, as you mentioned, only top level view gestures work. It's the same in UIKit: 1) first hitTests finds all views in for the given point, then 2) the system collects all UIGestureRecognizers for these views, then 3) it tries to pass the touches to the gesture recognizer system and checks which gesture recognizers would receive touches and which should fail. So, in your case you don't have the underlaying view DragGestureView in the hierarchy collected on step 1). So, only gesture recognizers from TwoFingerNavigationView are working.

The solution for you is to put both gesture recognizers into one UIView. They will appear both on step 2) and following your implementation will work simultaneously.

Upvotes: 2

Related Questions