HitTests in SceneView
Deprecation of Storyboards for Apple Watch and usefull SceneKit Workarounds
With watchOS 10, Apple has deprecated storyboards for Apple Watch apps, requiring developers to transition fully to SwiftUI. This change forces a significant refactoring of my game !!!LINK!!! on this platform, as I relied on storyboard-based UI elements. Additionally, integrating SceneKit in SwiftUI introduces further challenges, as SCNView
is not available on watchOS and WKInterfaceSCNScene
, which I’ve used till now, is deprecated. Apple recommends to use SceneView
. However, SceneView
does not conform to SCNSceneRenderer, meaning essential features like renderer(_:updateAtTime:)
as game loop and hitTest(_:options:)
to detect elements in the scene, which are selected (hit) by tap gestures are unavailable.
In this blog post, I present a solution that overcomes these limitations.
The Solution
To resolve this, we need to:
- Create a
GameController
class implementingSCNSceneRendererDelegate
- During creation of the
SceneView
in the SwiftUI View provide this class as delegate - The Delegate method
(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval)
in GameConroleer is called when theSceneView
renders the scene. - Capture the
SCNSceneRenderer
instance insiderenderer
and store in a local variablesceneRenderer
. - Now you can call
hitTest(_:options:)
as part ofsceneRenderer
Big Thank You to this stackoverflow question which puts me in the right direction.
Here’s a sample SwiftUI implementation:
import SwiftUI
import SceneKit
struct ContentView: View {
var gameController = GameController()
@State private var crownRotation: Double = 0 // Tracks Digital Crown input
@FocusState private var isFocused: Bool // Needed for Digital Crown input
var body: some View {
SceneView(
scene: gameController.scene,
pointOfView: gameController.cameraNode,
options: [.allowsCameraControl],
delegate: gameController
)
.focusable()
.focused($isFocused)
.digitalCrownRotation($crownRotation, from: -360, through: 360, by: 1, sensitivity: .low)
.onChange(of: crownRotation) { oldValue, newValue in
gameController.rotateShip(by: newValue)
}
.onAppear {
isFocused = true // Focus Digital Crown
}
.gesture(
SpatialTapGesture(count: 1)
.onEnded { event in
gameController.checkShipIsHit(at: event.location)
}
)
}
}
class GameController: NSObject, SCNSceneRendererDelegate {
let scene = SCNScene(named: "art.scnassets/ship.scn")!
var sceneRenderer: SCNSceneRenderer?
var cameraNode: SCNNode? {
scene.rootNode.childNode(withName: "camera", recursively: false)
}
var shipNode: SCNNode? {
scene.rootNode.childNode(withName: "shipMesh", recursively: true)
}
// Store renderer for hit testing
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
if sceneRenderer == nil {
sceneRenderer = renderer
}
// This can be used as game loop to implement game logic
}
// Rotate the ship based on Digital Crown input
func rotateShip(by rotationFactor: Double) {
shipNode?.eulerAngles.y = Float(rotationFactor / 5.0)
}
// Perform hit test to check if the ship was tapped
func checkShipIsHit(at location: CGPoint) {
guard let renderer = sceneRenderer else { return }
let hits = renderer.hitTest(location, options: nil)
if let tappedNode = hits.first?.node, tappedNode == shipNode {
tappedNode.runAction(SCNAction.sequence([
SCNAction.scale(to: 0.1, duration: 0.1), // Quick scale up
SCNAction.scale(to: 0.2, duration: 0.1) // Back to normal
]))
}
}
}
You will see the result in the next update of my Dungeon Master clone:
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
That all for today,
Cheers Stefan