본문 바로가기
IOS/실험

hitTest

by Joahnee 2024. 1. 12.

문제 정의

버튼을 가리고 있는 View가 존재할 때, 해당 View를 무시하고 버튼을 누를 수 있는건지에 대한 의문으로 시작되었습니다.

 

문제 해결과정

isUserInteractionEnabled를 사용하면 한번에 해결이 되지만 좀 더 원초적인(?) 이유가 궁금했습니다.

 

서칭해보니, hitTest라는 키워드를 공부하면 되겠다 싶었습니다.

 

우선, 공식문서의 힘을 빌려 정의부터 살펴볼까요.

 

 

직역 : 현재 뷰를 포함하여, 현재 뷰의 뷰 계층구조에서 구체적인 point를 포함하는 가장 먼 자손(?)을 리턴한다.

 

아래 그림을 보면서 설명드리겠습니다.

 

RedView가 표시되어 있는 부분을 눌렀을 때, 가장 먼 자손은 누구일까요?

 

바로 RedView입니다.

 

뷰 계층구조에서 위로 올라갈 수록 부모, 아래로 내려갈 수록 자식이 되거든요.

 

한마디로, 사용자에게 가장 가까운 뷰를 리턴해준다는 말 입니다.

 

정의 된 메소드는 이렇게 생겼네요.

 

파라미터를 하나씩 살펴보겠습니다.

 

point : A point in the view's local coordinate system. (bounds를 의미)

 

event : The event that warrants a call to this method. If you’re calling this method from outside your event-handling code, you can specify nil.

(이 메소드를 불러주는것을 보장하는 이벤트. 이 메소드를 이벤트 핸들링하는곳의 밖에서 사용하면 nil을 지정할 수 있다고합니다.)

 

return 값은 UIView를 리턴해주네요.

 

위에서 말씀드린 가장 먼 자손을 리턴해준다고 합니다.

 

하지만, 지정된 지점이 현재 뷰의 뷰 계층 외부에 완전히 위치한 경우에는 nil을 반환한다고 하네요.

 

무슨 말인지 모르겠지만 차차 알아갈 수 있을 것 같습니다.

 

Discussion에는 아래와 같이 적혀있습니다.

 

"이 메서드는 각 하위 뷰의 point(inside:with:) 메서드를 호출하여 어떤 하위 뷰에 터치 이벤트를 보낼지를 결정하면서 뷰 계층을 횡단합니다. 만약 point(inside:with:)가 true를 반환하면, 이 메서드는 계속해서 하위 뷰 계층을 횡단하여 지정된 지점을 포함하는 가장 앞쪽의 뷰를 찾습니다. 만약 뷰가 지점을 포함하지 않으면, 이 메서드는 해당 뷰 계층의 가지를 무시합니다. 이 메서드를 직접 호출해야 하는 경우는 거의 없지만, 하위 뷰에서 터치 이벤트를 숨기기 위해 이를 재정의할 수 있습니다.

이 메서드는 숨겨진 뷰, 비활성화된 사용자 상호 작용이 비활성화된 뷰, 또는 알파 레벨이 0.01 미만인 뷰를 무시합니다. 이 메서드는 뷰의 콘텐츠를 고려하지 않고 히트를 결정하므로, 지정된 지점이 해당 뷰의 콘텐츠의 투명한 부분에 있더라도 뷰를 반환할 수 있습니다.

이 메서드는 뷰의 경계 외부에 있는 지점을 히트로 보고하지 않으며, 실제로는 뷰의 하위 뷰 중 하나의 경계 안에 위치한 경우에도 히트로 보고하지 않습니다. 이 상황은 뷰의 clipsToBounds 속성이 false이고 영향을 받는 하위 뷰가 뷰의 경계를 벗어나는 경우에 발생할 수 있습니다."

 

해당 설명에 가장 적합한 swift 코드는 아래와 같습니다.

 

UIView에서 기본적으로 hitTest를 override 하지 않았을 때, 아래와 같은 기본 동작을 한다고 이해하시면 될 것 같습니다.

func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
      if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
          return nil
      }
      if self.point(inside: point, with: event) {
          for subview in subviews.reversed() {
              let convertedPoint = subview.convert(point, from: self)
              if let hitView = subview.hitTest(convertedPoint, with: event) {
                  return hitView
              }
          }
          return self
      }
      return nil
  }

 

어떻게 동작하는건가? 🤔

 

Point범위에 해당하는 사용자와 가장 가까운(가장 먼 자손)을 찾기 위해서 reverse pre-order DFS 방식을 사용한다고 해요.

 

원래 전위순회가 현재노드 -> 왼쪽 서브트리 -> 오른쪽 서브트리 인데, 이를 거꾸로 오른쪽 서브트리 -> 왼쪽 서브트리로 탐색한다는겁니다.

 

애플의 예제를 보시면 어떻게 순회하는지 그림으로 이해할 수 있습니다.

 

결국 hitTest는 View B.1을 리턴하게 됩니다.

Apple

 

예시를 통해 좀 더 확실히 이해해보겠습니다.

예제의 View Hierarchy

hitTest를 따로 사용하지 않았을 때

 

 

터치한 곳의 이벤트가 잘 들어오는 것을 볼 수 있습니다.

 

RedView가 클릭됐을 때도 출력이 잘되네요 😌

 

hitTest를 override해서 이벤트가 RedView까지 가지 못하게 BrownView에서 가로채보겠습니다.

 

가로채는데 성공했는데요.

 

WhiteView를 클릭했을 때도 BrownView가 출력되는 이유가 뭘까요..?

 

아까 말씀드린 코드를 다시한번 보겠습니다.

func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
      if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
          return nil
      }
      if self.point(inside: point, with: event) {
          for subview in subviews.reversed() {
              let convertedPoint = subview.convert(point, from: self)
              if let hitView = subview.hitTest(convertedPoint, with: event) {
                  return hitView
              }
          }
          return self
      }
      return nil
  }

 

point()함수를 충족하면 우선 Subviews를 확인하게 되는데요.

 

(WhiteView에서는 당연히 WhiteView의 영역을 클릭했기 때문에 for문은 실행이 됩니다.)

(어쨋든 자식뷰들을 무조건 다 확인해 볼것이라는 겁니다.)

 

확인한 subviews에서 적합하게 범위에 알맞는(point함수가 true를 리턴하는) subview가 없으면 해당 subView의 hitTest()에서는 point가 맞지않아서 nil을 리턴하게 되고, 그럼 WhiteView의 hitTest에서는 hitView가 아닌 self를 리턴해줘야하는데, 제가 BrownView에서 self를 리턴해줬기 때문에 WhiteView의 hitTest()에서도 BrownView를 return하게 되고 결국, WhiteView를 터치했지만, BrownView가 터치되는 마법같은 상황이 펼쳐지게 된것입니다.

 

이번에는 WhiteView에서 self를 리턴해보겠습니다.

 

 

 

ViewController의 view를 클릭했을 때 WhiteView가 나타나는 이유는 위에서 실험한것과 동일하게 View에서 subview의 hitTest(WhiteView-hitTest)에 들어갔을 때, WhiteView가 리턴되서 나타나는건데요.

 

BlueView를 클릭했을 때도 "WhiteView Touched"가 출력되는 이유는 reverse pre-order DFS를 사용하기 때문이에요.

 

퍼즐이 하나씩 맞춰지는 느낌입니다 ㅎㅎ

 

그럼 BlueView와 WhiteView의 Hierarchy에서 위치가 바뀐다면 어느곳을 누르던 BlueView가 touch 되었다고 나타날것입니다.

 

 

예상대로 그렇게 나타나네요 ㅎㅎ

 

저는 단순하게 return self로 예제를 보여드렸는데요.

 

더 다양하게 응용하고 싶으시다면, 아래처럼 사용해도 좋을 것 같은데요.

 

condition에는 특정 point가 클릭됐을 때? 같은걸 넣어주면 좋을 것 같아요.

  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  	if condition { // 특정 조건이 만족했을 경우,
    	return self // 현재 view에서 event 처리
    }
    return nil // 특정 조건이 만족하지 못했을 경우, 이벤트를 다른 View가 처리하도록 계속 탐색시키거나 처리하지 않음.
  }

 

 

여기까지 HitTest를 알아봤는데요.

 

저도 공부하면서 필요할때 들여다보기 위해 기록해봤어요. (나름 이해하기 힘들었던 부분인지라 😅)

 

잘못된 점이 있다면 댓글 부탁드립니다!

댓글