The docs for SKNode say that the hit test order is the reverse of the render order (i.e. hit testing works from the topmost node down), but this isn’t strictly true.
It implies that if you have two nodes overlapping and the top one is not participating in hit testing, then the next one down will get the event. Unfortunately, this isn’t what happens.
Reduced Test Case
Say we have a red node and we want it to accept interactions. To give us a visual indication when a hit is detected, the node will spin 90º when it’s tapped.
We can do this with a subclass of SKSpriteNode
that looks like this:
class RedNode : SKSpriteNode { init() { super.init(texture: nil, color: .red, size: CGSize(width: 400, height: 400)) self.isUserInteractionEnabled = true } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func touchesEnded(_ touches: Set, with event: UIEvent?) { print("Touch received!") let action = SKAction.rotate(byAngle: .pi / 2, duration: 0.3) self.run(action) } }
In our SKScene
subclass, in the didMove(to:)
method, we create an instance of this node, offset it slightly from the centre, and set its zPosition
to 1:
class GameScene: SKScene { override func didMove(to view: SKView) { self.anchorPoint = CGPoint(x: 0.5, y: 0.5) let redNode = RedNode() redNode.position = CGPoint(x: -100, y: -100) redNode.zPosition = 1 self.addChild(redNode) } }
On top of this, we add a plain blue node and set its zPosition
to 2. By default, this node will have its isUserInteractionEnabled
property set to false
:
let blueNode = SKSpriteNode(color: .blue, size: CGSize(width: 400, height: 400)) blueNode.position = CGPoint(x: 100, y: 100) blueNode.zPosition = 2 self.addChild(blueNode)
The two z-positions of these nodes will cause the red node to be rendered first and then the blue node to be rendered second, resulting in the overlap.
According to my reading of the docs, the hit testing should be the reverse of this. When I tap on the blue node, the system should check its isUserInteractionEnabled
property.
Because it is set to false
, it is effectively transparent to hit testing and SpriteKit should then look for the next node at the same point but with a lower zPosition
and see if that node can accept the tap.
In this case, though, as the red node is a sibling of the blue node, the blue node rejects the tap but then SpriteKit ignores the red node completely and instead walks directly up the hierarchy to get to the SKScene
subclass itself.
It then gives this SKScene
subclass the event.
Sibling order does matter in SpriteKit. However, changing the order in which the nodes are added to the scene has no effect.
We can also tell SpriteKit that we don’t want sibling order to matter by setting the ignoresSiblingOrder
property on SKView
to true
but even if we add view.ignoresSiblingOrder = true
to the GameViewController’s viewDidLoad()
method, the behaviour doesn’t change.
A Possible Workaround
To work around this limitation, we can implement the touchesEnded(_:with:)
method in the SKScene
subclass to serve as a backstop in case any events fall through. We can get all the nodes that have their isUserInteractionEnabled
property set to true
, sort them by zPosition
, and forward the message to the first node in the resulting array (if there is one):
override func touchesEnded(_ touches: Set, with event: UIEvent?) { for t in touches { let nodes = self.nodes(at: t.location(in: self)).filter({ $0.isUserInteractionEnabled }).sorted(by: { $0.zPosition > $1.zPosition }) nodes.first?.touchesEnded(touches, with: event) } }
It’s not ideal, but it works:
This can be extended to the other touch events (e.g. touchesBegan(_:with:)
), as well as the equivalent mouse events on macOS.
I think this is a bug in SpriteKit and I have filed a radar about it.