[iOS] Hit-Test

도움이 되는 선행 지식

Main Event Loop

Event

Responder Chain

참고

Hit-Testing in iOS

개요

Hit-Test는 터치된 곳(Point)이 화면에 그려지는 그래픽 객체 (UIView 같은)와 교차(관통)하는지를 결정하는 프로세스다

iOS는 Hit-Test를 사용해 이벤트를 받아야하는 가장 앞쪽의 UIView를 결정한다

Hit-Test는 역방향 깊이 우선탐색 알고리즘을 사용해 View 계층을 탐색하여 가장 앞쪽의 UIView를 찾아 낸다.

Hit-Test가 실행되는 시점

Hit-Test가 동작하는 법에 대해 알아보기에 앞서 어느 시점에 Hit-Test가 실행되는지를 보자

아래 그림은 화면에 손가락을 터치하는 시점부터 떼기까지의 큰 흐름을 보여준다.

hitTestTime.png

그림을 보면 알 수 있듯이 UITouch 객체가 포함된 UIEvent 객체를 View 혹은 gesture recognizer가 수신받기 이전, 화면에 손가락을 터치할때마다 Hit-Test가 실행되어 진다.

(당연한 게 아닐까? 이벤트 처리는 responder 객체가 처리를 해야하고, 이 때 제일 처음 건네 받는게 firstResponder객체 인데 이 firstResponder를 결정하기 위해 hitTest를 사용하는 것이니까?)

알 수 없는 이유로 Hit-Test는 연속으로 여러번 실행되나 결정된 Hit-Test View는 동일하게 유지가 된다고 함

Hit-Test가 완료되고 터치된 Point에 위치한 제일 앞의 View가 결정되면 이 View는 터치 이벤트를 나타내는 UITouch 객체와 연결되고 연결된 UITouch 객체의 모든 touch event 시퀀스(began, moved, ended, or canceled)를 받을 수 있다.

위 그림에서 touch.view = view 가 이 지점인 것 같음

UITouch객체가 view와 연결되어지면 연결된 view 밖으로 나가더라도 계속 연결되어 짐 [Event handling guide for iOS, iOS developer library]

Hit-Test의 과정

앞서 언급하였듯이 Hit-Test는 역방향 깊이 우선 탐색 알고리즘을 사용함

이 순회를 사용하여 터치 포인트가 관통하는 가장 깊은(Depth) 첫 번째 하위뷰를 발견하면 탐색을 중지함

이 알고리즘 자체가 Tree 순회 방식이기 때문에 깊은이라는 용어를 사용한 것 같음 앞에서는 가장 앞에 라고 되어있는데 깊은? 이라니? 라고 혼돈이 왔지만, 뒤에 그림을 보면 이해가되겠지만 미리 말하자면 이 Tree에서 역방향 깊이 우선 탐색 알고리즘에서 찾은 가장 깊은 하위뷰가 곧 가장 앞의 View임

이것이 가능한 이유는 View가 렌더링될 때 superView 보다는 subView가 앞에,

subView에서는 형제들 중, 가장 나중에 추가된 View가(subViews의 가장 마지막 인덱스) 앞에 보여지기 때문

다음 그림은 화면에 보여지는 UI와 그 UI에 대한 트리 계층을 보여준다.

hitTestLogic.png

UIWindow의 subView는 MainView

MainView의 subView는 각각 View A,B,C가 있음

superView보다는 subView가 앞에 렌더링되고, 각 subView들에서는 가장 나중에 추가된 View가 앞에 렌더링되어짐.

즉 MainView보다 View A,B,C가 앞에 렌더링 되어지고

A보단 B가, B보단 C가 앞에 렌더링 되어진다.

트리 구조로 보면 좌측으로 갈수록 인덱스가 작고(먼저 추가됨), 우측으로 갈수록 인덱스가 큼(나중에 추가됨, 제일 위에 렌더링됨)

즉, 왼쪽에서 오른쪽으로 subView의 순서가 반영되어 진다고 이해하면 된다.

UI를 보면 View A.2와 View B.1이 서로 겹친다.

이 때, View B가 View A보다 subView에서 인덱스가 높기 때문에, View B가 View A의 위에 렌더링 되어진다.

따라서 B.1과 A.2가 겹치는 부분에 터치가 이루어지면 HitTest를 통해 B.1이 반환되어 진다.

역방향 깊이 우선 탐색을 적용하면 아래와 같이 순회를 하게 됨

hitTestProcess.png

HitTest의 구현

그렇다면 구현은 어떻게 되어 있을까?

먼저 로직을 살펴보자

  1. View가 터치를 수신하는지를 확인 터치를 수신하는지 확인하는 것에는 조건이 있다
    • Hidden == NO
    • userInteractionEnabled == YES
    • alpha > 0.01
    • pointInside: withEvent: 반환 값이 == YES pointInside: withEvent: 는 터치된 지점이 View의 bound안에 포함되는지 여부를 확인하는 메소드이다. 포함된다면 true, 포함되지 않으면 false를 반환함
  2. 터치를 수신하지 않는다면 하위 뷰의 계층구조를 탐색하지 않고 nil을 반환
  3. 터치를 수신한다면 각 하위뷰에서 (물론 역방향 깊이 우선탐색 알고리즘을 사용한 순서) hitTest 메소드를 실행하고 하위뷰 중에서 nil이 아닌 첫 View가 터치 포인트의 맨앞 View이고 그 View가 반환되어진다. 만약 하위뷰가 없거나, 모든 하위뷰가 nil이라면 자신이 맨 앞에 존재하는 View이기 때문에 자신을 반환하게 된다.

이를 코드로 구현하면 아래와 같을 것이다.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
		// 터치를 수신하는지를 확인
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil; // 수신하지 않는 다면 nil 
    }
// 내 bounds에 터치 포인트가 관통하는지 여부
    if ([self pointInside:point withEvent:event]) { 
				// 역방향 깊이 우선 탐색 순으로 순회
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) { // 하위 뷰가 존재하면 하위뷰를 반환
                return hitTestView;
            }
        }// 하위뷰가 없거나 하위뷰들이 모두 nil이면 자신을 반환
        return self;
    }// 터치를 수신하지 않으면 nil 반환
    return nil;
}

적용

testlayout.png

이와 같은 View를 구성하고 UIView의 hitTest(_: with:)를 오버라이드 하여 아래와 같은 코드를 작성

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
	let result = super.hitTest(point, with: event) as? HitTestView
	if let name = result?.viewName {
	    print("\(viewName): hitTest Result : \(name)")
	}else {
      print("\(viewName): hitTest Result : nil")
  }
	return result
}

여기서 View D를 터치하면 탐색알고리즘으로 인해

E → D → C → A 순으로 로그가 찍히고 result.viewName은 D가 될 것이다.

실제 로그는 다음과 같다

View E: hitTest Result : nil
View D: hitTest Result : View D
View C: hitTest Result : View D
View A: hitTest Result : View D
View E: hitTest Result : nil
View D: hitTest Result : View D
View C: hitTest Result : View D
View A: hitTest Result : View D

왜 2번씩 호출 되는지…?

위 도입부에서 알 수 없는 이유로 Hit-Test는 연속으로 여러번 실행되나 결정된 Hit-Test View는 동일하게 유지가 된다고 함 이라고 언급된걸 보면 알수 없는 이유인가보다..?

2번씩 호출되는 의외성이 있지만 어쨋든 로그 찍히는 순서는 예상과 같고, 터치 포인트가 E와 충돌하지 않으니

정상적으로 nil이 출력되었다.

만약 D를 터치하였을 때, D를 무시하고 View C가 이벤트를 받도록 하려면 어떻게 해야할까?

result가 View D일 때 nil을 반환하면 View C의 자식들이 모두 nil을 반환하니 View C는

결과로 자신을 반환할 것이다.

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
	let result = super.hitTest(point, with: event) as? HitTestView
	print("\(viewName): hitTest Result : \(result?.viewName)")
	if let name = result?.viewName,
	   name == "View D" { return nil }
	return result
}

똑같이 D를 터치 한다면 최종적으로 반환되는건 View C일 것이다

View E: hitTest Result : nil
View D: hitTest Result : Optional("View D")
View C: hitTest Result : Optional("View C")
View A: hitTest Result : Optional("View C")
View E: hitTest Result : nil
View D: hitTest Result : Optional("View D")
View C: hitTest Result : Optional("View C")
View A: hitTest Result : Optional("View C")

최상위 View인 A에서 보면 View C가 hitTest의 결과로 반환된 것을 확인할 수 있다.

댓글남기기