Responder Chain 그리고 Hit Test

마지막 글을 작성하고 1년도 넘었지만 지금이라도 이전 내용을 다시 글로 정리해보려 한다. ㅜ.ㅜ


Responder Chain 그리고 Hit Test

Point, Hit Test

우선 화면을 터치했을 때 어떤 view(button 등등 포함)를 터치한건지 어떻게 알아낼까? 화면을 터치하면 OS 즉 iOS에서 이 이벤트를 UIApplication으로 전달합니다. 그리고 그 밑으로 UIWindow -> root view controller -> view … 이러한 순서로 전달하게 됩니다. 이 과정에 숨어있는 동작이 UIView에 있는 point, hit test 메소드 입니다.

Hit Test!

HitTest 공식 문서에 나와있는 내용을 요약하면 특정 point(즉 쉽게 생각하면 터치가 발생한 좌표)를 포함하는 가장 하위 receiver를 return한다고 합니다. 일반적인 상황을 생각하면 우리가 직접적으로 터치한다고 생각하는 view를 return하겠네요. 그리고 이 hitTest에서 point함수 를 호출해서 그 point가 특정 view의 좌표 내부에 존재하는지 체크한다고 합니다. 내부에 존재하면 true를 return하고 아니면 false를 return!

Point

point point는 위에서 말했다싶이 특정 인자로 주어지는 point가 view 내부에 존재하면 true를 return하고 아니면 false를 return하는 함수입니다.

Responder Chain

위에 내용들을 통해서 터치가 HitTest, Point함수를 이용해서 iOS -> UIApplication -> UIWindow -> ViewController와 같은 경로로 전해지는 것을 알았습니다. 그렇다면 이 글의 제목으로 있는 Responder Chain은 뭘까요?

우선 UIKit에는 UIResponder라는 event에 대응하기 위한 객체가 있습니다. 그리고 우리가 흔히 사용하는 UIView, UIViewController, UIWindow, UIApplication모두 UIResponder를 상속한 클래스입니다. 이 UIResponder가 처리하는 여러 이벤트 중 우리가 계속 살펴본 터치 이벤트가 있습니다. 그리고 앞에 살펴본 Hit Test를 통해서 터치가 적절한 객체로 전달되게 됩니다. Sample 이 화면을 예로들면 노란색 영역을 터치했을 때는 노란색 객체에 이벤트가 전달될 것이고, 초록색 영역을 터치하면 초록색 객체에 이벤트가 전달될 것입니다. 그런데 만약 초록색 영역을 터치했는데 초록색 객체가 이 이벤트를 처리하지 않는다면 어떻게 될까요?

이것에 관한 내용이 Responder Chain입니다. 이벤트를 받았을 때 그 이벤트를 처리하지 않는다면 초록색보다 상위 객체에 해당하는 노란색 객체에 이벤트가 전달되고 노란색 객체도 처리하지 않는다면 빨간색 이렇게 계속해서 전달되게 됩니다. 그리고 최종적으로 UIApplication -> UIApplicationDelegate도 처리하지 않으면 소멸됩니다. ResponderChain

Gesture Recognizer

이 글을 작성하게 된 이유가 이전 글에서 말했던 문제 때문이었습니다. 우선 결론만 말하면 Gesture Recognizer는 Responder Chain에 속하지 않습니다. 예시로 보여드리겠습니다.

Sample 이 화면에서 각각의 영역은 터치가 시작될 때, 끝날 때, 취소될 때 print를 하도록 override해놨습니다.

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    print("\(self.title ?? "") touches began")
    
    super.touchesBegan(touches, with: event)
  }
  
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    print("\(self.title ?? "") touches ended")
    
    super.touchesEnded(touches, with: event)
  }
  
  override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    print("\(self.title ?? "") touches cancelled")
    
    super.touchesCancelled(touches, with: event)
  }

그리고 빨간색 영역에만 tap gesture recognizer를 추가했습니다. 이 상태에서 초록색 영역을 tap하면 우리가 생각한 결과는 위에서 봤던 Responder Chain에 따라서 이러한 결과를 예상하겠죠?

green touches began
yellow touches began
red touches began
red tap!
green touches ended
yellow touches ended
red touches ended

하지만 실제 결과를 보면

green touches began
yellow touches began
red touches began
red tap!
green touches cancelled
yellow touches cancelled
red touches cancelled

이전 글의 원인은 바로 이녀석 때문이었습니다. gesture recognizer가 touch를 모두 cancel시켜버렸기 때문에 발생한 문제였습니다! 애플에서 말하길.. window는 hit-testted된 뷰에 event를 전달하기 전에 먼저 gesture recognizer에게 전달합니다. gesture recognizer와 맞지 않는 조건이라면 뷰에 온전하게 event가 전달되지만 맞는 조건이라면 gesture recognizer에게 먼저 전달되게 되고 뷰에 남아있는 모든 터치들을 취소시켜버립니다. 취소시키지 않으려면 간단합니다. gesture recognizer에 있는 cancelsTouchesInView를 false로 하면 됩니다…

처음 이 문제를 찾았을 때 많이 당황했었는데 그 덕분에 많은 사실들을 알아가네요..

source code: github