[ios] Hit Testing in iOS
안녕하세요. 오늘은 iOS에서 Event Handling에 대해 공부하면서 알게 된 내용을 정리해보려 합니다. 이전 포스팅에서는 UIControl에 대해 알아보았습니다. 오늘은 Hit Testing에 대해 기록해보도록 하겠습니다.
Hit Testing
기본적으로 아이폰 화면상에서 터치가 일어나면 iOS에서는 해당 터치 이벤트를 적절히 처리해주어야 합니다. 이렇게 사용자로부터 특정 액션이 들어왔을 때 이를 처리해주는 녀석과 어떻게 처리해주는 방법을 지정해주는 것이 UIControl
의 역할이었습니다.
Hit Testing은 간단히 설명하자면 터치 이벤트가 발생한 뷰를 찾는 행위입니다. 조금 더 설명을 덧붙이자면 터치 이벤트가 발생한 최상단 뷰를 찾는 행위입니다. 그리고 그렇게 찾은 뷰가 해당 이벤트를 처리할 수 있는 첫 번째 뷰, 즉 First Responder가 되는 것입니다.
여기서 최상단이라하면 계층의 최상단을 일컫는 것이 아닌 뷰 스택에서의 최상단을 말합니다.
이를 위해 iOS에서는 Pre-Order Depth First Traversal Algorithm을 이용합니다. 직역하자면 역순 깊이 우선 순회 알고리즘이라할 수 있습니다.
이 알고리즘은 불필요한 뷰의 탐색을 최소화함으로써 보다 효율적으로 터치 이벤트가 발생한 최상단 뷰를 찾아낼 수 있습니다. 깊이 우선을 이해하려면 먼저 깊이가 어떤 순서에 의해 결정되는지 알아야합니다. 깊을수록 뷰 스택의 최상단에 위치하는 뷰가 됩니다. 그리고 뷰들은 서로 두 가지의 관계를 가질수 있습니다.
- Parent-Child 관계
- Sibling 관계
Parent-Child 관계에 있어서 Child가 더 깊다는 것은 당연히 알 수 있습니다. Parent 뷰 위에 Child 뷰가 올라가기 때문입니다. 그렇다면 Sibling 관계에서는 깊이의 순서를 어떻게 정할까요? 바로 인덱스로 그 순서를 결정하고 가장 마지막 인덱스의 뷰가 가장 깊은 뷰가 됩니다. 즉 가장 마지막에 추가된 뷰가 가장 깊다고 볼 수 있습니다.
self.view.addSubview(viewA)
self.view.addSubview(viewB)
self.view.addSubview(viewC)
viewA
, viewB
, viewC
는 모두 self.view
의 Child 뷰로 이 셋은 Sibling 관게를 갖습니다. 위의 코드로 보면 viewC
가 가장 마지막에 추가된걸 보실 수 있고 viewC
가 깊이가 가장 깊다는 것을 알 수 있습니다. 즉 viewC
가 뷰 스택의 최상단에 위치한다는 것입니다. 만일 세 뷰가 모두 겹친다면 사용자는 viewC
만을 볼 수 있을 것입니다.
다음의 다이어그램을 살펴보면서 터치 이벤트가 발생한 뷰를 찾는 과정을 살펴보도록 하겠습니다.
viewA
, viewB
, viewC
는 Sibling 관계이고 오른쪽으로 갈 수록 깊은 뷰가 되며 이 셋 중에는 viewC
가 가장 깊은 뷰라 볼 수 있습니다. 이런 Sibling 관계에선 가장 깊은 뷰부터 검사를 진행합니다. 즉 Sibling 관계에선 가장 위에 위치한 뷰를 먼저 검사하는 것입니다. 그로인해 불필요한 밑의 뷰들의 검사는 생략할 수 있는 것입니다.
Hit-Testing을 진행할 때는 뷰 계층에 있어서 최상위 뷰가 (위의 다이어그램에선 UIWindow
부터) hitTest(_:with:)
메소드를 호출하며 검사를 진행합니다. hitTest(_:with:)
메소드는 내부적으로 point(inside:with:)
메소드를 호출합니다. 이 메소드를 이용해 현재 검사하고 있는 뷰가 터치 이벤트가 발생한 지점(Point)을 포함하는지 검사합니다. 만일 포함하지 않는다면 false
를 반환하고 해당 뷰의 subviews
들은 검사하지 않고 다음 뷰로 넘어가게 됩니다. 포함한다면 true
를 반환합니다. 이렇게 true
가 반환된다면 해당 뷰의 subviews
들을 순회하며 hitTest(_:with:)
를 호출하면서 터치가 일어난 최상단 뷰를 찾는 것입니다. 그래서 최종적으로 hitTest(_:with:)
는 터치가 일어난 최상단 뷰를 반환하게 됩니다.
hitTest(_:with:)
메소드는 기본적으로isHidden = true
,isUserInteractionEnabled = false
또는alpha
값이 0.01 미만인 뷰의 검사는 진행하지 않고 해당 뷰의subviews
또한 검사를 진행하지 않습니다.
분명 글로 이해하는데는 한계가 있습니다. 그럼 바로 위의 다이어그램을 이용해서 하나씩 살펴보도록 하겠습니다. 다음과 같이 터치 이벤트가 발생했다고 가정해보도록 하겠습니다.
- 가장 먼저 뷰 계층에 있어서 최상위인
UIWindow
가hitTest(_:with:)
메소드를 호출합니다. 그리고 내부적으로point(inside:with:)
메소드로 현재 터치 이벤트가 발생한 지점이UIWindow
의 내부인지를 판단합니다. 내부이기 때문에true
를 반환하고 그의subviews
인MainView
의 검사를 시작합니다. MainView
역시hitTest(_:with:)
메소드를 호출하여 1번과 같은 과정을 진행합니다. 역시true
가 반환되므로MainView
의subviews
들을 순회하며 검사하게 되는데 Sibling 관계의 깊이 순서에 의해viewC
를 가장 먼저 검사합니다.- 하지만 터치한 위치를 보면 해당 지점은
viewC
에 속하지 않습니다. 그렇기 때문에point(inside:with:)
메소드는false
를 반환하고hitTest(_:with:)
메소드는nil
을 반환합니다. 그로인해viewC
의subviews
들의 검사는 생략됩니다. 그리고 그 Sibling 관계에서 다음 순서인viewB
를 검사하게 됩니다. viewB
는hitTest(_:with:)
와point(inside:with:)
검사를 통과하기 때문에subviews
의 검사가 진행됩니다. 그리고 Sibling 관계의 깊이 순서에 의해viewB.2
를 먼저 검사하게 됩니다.- 하지만 3번의 과정과 마찬가지로
viewB.2
는 터치 지점을 포함하지 않기 때문에 지나치게 되고viewB.1
뷰의 검사가 진행됩니다. - 마침내
viewB.1
의 검사가 진행되고 위와 같은 과정으로viewB.1
이 터치 이벤트가 발생한 최상단 뷰로 판정됩니다.
이런 과정을 통해 터치 이벤트가 발생한 최상단 뷰를 찾아내는 것입니다. 이렇게 찾아낸 뷰는 터치 이벤트에 대해 First Responder가 되어 이벤트를 처리할 수 있는 기회가 주어집니다.
만일 터치 이벤트에 대한 핸들러가 First Responder에 해당하는 뷰에 정의되어 있지 않다면 Responder Chain을 통해 적절한 핸들러를 찾아나서게 됩니다. 이 과정은 Responder Chain에 대해 더 공부를 해보고 포스팅해보도록 하겠습니다.
그렇다면 언제 hitTest(_:with:)
메소드를 오버라이딩해볼 수 있을까요?
만일 버튼이나 슬라이더와 같은 UIControl
프로토콜을 준수하는 뷰들 위에 투명한 뷰가 존재한다고 가정해보겠습니다. 이 투명한 뷰의 역할은 여러가지가 있을 수 있습니다. 만일 그라데이션과 같은 단순히 부수적인 시각 효과를 주는 뷰라면 그 밑에 있는 버튼이나 슬라이더가 터치 이벤트에 정상적으로 동작을 해야합니다.
하지만 그들 위에 이러한 뷰가 있다면 해당 터치 이벤트는 정상적으로 버튼이나 슬라이더에 전달이 되지 않을 수 있습니다. 이런 경우 우리는 그러한 시각적인 효과를 주는 뷰의 hitTest(_:with:)
메소드를 오버라이딩함으로써 그 밑에 뷰들에 터치 이벤트를 정상적으로 전달할 수 있습니다.
다음과 같이 버튼 위에 투명한 뷰를 덮어보도록 하겠습니다.
이대로라면 아무리 버튼 위에서 터치 이벤트가 발생해도 버튼은 해당 이벤트를 받지 못합니다. 버튼에 터치 이벤트를 전달하기 위해서는 다음과 같이 hitTest(_:with:)
메소드를 오버라이딩하면 가능합니다.