いものやま。

雑多な知識の寄せ集め

iOS8とiOS9でSpriteKitのタッチ判定の挙動に違いがあった話。

BirdHeadのUIを作っていたら、ノードをタッチをしてもタッチの通知が来ないということがあった。
しかも、iOS8で動かしたときだけ。(iOS9だと大丈夫)

少し実験してみると、iOS8とiOS9でSpriteKitのタッチ判定の挙動に違いがあり、それが原因だった。

今日はそのことについて。

実験用のクラス

実験を行うために、次のようなSKShapeNodeを継承したTouchShapeNodeというクラスを用意した。

import SpriteKit

class TouchShapeNode: SKShapeNode {
  private var touchCount: Int
  
  init(name: String, size: CGSize) {
    self.touchCount = 0
    
    super.init()
    
    self.name = name
    
    let rect = CGRect(origin: CGPoint.zero, size: size)
    var move = CGAffineTransformMakeTranslation(-size.width/2.0, -size.height/2.0)
    self.path = CGPathCreateWithRect(rect, &move)
    
    self.userInteractionEnabled = true
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    self.touchCount += 1
    print("[\(self.name!)] \(self.touchCount)")
  }
}

SKShapeNode(rectOfSize: CGSize)と同様のノードが作られ、ただ、タッチした場合にはデバッグ出力がされるようになっている。

大丈夫なケース(その1)

SKSceneを継承したGameSceneを次のようにしてみる。

import SpriteKit

class GameScene: SKScene {
  private static var touchCount: Int = 0
  
  override func didMoveToView(view: SKView) {
    let background = SKShapeNode(rectOfSize: self.size)
    background.fillColor = SKColor.whiteColor()
    background.lineWidth = 0.0
    background.position = CGPoint(x: self.size.width/2.0, y: self.size.height/2.0)
    self.addChild(background)
    
    let touch1Size = CGSize(width: self.size.width/2.0, height: self.size.height/2.0)
    let touch1 = TouchShapeNode(name: "touch1", size: touch1Size)
    touch1.fillColor = SKColor.redColor()
    touch1.lineWidth = 0.0
    background.addChild(touch1)
    
    let touch2Size = CGSize(width: self.size.width/4.0, height: self.size.height/4.0)
    let touch2 = TouchShapeNode(name: "touch2", size: touch2Size)
    touch2.fillColor = SKColor.blueColor()
    touch2.lineWidth = 0.0
    background.addChild(touch2)
  }
  
  override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    GameScene.touchCount += 1
    print("[scene] \(GameScene.touchCount)")
  }
}

これを実行すると、次のような見た目。

f:id:yamaimo0625:20151118135103p:plain:h600

赤い矩形(touch1)と青い矩形(touch2)は兄弟関係になっていて、青い矩形の方が後から追加されているので、赤い矩形の上に重なるように表示されている。

この状態で、白い部分→赤い部分→青い部分とタッチしていくと、次のようなデバッグ出力がされる。

[scene] 1
[touch1] 1
[touch2] 1

これはiOS8でもiOS9でも同じ。
ちゃんと「一番手前に見えているノード」に通知が来ていることが分かる。

大丈夫なケース(その2)

今度はGameSceneを次のようにしてみる。

import SpriteKit

class GameScene: SKScene {
  private static var touchCount: Int = 0
  
  override func didMoveToView(view: SKView) {
    let background = SKShapeNode(rectOfSize: self.size)
    background.fillColor = SKColor.whiteColor()
    background.lineWidth = 0.0
    background.position = CGPoint(x: self.size.width/2.0, y: self.size.height/2.0)
    self.addChild(background)
    
    let layer1 = SKNode()

    let touch1Size = CGSize(width: self.size.width/2.0, height: self.size.height/2.0)
    let touch1 = TouchShapeNode(name: "touch1", size: touch1Size)
    touch1.fillColor = SKColor.redColor()
    touch1.lineWidth = 0.0
    background.addChild(layer1)
    layer1.addChild(touch1)
    
    let layer2 = SKNode()

    let touch2Size = CGSize(width: self.size.width/4.0, height: self.size.height/4.0)
    let touch2 = TouchShapeNode(name: "touch2", size: touch2Size)
    touch2.fillColor = SKColor.blueColor()
    touch2.lineWidth = 0.0
    background.addChild(layer2)
    layer2.addChild(touch2)
  }
  
  override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    GameScene.touchCount += 1
    print("[scene] \(GameScene.touchCount)")
  }
}

今度は、赤い矩形と青い矩形は従兄弟関係になってる。
(レイヤー1とレイヤー2が兄弟で、赤い矩形はレイヤー1の子、青い矩形はレイヤー2の子)

見た目はさっきと同じで、タッチしたときの挙動も同じ。
何も問題は起こらない。

ダメなケース

けど、これを次のようにすると、おかしなことが起こる。

import SpriteKit

class GameScene: SKScene {
  private static var touchCount: Int = 0
  
  override func didMoveToView(view: SKView) {
    let background = SKShapeNode(rectOfSize: self.size)
    background.fillColor = SKColor.whiteColor()
    background.lineWidth = 0.0
    background.position = CGPoint(x: self.size.width/2.0, y: self.size.height/2.0)
    self.addChild(background)
    
    let layer1 = SKNode()

    let touch1Size = CGSize(width: self.size.width/2.0, height: self.size.height/2.0)
    let touch1 = TouchShapeNode(name: "touch1", size: touch1Size)
    touch1.fillColor = SKColor.redColor()
    touch1.lineWidth = 0.0
    background.addChild(layer1)
    layer1.addChild(touch1)
    
    let touch2Size = CGSize(width: self.size.width/4.0, height: self.size.height/4.0)
    let touch2 = TouchShapeNode(name: "touch2", size: touch2Size)
    touch2.fillColor = SKColor.blueColor()
    touch2.lineWidth = 0.0
    background.addChild(touch2)
  }
  
  override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    GameScene.touchCount += 1
    print("[scene] \(GameScene.touchCount)")
  }
}

赤い矩形と青い矩形は、甥-叔父の関係。
(レイヤー1と青い矩形が兄弟で、赤い矩形はレイヤー1の子)

見た目はさっきまでと同じ。

けど、白い部分→赤い部分→青い部分とタッチしていったときの挙動が、iOS8とiOS9で変わってくる。

(iOS8の場合)
[scene] 1
[touch1] 1
[touch1] 2
(iOS9の場合)
[scene] 1
[touch1] 1
[touch2] 1

なんと、iOS8だと青い矩形(touch2)をタッチしても、赤い矩形(touch1)がタッチされたと判定される!

最悪なケース

実は、赤い矩形がタッチに反応しないようにしても、同様のことが起こる。

import SpriteKit

class GameScene: SKScene {
  private static var touchCount: Int = 0
  
  override func didMoveToView(view: SKView) {
    let background = SKShapeNode(rectOfSize: self.size)
    background.fillColor = SKColor.whiteColor()
    background.lineWidth = 0.0
    background.position = CGPoint(x: self.size.width/2.0, y: self.size.height/2.0)
    self.addChild(background)
    
    let layer1 = SKNode()

    let touch1Size = CGSize(width: self.size.width/2.0, height: self.size.height/2.0)
    let touch1 = TouchShapeNode(name: "touch1", size: touch1Size)
    touch1.fillColor = SKColor.redColor()
    touch1.lineWidth = 0.0
    touch1.userInteractionEnabled = false  // これを追加
    background.addChild(layer1)
    layer1.addChild(touch1)
    
    let touch2Size = CGSize(width: self.size.width/4.0, height: self.size.height/4.0)
    let touch2 = TouchShapeNode(name: "touch2", size: touch2Size)
    touch2.fillColor = SKColor.blueColor()
    touch2.lineWidth = 0.0
    background.addChild(touch2)
  }
  
  override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    GameScene.touchCount += 1
    print("[scene] \(GameScene.touchCount)")
  }
}

この状態で、白い部分→赤い部分→青い部分とタッチすると、次のようなデバッグ出力になる。

(iOS8の場合)
[scene] 1
[scene] 2
[scene] 3
(iOS9の場合)
[scene] 1
[scene] 2
[touch2] 1

なので、次のようなコードを書いた場合、見た目は兄弟同士で、後から追加した弟のノードにタッチの通知が来そうだけど、iOS8だと通知が来ないということが起こる。

import SpriteKit

class RedRectNode: SKNode {
  init(size: CGSize) {
    super.init()
    let frame = SKShapeNode(rectOfSize: size)
    frame.fillColor = SKColor.redColor()
    frame.lineWidth = 0.0
    self.addChild(frame)
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

class GameScene: SKScene {
  private static var touchCount: Int = 0
  
  override func didMoveToView(view: SKView) {
    let background = SKShapeNode(rectOfSize: self.size)
    background.fillColor = SKColor.whiteColor()
    background.lineWidth = 0.0
    background.position = CGPoint(x: self.size.width/2.0, y: self.size.height/2.0)
    self.addChild(background)
    
    let redRectSize = CGSize(width: self.size.width/2.0, height: self.size.height/2.0)
    let redRect = RedRectNode(size: redRectSize)
    background.addChild(redRect)
    
    let touch2Size = CGSize(width: self.size.width/4.0, height: self.size.height/4.0)
    let touch2 = TouchShapeNode(name: "touch2", size: touch2Size)
    touch2.fillColor = SKColor.blueColor()
    touch2.lineWidth = 0.0
    background.addChild(touch2)
  }
  
  override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    GameScene.touchCount += 1
    print("[scene] \(GameScene.touchCount)")
  }
}

自分の場合、BirdHeadでまさにこれをやって、DeckNodeとButtonNodeをシーンでこの順に追加したんだけど、DeckNodeの子として追加したフレーム(SKShapeNode)がButtonNodeの甥にあたり、このフレームがButtonNodeと重なり合っていたので、iOS9だと通知が来るのに、iOS8では通知が来ない、という問題が起こった。
こうなると、見た目はButtonNodeが一番手前になっているのに押しても押しても反応しないということが起こり、「何でだ?」となってしまう。

解決策は?

じゃあ、このような問題を起こさないためには、どうしたらいいか?

まず、根本的なところとして、ちゃんとOSの各バージョンで、書いたコードの動作を確認するということ。
自分の場合、普段iOS9のシミュレータで動作確認してたので、一通り書き終わった後にiOS8でも試してみたらちゃんと動作しなくて焦るということが起こった。

その上で、いくつかの解決案を。

兄弟ノードが重ならないようにする

出来るのなら一番確実な方法は、これ。
そもそも兄弟ノードが重ならなければ、今回のような問題は絶対に起きない。

レイヤーを分ける

とはいえ、実際には必ずしもそう出来るとは限らない。
なので、レイヤーとなるSKShapeNodeを二つ用意して、それぞれを「タッチされないノードを配置するレイヤー」と「タッチされるノードを配置するレイヤー」とし、最低限、タッチされるノードを配置するレイヤー」で兄弟ノードが重ならないようにする。
こうすれば、注意しないといけないノードの数が減るので、少しはマシになる。

従兄弟関係になるようにする

あるいは、従兄弟関係なら大丈夫なはずなので、SKNodeやSKShapeNodeを1枚挟むようにして、従兄弟関係になるようにするという方法も考えられる。
ただ、シミュレータだと上手くいくけど、実機だと上手くいかない場合があるっぽい・・・(原因不明)

無理やり自前で処理する

これは最後の手段。 もうどうやってもダメなら、タッチ通知を拾うノードで無理やり適切なノードを探し出し、通知を行うしかない。
大変だけど。

とりあえず、このような問題があるということを認識してるだけでも、原因調査は簡単になるはず。

危険なケース

なお、上記の「レイヤーを分ける」「従兄弟関係になるようにする」というのに関して、ちょっと注意が必要。

レイヤーや間に挟むノードとしては、SKNodeで大丈夫そう(実際、シミュレータだとSKNodeで大丈夫)なんだけど、実機で試すと、SKShapeNodeでしっかりと画面全体を覆っていないとダメということがあった。

ただし、そうすると今度はまた別の危険性も。

実験をする中で次のようなコードを書いた。

import SpriteKit

class GameScene: SKScene {
  private static var touchCount: Int = 0
  
  override func didMoveToView(view: SKView) {
    let background = SKShapeNode(rectOfSize: self.size)
    background.fillColor = SKColor.whiteColor()
    background.lineWidth = 0.0
    background.position = CGPoint(x: self.size.width/2.0, y: self.size.height/2.0)
    self.addChild(background)
    
    let layer1 = SKShapeNode(rectOfSize: self.size)
    layer1.lineWidth = 0.0

    let touch1Size = CGSize(width: self.size.width/2.0, height: self.size.height/2.0)
    let touch1 = TouchShapeNode(name: "touch1", size: touch1Size)
    touch1.fillColor = SKColor.redColor()
    touch1.lineWidth = 0.0
    background.addChild(layer1)
    layer1.addChild(touch1)

    let layer2 = SKShapeNode(rectOfSize: self.size)
    layer2.lineWidth = 0.0

    let touch2Size = CGSize(width: self.size.width/4.0, height: self.size.height/4.0)
    let touch2 = TouchShapeNode(name: "touch2", size: touch2Size)
    touch2.fillColor = SKColor.blueColor()
    touch2.lineWidth = 0.0
    background.addChild(layer2)
    layer2.addChild(touch2)
  }
  
  override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    GameScene.touchCount += 1
    print("[scene] \(GameScene.touchCount)")
  }
}

見た目は変わらず。

けど、白い部分→赤い部分→青い部分とタッチすると、次のようなデバッグ出力になる。

(iOS8の場合)
[scene] 1
[touch1] 1
[touch2] 1
(iOS9の場合)
[scene] 1
[scene] 2
[touch2] 1

なんと、この場合はiOS9の方で赤い矩形のタッチ通知が来ないということが起こる。
これは、レイヤー2がレイヤー1を(兄弟関係だけど)完全に覆ってしまっているので、レイヤー1の方はチェックされないせいだと思う。
もちろん、レイヤー2を「タッチされるノードを配置するレイヤー」として、赤い矩形をレイヤー2の子とすれば問題は起きないんだけど、気をつけないと危ない・・・

今日はここまで!