SwiftUI -> Scenekit: tap gesture recognizer

Using Scenekit UIViewRepresentable (code block 1 bellow), we could add a tap gesture recognizer hit test like


With the new SceneView for Scenekit/SwiftUI (code block 2), we add a tap gesture recognizer?

I found this API, but its not clear how to use it...
https://developer.apple.com/documentation/scenekit/sceneview/3607839-ontapgesture



Code Block
import SwiftUI
import SceneKit
import UIKit
import QuartzCore
struct SceneView: UIViewRepresentable {
    
    func makeUIView(context: Context) -> SCNView {
        let view = SCNView(frame: .zero)
        let scene = SCNScene(named: "ship")!
        view.allowsCameraControl = true
        view.scene = scene
        // add a tap gesture recognizer
       let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
       view.addGestureRecognizer(tapGesture)
        return view
    }
  
    func handleTap(_ gestureRecognize: UIGestureRecognizer) {
        // retrieve the SCNView
       let view = SCNView(frame: .zero)
        // check what nodes are tapped
        let p = gestureRecognize.location(in: view)
        let hitResults = view.hitTest(p, options: [:])
        // check that we clicked on at least one object
        if hitResults.count > 0 {
            // retrieved the first clicked object
            let result = hitResults[0]
     
            // get material for selected geometry element
            let material = result.node.geometry!.materials[(result.geometryIndex)]
            // highlight it
            SCNTransaction.begin()
           SCNTransaction.animationDuration = 0.5
            // on completion - unhighlight
            SCNTransaction.completionBlock = {
                SCNTransaction.begin()
                SCNTransaction.animationDuration = 0.5
                
                material.emission.contents = UIColor.black
                
                SCNTransaction.commit()
            }
            
            material.emission.contents = UIColor.green
            
            SCNTransaction.commit()
        }
    }
    
    func updateUIView(_ view: SCNView, context: Context) {
    }
    }
       




Code Block
import SwiftUI
import SceneKit
struct ContentView: View {
    
        var scene = SCNScene(named: "ship.scn")
        
        var cameraNode: SCNNode? {
                scene?.rootNode.childNode(withName: "camera", recursively: false)
        }
        
        var body: some View {
                SceneView(
                        scene: scene,
                        pointOfView: cameraNode,
                        options: []
                    
                )
                .allowsHitTesting(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/)
        }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }

}

Replies

Did you ever discover how to to it?
Same question here, I can't figure out how to process the tap in onTapGesture to get a SCNHitTestResult. allowsHitTesting seems to only activate something, but there's no documentation on how to proceed when it's turned on.
There are a handful of changes that would need to be made to accommodate performing hit testing in the manner you've detailed. Here are a few thoughts based on your code sample;
  • You are passing a variety of arguments (such as your scene, point of view, and options) to your SceneView UIViewRepresentable, but nowhere in your SceneView do those arguments seem to exist.

  • You are also trying to pass the scene's camera node, even though you haven't added the scene to the SCNView yet. This will not work, since it won't be able to check for a camera node until you add the scene to the SCNView.

  • A UIViewRepresentable is a struct, not a class. You won't be able to use a UITapGestureRecognizer on a struct, which means you'll need to create a Coordinator, which is a class, to be called when a user taps.

  • Perhaps most importantly, your code for handleTap() is not using the existing SCNView, but is creating a new SCNView each time the user taps. Since this new SCNView is empty, with no scenes or nodes added to it, it will never return a successful hit test result, as there is nothing to perform a hit test on.

As an aside, you mentioned using an onTapGesture modifier in your SwiftUI code. The onTapGesture modifier does much of the heavy lifting for setting up a tap gesture, in much the way you've manually instantiated, configured, and handled your tap gesture in SceneView, via your UITapGestureRecognizer. The onTapGesture in SwiftUI does all of that instantiation and configuration for you, only requiring you to provide what should actually happen when a user taps. While that would simplify your code, the issue with this is that onTapGesture does not provide you the location of the tap, which would be necessary to perform a hit test. As such, your approach of using a UITapGestureRecognizer makes the most sense.

To overcome these issues, I've tested the below code, which is running successfully and performing a successful hit test (the ship turns green when tapped, then returns to its original material accordingly).

As such, your ContentView would look more like this;

Code Block
import SwiftUI
import SceneKit
struct ContentView: View {
    var scene = SCNScene(named: "art.scnassets/ship.scn")
    var body: some View {
        SceneView(
            scene: scene!,
            options: []
        )
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}


Subsequently, your SceneView UIViewRepresentable would look more like this;


Code Block
import SwiftUI
import SceneKit
struct SceneView: UIViewRepresentable {
var scene: SCNScene
var options: [Any]
var view = SCNView()
func makeUIView(context: Context) -> SCNView {
// Instantiate the SCNView and setup the scene
view.scene = scene
view.pointOfView = scene.rootNode.childNode(withName: "camera", recursively: true)
view.allowsCameraControl = true
// Add gesture recognizer
let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.handleTap(_:)))
view.addGestureRecognizer(tapGesture)
return view
}
func updateUIView(_ view: SCNView, context: Context) {
//
}
func makeCoordinator() -> Coordinator {
Coordinator(view)
}
class Coordinator: NSObject {
private let view: SCNView
init(_ view: SCNView) {
self.view = view
super.init()
}
@objc func handleTap(_ gestureRecognize: UIGestureRecognizer) {
// check what nodes are tapped
let p = gestureRecognize.location(in: view)
let hitResults = view.hitTest(p, options: [:])
// check that we clicked on at least one object
if hitResults.count > 0 {
// retrieved the first clicked object
let result = hitResults[0]
// get material for selected geometry element
let material = result.node.geometry!.materials[(result.geometryIndex)]
// highlight it
SCNTransaction.begin()
SCNTransaction.animationDuration = 0.5
// on completion - unhighlight
SCNTransaction.completionBlock = {
SCNTransaction.begin()
SCNTransaction.animationDuration = 0.5
material.emission.contents = UIColor.black
SCNTransaction.commit()
}
material.emission.contents = UIColor.green
SCNTransaction.commit()
}
}
}
}


Also worth nothing, the SceneView component of SwiftUI can be configured for displaying a SCNScene, like so;

Code Block
SceneView(scene: scene,
options: .allowsCameraControl
)


With that said, the allowsHitTesting) modifier is not specific to SceneKit, and isn't referring to a hit test as you know it in the SceneKit framework. Your approach of using a UIViewRepresentable gives you the control you need to achieve the task you've presented through your sample code.
Has anyone yet figured out how to hit-test a tap (drag 0) using the SwiftUI SceneView (View struct) without implementing a UIViewRepresentable?

For whatever reason, the SwiftUI SceneView does not conform to the SCNSceneRenderer protocol. If it did, then it would not be necessary to make use of a UIViewRepresentable (or NSViewRepresentable for macOS) view.

I have a complete example app, for macOS, here: https://github.com/Thunor/HitTestApp