Hit Testing
Hit test는 사용자 event가 발생할 때 view 계층(hierarchy)에서 subview들을 탐색(traverse)하며 event를 처리할 view를 결정하는 과정이다. Root view부터 시작하여 subview들을 역방향으로 탐색하며, event 발생 위치(point)를 포함하는 view가 있다면 그 view의 subview들을 같은 방법으로 탐색해 나간다.
‘역방향’으로 탐색하는 이유는, 화면의 가장 앞에 위치한 view부터 탐색하기 위함이다. 여러 개의 view가 겹쳐있다면 사용자가 보게 되는 맨 앞의 view가 event를 가져가야 한다. 화면의 가장 앞에 있는 view는 다음과 같은 특징을 가진다.
- z-order index(depth)가 가장 크다.
- 형제 view(sibling view)들 중에서 subview index가 가장 크다.
Tree 구조에서 이와 같은 탐색 방법을 **역방향 깊이 우선 탐색(Reverse Pre-Order Depth-First Traversal)**이라고 한다.
아래 그림은 touch event가 발생했을 때 event를 처리할 view를 결정하는 과정을 보여준다.
- View 계층의 root view(
UIWindow
)부터 시작하여 touch point를 포함하는 subview들 중 가장 멀리 떨어진 view를 탐색한다. - MainView의 subview들 중 index가 가장 큰 View C부터 탐색을 시작한다.
- View C는 touch point를 포함하지 않으므로 건너뛰고, View B를 탐색한다.
- View B는 touch point를 포함하고 있으므로, 그 subview들 중 index가 가장 큰 View B.2를 탐색한다.
- View B.2는 touch point를 포함하지 않으므로 건너뛰고, view B.1을 탐색한다.
- View B.1은 touch point를 포함하고 root로부터 가장 멀리 떨어진 view이므로, View B.2에 event를 전달한다.
Hit Test 구현
Hit testing에서 event를 전달받을 후보 view가 되기 위해서는 다음 조건을 만족해야 한다.
- View가 화면에 보여야 한다.
- View가 user interaction이 가능해야 한다.
- View 영역이 event 발생 위치(point)를 포함해야 한다.
View 계층에서 세 가지 조건을 만족하는 view를 찾기 위해 hitTest(_:with:)
함수를 다음과 같이 구현할 수 있다.
함수가 nil
을 반환하는 것은 event를 받을 수 없는 view이므로 다음 view를 탐색하라는 의미이다.
func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 1번 조건 검사
guard !isHidden, alpha > 0.01 else { return nil }
// 2번 조건 검사
guard isUserInteractionEnabled else { return nil }
// 역방향 탐색을 위해 subviews array를 뒤집어서(reversed) 탐색
for subview in subviews.reversed() {
let point = subview.convert(point, from: self)
guard let hitView = subview.hitTest(point, with: event) else {
continue
}
// 재귀적으로 반환되는 hitView는 곧 subview를 의미한다.
// 즉, subview에서 hit testing이 성공하면 subview가 자기 자신을 반환할 것이다.
return hitView
}
// 3번 조건 검사
guard bounds.contains(point) else { return nil }
return self
}
UIView
에는 이런 방식으로 구현된 hitTest(_:with:)
method가 이미 구현되어 있다. 만약, 어떤 view가 특정 조건에서 event를 수신하지 못하게 하려면 다음과 같이 override해서 사용할 수 있다.
class SomeView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// somCondition이 true일 때만 이 view에서 hit testing을 계속한다.
guard someCondition else { return nil }
return super.hitTest(point, with: event)
}
}
활용
Hit testing에서 반환되는 view를 조작하여 특정 상황에서만 event를 받을 수 있도록 구현할 수 있다.
1. Passing through touch event
아래와 같이 5개의 UISwitch
가 파란색 view로 덮여 있다. 스위치를 터치하더라도, 덮고 있는 view가 event를 가져가므로 스위치를 on/off할 수 없다.
이 상황에서 스위치를 터치해서 on/off할 수 있게 만들려면, cover view에 event가 전달되지 않아야 하므로 event를 받기 위한 3가지 조건 중 한 가지를 만족하지 않도록 바꾸면 된다.
- User interaction 비활성화
coverView.isUserInteractionEnabled = false
- View 숨김(Cover view가 반드시 보여야한다면, 이 방법은 사용하지 못할 것이다.)
coverView.isHidden = true // or coverView.alpha = 0
hitTest(_:with:)
함수에서nil
반환class CoverView: UIView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return nil } }
만약 특정 스위치만 on/off할 수 있게 만들려면 hitTest(_:with:)
method를 override해서 nil
을 반환하는 조건을 추가로 구현해야 한다.
class CoverView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
var hitView = super.hitTest(point, with: event)
// 가운데 스위치가 있는 영역
let rect = bounds.insetBy(dx: bounds.width / 3, dy: bounds.height / 3)
// Point가 가운데 영역(rect)에 포함될 때는 nil을 반환한다.
guard rect.contains(point) else {
return hitView
}
return nil
}
}
2. Throw touch event
Hit testing을 통해 event를 받는 view는 hitTest(_:with:)
method에서 반환되는 view이다. 즉, 실제로는 event를 받기 위한 3가지 조건을 만족하지 못하는 어떤 view를 임의로 hitTest(_:with:)
에서 반환시키면 그 view도 event를 받아 처리할 수 있게 된다.
다음은 이 방법을 사용하여 크기가 작은 버튼의 터치 영역을 확장시키는 예시이다.
class CustomButton: UIButton {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Button 영역에서 상하좌우 10pt만큼 더 넓은 영역까지 touch point를 검사한다.
let contains = bounds.insetBy(dx: -10, dy: -10).contains(point)
return contains ? super.hitTest(point, with: event) : nil
}
}