Hit Testing and Event Propogation in SpriteKit

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)
	}
}	
Example animated gif showing a red square. It spins 90º twice over three seconds.

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)
Screenshot of a red square offset from the centre towards the bottom right partially obscured by a blue square offset to the top right

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.

Animated gif showing a red square offset from the centre towards the bottom right partially obscured by a blue square offset to the top right. The red square spins 90º once when clicked in the area not obscured by the blue square, but doesn't spin when clicked in the area where it is obscured by the blue square.

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:

Animated gif showing a red square offset from the centre towards the bottom right partially obscured by a blue square offset to the top right. The red square spins 90º once when clicked in the area not obscured by the blue square, and once again when clicked in the area where it is obscured by the blue square.

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.