Hit Testing

Hit test는 사용자 event가 발생할 때 view 계층(hierarchy)에서 subview들을 탐색(traverse)하며 event를 처리할 view를 결정하는 과정이다. Root view부터 시작하여 subview들을 역방향으로 탐색하며, event 발생 위치(point)를 포함하는 view가 있다면 그 view의 subview들을 같은 방법으로 탐색해 나간다.

‘역방향’으로 탐색하는 이유는, 화면의 가장 앞에 위치한 view부터 탐색하기 위함이다. 여러 개의 view가 겹쳐있다면 사용자가 보게 되는 맨 앞의 view가 event를 가져가야 한다. 화면의 가장 앞에 있는 view는 다음과 같은 특징을 가진다.

  1. z-order index(depth)가 가장 크다.
  2. 형제 view(sibling view)들 중에서 subview index가 가장 크다.

Tree 구조에서 이와 같은 탐색 방법을 **역방향 깊이 우선 탐색(Reverse Pre-Order Depth-First Traversal)**이라고 한다.

아래 그림은 touch event가 발생했을 때 event를 처리할 view를 결정하는 과정을 보여준다.

  1. View 계층의 root view(UIWindow)부터 시작하여 touch point를 포함하는 subview들 중 가장 멀리 떨어진 view를 탐색한다.
  2. MainView의 subview들 중 index가 가장 큰 View C부터 탐색을 시작한다.
  3. View C는 touch point를 포함하지 않으므로 건너뛰고, View B를 탐색한다.
  4. View B는 touch point를 포함하고 있으므로, 그 subview들 중 index가 가장 큰 View B.2를 탐색한다.
  5. View B.2는 touch point를 포함하지 않으므로 건너뛰고, view B.1을 탐색한다.
  6. View B.1은 touch point를 포함하고 root로부터 가장 멀리 떨어진 view이므로, View B.2에 event를 전달한다.

Hit Test 구현

Hit testing에서 event를 전달받을 후보 view가 되기 위해서는 다음 조건을 만족해야 한다.

  1. View가 화면에 보여야 한다.
  2. View가 user interaction이 가능해야 한다.
  3. 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가지 조건 중 한 가지를 만족하지 않도록 바꾸면 된다.

  1. User interaction 비활성화
    coverView.isUserInteractionEnabled = false
    
  2. View 숨김(Cover view가 반드시 보여야한다면, 이 방법은 사용하지 못할 것이다.)
    coverView.isHidden = true
    // or
    coverView.alpha = 0
    
  3. 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
    }
}

Reference