I need to make a sprite node to ignore touch, so that it fall through to other nodes behind it (lower z order).
In UIKit this can be done by overriding hitTest, but there doesn't seem to be a way here in SpriteKit. Another approach in UIKit is to change the zPosition
of the CALayer. This way we can setup the view such that its zOrder is at the top (so that it renders on top), but it's touch behavior is still based on the relative order in superview's subviews
array. Basically, we can decouple the touch handling and rendering order. I dont think we can do that in SpriteKit either.
Note that setting isUserInteractionEnabled = false
doesn't work, since it still swallows the touch and prevent the nodes behind to receive touch event.
Anyone who used cocos2d before - this is basically the swallow touch
flag.
My use case is that, I have a particle effect added to the screen during game play (it's added to the top of screen). Currently the particle swallows touches on its region, and affect my game play. I want to just show the particle, but do not interfere with touches for other components on screen.
I want to thank @Luca for the 2 solutions. They are very close to what I want, and inspired me with my final solution.
Luca's first solution has a few issues:
Luca's 2nd solution addressed some of these, but also has a few issues. For example, we have to introduce a new flag isUserInteractionEnabled_2
, and also need to traverse the scene to find the top most node. Also, it requires me to change the way I write existing games (rather than simply an infra change). So I strongly prefer Luca's 1st solution, because I can completely encapsulate the logic in my infra module, so all my games benefit from it without any change in game logic.
So I improved Luca's 1st solution, and here's how I address the above 3 problems.
the exiting pointAt
API doesn't work as I explained above. Looks like tree traversal is unavoidable. So here it is:
private func dfs(
_ parent: SKNode,
_ parentFinalZ: CGFloat,
_ cur: SKNode,
_ pointWrtParent: CGPoint,
_ maxNode: inout SKNode?,
_ maxFinalZ: inout CGFloat?)
{
// If invisible, no point traversing, even if the child is visible, it's still not shown.
guard cur.visible else { return }
let curFinalZ = parentFinalZ + cur.z
let pointWrtCur = cur.convert(pointWrtParent, from: parent)
for child in cur.children.reversed() {
dfs(cur, curFinalZ, child, pointWrtCur, &maxNode, &maxFinalZ)
}
// It's possible that parent interaction is not enabled, but the child is. So we want to check after traversing children.
guard cur.isUserInteractionEnabled else { return }
// It's possible that the children's bound is outside parent's bound
guard cur.contains(pointWrtParent) else { return }
// ignore SKEmitter
if cur is SKEmitterNode { return }
// if curFinalZ == maxFinalZ, do not update maxNode, because we search the children first, which take precedence over parent, if they have the same z
if maxFinalZ == nil || curFinalZ > maxFinalZ! {
maxNode = cur
maxFinalZ = curFinalZ
}
}
fileprivate extension SKScene {
private func topNodeOrSelf(_ touches: Set<UITouch>) -> SKNode {
let p = touches.first!.location(in: self)
var maxNode: SKNode? = nil
var maxZ: CGFloat? = nil
for child in children.reversed() {
dfs(self, z, child, p, &maxNode, &maxZ)
}
return maxNode ?? self
}
}
This code does a DFS traversal to find the node with max global Z (accumulating all the z's in the path).
I address this problem by keeping the particle (but making it invisible), until it's done its job to relay touch ended/cancelled events.
Here's how I do it:
open override func removeFromParent() {
if !hasDanglingTouchBegan {
// Either not touched, or touch ended/canceled. Directly remove from parent.
// Need to reset alpha and hasBegan, since we re-cycle emitter node
self.alpha = 1
self.hasDanglingTouchBegan = false
// actually remove (super call)
super.removeFromParent()
} else {
// Touch has began, but it needs to remove before touches ended/canceled
// We cannot remove directly, because it will stop relaying touch ended/canceled event to the scene
// Instead, we set it to transparent, and retry after 1 sec interval
// Don't use isHidden flag, since we use that to traverse the tree. (Though in our case isHidden would happen to work because we want to ignore emitters during traversal, but it's better to rely on the type SKEmitterNode when filtering out emitter nodes)
self.alpha = 0
run(after: 1) {
self.removeFromParent()
}
}
}
Luckily, the internal implementation of SKEmitterNode is objc (rather than swift), so that I can overwrite functions (touchesBegan
, etc) in the extension.
However, it's still better to subclass, in case we have a scenario where we do want to swallow touch. So I am keeping my another question open: SpriteKit unable to unarchive an SKEmitterNode subclass from sks file
Here's a complete implementation:
import Foundation
import SpriteKit
// For some reason, NSKeyedUnarchiver doesn't support reading an sks file into a subclass of SKEmitterNode.
// https://stackoverflow.com/questions/77587789/spritekit-unable-to-unarchive-an-skemitternode-subclass-from-sks-file
// The workaround is simply to use extension, which should be fine because we intend the same bahavior for all emitters.
// But it's still better to use a subclass if possible, in case in the future we may have emitter node that swallows touch.
extension SKEmitterNode {
static func load(fnWithoutExtension: String, in bundle: Bundle) -> SKEmitterNode? {
guard
let sksPath = bundle.path(forResource: fnWithoutExtension, ofType: "sks"),
let sksData = try? Data(contentsOf: URL(fileURLWithPath: sksPath)),
let emitter = try? NSKeyedUnarchiver.unarchivedObject(ofClass: SKEmitterNode.self, from: sksData),
let texturePath = bundle.path(forResource: fnWithoutExtension, ofType: "png"),
let textureImage = UIImage(contentsOfFile: texturePath)
else { return nil }
// We still need to set texture, because the texture file is not in main bundle
emitter.particleTexture = SKTexture(image: textureImage)
// Have to enable user interaction to receive touch
emitter.isUserInteractionEnabled = true
return emitter
}
private var hasDanglingTouchBegan: Bool {
get {
let dictionary = userData ?? [:]
return dictionary["hasDanglingTouchBegan"] as? Bool ?? false
}
set {
let dictionary = userData ?? [:] // use let since userData itself is mutable dictionary
dictionary["hasDanglingTouchBegan"] = newValue
userData = dictionary
}
}
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
hasDanglingTouchBegan = true
scene?.relayTouchesBegan(touches, with: event)
}
open override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
scene?.relayTouchesMoved(touches, with: event)
}
open override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
hasDanglingTouchBegan = false
scene?.relayTouchesEnded(touches, with: event)
}
open override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
hasDanglingTouchBegan = false
scene?.relayTouchesCancelled(touches, with: event)
}
open override func removeFromParent() {
if !hasDanglingTouchBegan {
// Either not touched, or touch ended/canceled. Directly remove from parent.
// Need to reset alpha and hasBegan, since we re-cycle emitter node
self.alpha = 1
self.hasDanglingTouchBegan = false
// actually remove (super call)
super.removeFromParent()
} else {
// Touch has began, but it needs to remove before touches ended/canceled
// We cannot remove directly, because it will stop relaying touch ended/canceled event to the scene
// Instead, we set it to transparent, and retry after 1 sec interval
// Don't use isHidden flag, since we use that to traverse the tree. (Though in our case isHidden would happen to work because we want to ignore emitters during traversal, but it's better to rely on the type SKEmitterNode when filtering out emitter nodes)
self.alpha = 0
run(after: 1) {
self.removeFromParent()
}
}
}
}
private func dfs(
_ parent: SKNode,
_ parentFinalZ: CGFloat,
_ cur: SKNode,
_ pointWrtParent: CGPoint,
_ maxNode: inout SKNode?,
_ maxFinalZ: inout CGFloat?)
{
// If invisible, no point traversing, even if the child is visible, it's still not shown.
guard cur.visible else { return }
let curFinalZ = parentFinalZ + cur.z
let pointWrtCur = cur.convert(pointWrtParent, from: parent)
for child in cur.children.reversed() {
dfs(cur, curFinalZ, child, pointWrtCur, &maxNode, &maxFinalZ)
}
// It's possible that parent interaction is not enabled, but the child is. So we want to check after traversing children.
guard cur.isUserInteractionEnabled else { return }
// It's possible that the children's bound is outside parent's bound
guard cur.contains(pointWrtParent) else { return }
// ignore SKEmitter
if cur is SKEmitterNode { return }
// if curFinalZ == maxFinalZ, do not update maxNode, because we search the children first, which take precedence over parent, if they have the same z
if maxFinalZ == nil || curFinalZ > maxFinalZ! {
maxNode = cur
maxFinalZ = curFinalZ
}
}
fileprivate extension SKScene {
private func topNodeOrSelf(_ touches: Set<UITouch>) -> SKNode {
let p = touches.first!.location(in: self)
var maxNode: SKNode? = nil
var maxZ: CGFloat? = nil
for child in children.reversed() {
dfs(self, z, child, p, &maxNode, &maxZ)
}
return maxNode ?? self
}
func relayTouchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
topNodeOrSelf(touches).touchesBegan(touches, with: event)
}
func relayTouchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
topNodeOrSelf(touches).touchesMoved(touches, with: event)
}
func relayTouchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
topNodeOrSelf(touches).touchesEnded(touches, with: event)
}
func relayTouchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
topNodeOrSelf(touches).touchesCancelled(touches, with: event)
}
}
Note that I have a few helpers in other files, for example, visible
is simply opposite of isHidden
, and z
is simply zPosition