Introduce

  • hitTest(_:with:)는 UIView의 instance method로, UIWindow와 연결된 root view로부터 시작되는 view hierarchy를 깊이우선 탐색 방법으로 traverse하며 touch event를 받는 가장 앞에 나와있는 view를 찾아 반환한다.
  • 깊이우선 탐색(Reverse Pre-Order Depth-First Traversal) 알고리즘은 hierarchy에서 가장 멀리 떨어진 view부터 탐색하는 방법이다. 이 방법은 subview가 항상 superview보다 위에 그려지고 sibling 관계에 있는 subview들에서 index가 높은 subview가 index가 낮은 subview보다 위에 그려지기 때문에, hierarchy에서 가장 앞에 그려진 view(hierarchy depth가 가장 깊은 view)를 찾는데 효과적이다.

    Reference

    Hit-Testing in iOS
    iOS) hitTest

    Operation

  • Touch event가 발생하면 root view부터 hitTest(_:with:) method를 호출한다.
  • Event를 받을 조건을 충족한다면 subview들 중 가장 마지막에 추가된(index가 가장 큰) subview에 대해 hitTest(_:with:)를 호출한다. Event 받을 조건을 충족하지 못한다면 nil을 반환하여 해당 뷰의 hit testing을 건너뛴다.
    1. User interaction 허용 : userInteractionEnabled == true
    2. View가 숨겨지지 않아야 함 : hidden == false, alpha > 0.01
    3. Touch point가 view 안에 있어야함(bounds) : point(inside:with:) == true
  • 가장 마지막 subview(더 이상 subview를 갖지 않는 view)를 찾으면 그 subview를 반환한다.
  • 반환된 subview는 화면에서 가장 앞에 나와있는 view이고 touch를 가장 먼저 받게 되는 view이다.

image.png

  • 위 그림에서 root view인 UIWindow부터 traversing이 시작된다.
  • UIWindow의 subview인 MainView에서 hitTest(_:with:)가 호출된다.
  • MainView의 subview들 중 가장 마지막에 추가된(index가 가장 큰) View C부터 hitTest(_:with:)가 호출된다.
  • Touch point가 View C에는 포함되지 않으므로 View ChitTest(_:with:)nil을 반환하게 되고, 다음 sibling subview인 View BhitTest(_:with:)가 호출된다.
  • Touch point가 View B에는 포함되므로 View B의 subview들 중 가장 마지막에 추가된 View B.2부터 hitTest(_:with:)가 호출된다.
  • Touch point가 View B.2에는 포함되지 않으므로 View B.2hitTest(_:with:)nil을 반환하게 되고, 다음 sibling subview인 View B.1hitTest(_:with:)가 호출된다.
  • Touch point가 View B.1의 영역 안에 포함되고 더 이상 subView를 갖지 않으므로 hitTest(_:with:)는 자기 자신(self)인 View B.2를 반환한다.
  • hitTest(_:with:)의 재귀호출에 의해 root view까지 반환된 View B.2가 전달되고 touch event는 View B.2에 전달된다.
func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  guard self.isUserInteractionEnabled || self.isHidden || self.alpha > 0.01 else {
    return nil
  }

  if (self.point(inside: point, with: event) {
    for subView in self.subviews.reversed() 
      let convertedPoint = subView.convert(point, from: self)
      if let hitView = subView.hitTest(convertedPoint, with: event) {
        return hitView
      } else {
        continue
      }
    }
    return self
  }
  return nil
}

Override

  • hitTest(_:with:)를 override해서 어떤 view 밑에 가려진 view가 touch event를 받도록 만드는 등 원하는 view가 touch event를 받도록 만들 수 있다.
  • Touch event를 받지 않기를 원하는 view에서 hitTest(_:with:)nil을 반환하도록 override한다.

Passing touch event

  • hitTest(_:with:)를 override하여 원하는 view가 touch event를 받도록 제어할 수 있다.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  // View가 특정 조건에서만 event를 받도록 함
  if (condition) {
    return super.hitTest(point, with: event)
  } else {
    return nil
  }
}
  • 특정 view가 subview들을 제외한 영역에서는 touch event를 무시해야 하는 상황이 발생할 수 있다.
  • 현재 view를 기준으로 하는 hierarchy에서 hitTest(_:with:)를 진행하고, 그 결과가 현재 view라면 event를 통과시킬 수 있다.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  let hitView = super.hitTest(point, with: event)
  if (hitView == self) {
    hitView = nil
  }
  return hitView
}

Extend touch area

  • UIView의 touch 영역은 bounds 속성의 size로 결정된다.
  • 크기는 그대로 두면서 touch 영역만 확장하려고 할 때, 더 넓은 영역에서도 hitTest(_:with:)가 view를 반환하도록 할 수 있다.
  • 이 경우, 확장된 touch 영역은 parent view의 영역(bounds)에 포함되어 있어야 한다. 그렇지 않다면, parent view에서도 확장된 touch 영역을 포함하기 위해 함께 override해야 한다.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  var hitView: UIView? = nil

  let extendedArea = self.bounds.insetBy(dx: -15, dy: -15)
  if (extendedArea.contains(point)) {
    hitView = super.hitTest(point, with: event)
  }

  return hitView
}