Detect touches and ignore other gestures Swift SceneKit

94 Views Asked by At

I have an interactive globe and I want to detect taps on the globe, so I can get their 3d position.

When I override the function public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?), I am able to detect when a gesture starts, and from the touches' parameter I can get the 3d position of the tap on the globe.

The problem is this function executes from any gesture (drag, magnification, etc.), but I only want to do something on a click or tap.

I could add a tap gesture to the scene view, but the function called from a tap gesture does not give me the 3d position of the tap within the globe.

Is there any other function I can override to detect only taps while getting touches: Set?
If not, is there any way I can filter out the touches to only tap gestures?

import Foundation
import SceneKit
import CoreImage
import SwiftUI
import MapKit

public typealias GenericController = UIViewController

public class GlobeViewController: GenericController {
    var nodePos: CGPoint? = nil
    public var earthNode: SCNNode!
    private var sceneView : SCNView!
    private var cameraNode: SCNNode!
    private var dotCount = 50000
    
    public init(earthRadius: Double) {
        self.earthRadius = earthRadius
        super.init(nibName: nil, bundle: nil)
    }
    
    public init(earthRadius: Double, dotCount: Int) {
        self.earthRadius = earthRadius
        self.dotCount = dotCount
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first, touch.view == self.sceneView else {
            return
        }

        let touchLocation = touch.location(in: sceneView)
        let hitTestResults = sceneView.hitTest(touchLocation, options: nil)
        
        let touchLocation3D = sceneView.unprojectPoint(SCNVector3(Float(touchLocation.x), Float(touchLocation.y), 0.0))
        
        print(touchLocation3D)
        
        if let tappedNode = hitTestResults.first?.node {
            print(tappedNode)
            // Handle the tapped node
            if tappedNode.name == "NewYorkDot" {
                // This is the New York dot, perform your action here
                print("Tapped on New York! Position: \(tappedNode.position)")
            } else if tappedNode.name == "RegularDot" {
                // Handle other nodes if needed
                print("Tapped on a regular dot. Position: \(tappedNode.position)")
            }
        }
    }

    public override func viewDidLoad() {
        super.viewDidLoad()
        setupScene()
        
        setupParticles()
        
        setupCamera()
        setupGlobe()
        
        setupDotGeometry()
    }
    
    private func setupScene() {
        let scene = SCNScene()
        sceneView = SCNView(frame: view.frame)
        sceneView.scene = scene
        sceneView.showsStatistics = true
        sceneView.backgroundColor = .clear
        sceneView.allowsCameraControl = true
        sceneView.isUserInteractionEnabled = true
        self.view.addSubview(sceneView)
    }
        
    private func setupParticles() {
        guard let stars = SCNParticleSystem(named: "StarsParticles.scnp", inDirectory: nil) else { return }
        stars.isLightingEnabled = false
                
        if sceneView != nil {
            sceneView.scene?.rootNode.addParticleSystem(stars)
        }
    }
    
    private func setupCamera() {
        self.cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        cameraNode.position = SCNVector3(x: 0, y: 0, z: 5)
        sceneView.scene?.rootNode.addChildNode(cameraNode)
    }

    private func setupGlobe() {
        self.earthNode = EarthNode(radius: earthRadius, earthColor: earthColor, earthGlow: glowColor, earthReflection: reflectionColor)
        sceneView.scene?.rootNode.addChildNode(earthNode)
    }

    private func setupDotGeometry() {
        let textureMap = generateTextureMap(dots: dotCount, sphereRadius: CGFloat(earthRadius))

        let newYork = CLLocationCoordinate2D(latitude: 44.0682, longitude: -121.3153)
        let newYorkDot = closestDotPosition(to: newYork, in: textureMap)

        let dotColor = GenericColor(white: 1, alpha: 1)
        let oceanColor = GenericColor(cgColor: UIColor.systemRed.cgColor)
        let highlightColor = GenericColor(cgColor: UIColor.systemRed.cgColor)
        
        // threshold to determine if the pixel in the earth-dark.jpg represents terrain (0.03 represents rgb(7.65,7.65,7.65), which is almost black)
        let threshold: CGFloat = 0.03
        
        let dotGeometry = SCNSphere(radius: dotRadius)
        dotGeometry.firstMaterial?.diffuse.contents = dotColor
        dotGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
        
        let highlightGeometry = SCNSphere(radius: dotRadius)
        highlightGeometry.firstMaterial?.diffuse.contents = highlightColor
        highlightGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
        
        let oceanGeometry = SCNSphere(radius: dotRadius)
        oceanGeometry.firstMaterial?.diffuse.contents = oceanColor
        oceanGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
        
        var positions = [SCNVector3]()
        var dotNodes = [SCNNode]()
        
        var highlightedNode: SCNNode? = nil
        
        for i in 0...textureMap.count - 1 {
            let u = textureMap[i].x
            let v = textureMap[i].y
            
            let pixelColor = self.getPixelColor(x: Int(u), y: Int(v))
            let isHighlight = u == newYorkDot.x && v == newYorkDot.y
            
            if (isHighlight) {
                let dotNode = SCNNode(geometry: highlightGeometry)
                dotNode.name = "NewYorkDot"
                dotNode.position = textureMap[i].position
                positions.append(dotNode.position)
                dotNodes.append(dotNode)
                
                print("myloc \(textureMap[i].position)")
                
                highlightedNode = dotNode
            } else if (pixelColor.red < threshold && pixelColor.green < threshold && pixelColor.blue < threshold) {
                let dotNode = SCNNode(geometry: dotGeometry)
                dotNode.name = "Other"
                dotNode.position = textureMap[i].position
                positions.append(dotNode.position)
                dotNodes.append(dotNode)
            }
        }
        
        DispatchQueue.main.async {
            let dotPositions = positions as NSArray
            let dotIndices = NSArray()
            let source = SCNGeometrySource(vertices: dotPositions as! [SCNVector3])
            let element = SCNGeometryElement(indices: dotIndices as! [Int32], primitiveType: .point)
            
            let pointCloud = SCNGeometry(sources: [source], elements: [element])
            
            let pointCloudNode = SCNNode(geometry: pointCloud)
            for dotNode in dotNodes {
                pointCloudNode.addChildNode(dotNode)
            }
     
            self.sceneView.scene?.rootNode.addChildNode(pointCloudNode)
            
        }
   }
}


typealias GenericControllerRepresentable = UIViewControllerRepresentable

@available(iOS 13.0, *)
private struct GlobeViewControllerRepresentable: GenericControllerRepresentable {
    var particles: SCNParticleSystem? = nil

    func makeUIViewController(context: Context) -> GlobeViewController {
        let globeController = GlobeViewController(earthRadius: 1.0)
        updateGlobeController(globeController)
        return globeController
    }
    
    func updateUIViewController(_ uiViewController: GlobeViewController, context: Context) { }
}

@available(iOS 13.0, *)
public struct GlobeView: View {
    public var body: some View {
        GlobeViewControllerRepresentable()
    }
}
1

There are 1 best solutions below

1
VonC On BEST ANSWER

Using ColdLogic's advice, using a UITapGestureRecognizer for detecting tap gestures in SceneKit could indeed be the recommended approach for your scenario.

You attach a UITapGestureRecognizer to the SCNView. That recognizer is specifically for detecting tap gestures.
In the handleTap method, you perform a hit test using the tap location. That is to find the 3D coordinates of the tap on the globe.
The hit test results give you the 3D position, which is what you are interested in for your interactive globe.

[User Interaction] 
       |
   [Tap Gesture]
       |
[handleTap Method] --> [Hit Test on sceneView]
       |
[3D Position Calculation]
       |
[Handle 3D Tap Position]

That way, you would not have to parse touch events manually with touchesBegan, which can be more complex and less efficient for this specific use case.

Use the hit test result to get the 3D position of the tap on the globe: your GlobeViewController class would be:

// Previous Code

public class GlobeViewController: GenericController {
    // Existing properties and initializers

    public override func viewDidLoad() {
        super.viewDidLoad()
        setupScene()
        
        // Other setup methods

        // Add Tap Gesture Recognizer
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
        sceneView.addGestureRecognizer(tapGesture)
    }

    @objc func handleTap(_ gestureRecognize: UIGestureRecognizer) {
        let p = gestureRecognize.location(in: sceneView)
        let hitResults = sceneView.hitTest(p, options: [:])
        
        if let hitResult = hitResults.first {
            let position3D = hitResult.worldCoordinates
            // Use the 3D position as needed
            print("3D Tap Position: \(position3D)")
        }
    }
    
    // Rest of the class
}
  • UITapGestureRecognizer is used to detect taps on the sceneView.
  • handleTap is triggered when a tap is recognized. It performs a hit test at the tap location and gets the first result.
  • hitResult.worldCoordinates gives the 3D coordinates of the tap on the globe.

That should detect taps specifically, and get their 3D position without being triggered by other gestures like drag or magnification.


To expand on the handleTap function, the hit test results can include multiple hits if there are several objects at the tap location. You can iterate over these results to find the desired node or the first hit, depending on the application's needs.

@objc func handleTap(_ gesture: UITapGestureRecognizer) {
    let touchLocation = gesture.location(in: sceneView)
    let hitTestResults = sceneView.hitTest(touchLocation, options: nil)

    hitTestResults.forEach { result in
        let position3D = result.worldCoordinates
        // Use the 3D position as needed
        print("Tapped position in 3D: \(position3D)")

        // Example: Check if a specific node was tapped
        if let tappedNode = result.node {
            print("Tapped on node: \(tappedNode.name ?? "unknown")")
            // Perform actions specific to the tapped node
        }
    }
}

The forEach loop iterates over each hit test result. result.worldCoordinates provides the 3D coordinates of the hit location.
You can also access the node property of each hit result to perform node-specific actions.