[iOS] Hit testing

cskim·2019년 10월 8일
0

Hit Testing

Hit testing이란 사용자로부터 입력된 touch event가 어떤 view에서 발생했는지 알아내고 그 view에 touch event를 전달하는 과정을 말합니다. App은 view hierarchy에서 touch evnet가 발생한 위치(point)를 포함하고 있는 view들을 탐색하며 root view로부터 가장 멀리 떨어져 있는 view를 반환합니다. View hierarchy에서 rootview는 UIWindow가 보여주고 있는 UIViewController의 rootview가 됩니다. 이 view부터 subview로 등록되어 있는 view들을 탐색하며 touch event가 발생한 view를 찾습니다.

이렇게 root node에서 가장 멀리 떨어진 node부터 탐색해 나가는 방법을 역방향 깊이우선 탐색(Reverse Pre-Order Depth-First Traversal) 이라고 합니다. View hierarchy에서 subview들은 항상 superview보다 위에 그려지고 sibling 관계에 있는 subview들 중 나중에 추가된 subview가 다른 view들의 위에 그려지기 때문에, 화면에 가장 앞에 위치한 view를 찾기에 적합한 방법입니다.

참고 : https://blog.canapio.com/49

View hierarchy에서 hit testing이 진행되는 과정은 다음과 같습니다.

  1. UIWindow에서 root view인 MainView를 거쳐 다음 깊이에 있는 sibling view들 중 가장 마지막에 추가된 View C부터 검사를 시작합니다.
  2. View C는 touch point를 포함하지 않으므로 다음 sibling view인 View B를 검사합니다.
  3. View B는 touch point를 포함하므로 다음 깊이에 있는 sibling view들 중 가장 마지막에 추가된 View B.2부터 검사를 시작합니다.
  4. View B.2는 touch point를 포함하지 않으므로 다음 sibling view인 View B.1을 검사합니다.
  5. View B.1은 touch point를 포함하면서 root view로부터 가장 멀리 떨어진 view이기 때문에 event를 받을 view가 됩니다.

구현

Hit testing을 위해 UIView에는 hitTest(_:with:) method가 구현되어 있습니다. Touch event가 발생하면 가장 앞에 나타나 있는 view부터 재귀적으로 hitTest(_:with:) method를 호출하며 view들을 탐색하고, 현재 view가 touch point를 포함하고 있다면 해당 view를 반환합니다. 만약 touch point를 포함하지 않는다면 nil을 반환하여 hierarcy의 다음 view에서 hit testing을 진행합니다. 이 과정을 아래와 같은 코드로 구현해 볼 수 있습니다.

func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  // View가 사용자 입력을 받을 수 있어야 합니다.
  guard self.isUserInteractionEnabled else {
    return nil
  }
        
  // View가 화면에 나타나 있어야 합니다. 
  // View는 isHidden 속성이 false이거나 투명도가 0.01 이하로 딸어졌을 때 완전히 사라진 것으로 간주됩니다.
  guard !self.isHidden && self.alpha > 0.01 else {
    return nil
  }
        
  // View가 touch한 지점(point)을 포함하고 있어야 합니다.
  guard self.point(inside: point, with: event) else {
    return nil
  }

  // Subview들을 역박향으로 탐색하므로 subviews array의 가장 마지막 element부터 탐색합니다
  for subview in subviews.reversed() {
    let newPoint = subview.convert(point, from: self)
    if let hitView = subview.hitTest(newPoint, with: event) {
      return hitView
    }
  }
        
  return self
}

활용하기

Custom view에서 hitTest(_:with:) method를 override하면 touch event를 받을 view를 직접 결정하거나 event를 받을 조건을 직접 설정하는 등 조작이 가능합니다. 이것을 이용해서 touch event를 아래 view에 전달하거나 터치 영역을 확장시키는 등 custom이 가능합니다.

Passing Touch Event

hitTest(_:with:)를 이용하면 원하는 view가 touch event를 받도록 만들 수 있습니다. 다음과 같이 UISwitch 위에 UIView를 덮으면 switch를 터치할 수 없습니다. UISwitch보다 UIView가 view hierarchy에서 아래에 있기 때문에 덮여있는 view가 touch event를 가져가기 때문입니다.

View의 hitTest(_:with:) method를 override하여 nil을 반환하도록 하면 덮여있는 view는 hit testing 과정을 건너 뛰게 되고, 다음 subview인 switch부터 다시 hit testing을 진행하게 됩니다. 아래 코드는 가운데 switch만 event를 받을 수 있도록 view의 중심 영역에서만 event를 통과시키고 있습니다.

class CoverView: UIView {
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    // 원래 hitTest 메서드로 현재 터치된 곳에서 가장 위에 올라와 있는 view를 가져옴
    // 여기서는 cover view
    var hitView = super.hitTest(point, with: event)
        
    // 특정 영역에서만 이벤트를 받도록 함
    let inset: (x: CGFloat, y: CGFloat) = (self.bounds.width/3, self.bounds.height/3)
    let touchRect = self.bounds.insetBy(dx: inset.x, dy: inset.y)
    
    // 해당 영역에서 발생한 touch에 대해 통과시킴
    if touchRect.contains(point) {
      hitView = nil
    }
    return hitView
}

CoverView를 위와 같이 만들었을 때 가운데 switch를 터치하면 잘 동작하는 것을 확인할 수 있습니다. 바깥에 있는 네 개의 switch들은 여전히 touch event를 받지 못합니다.

Extend touch area

App에서 버튼이 너무 작아 터치하기 어렵다면 버튼의 hitTest(_:with:)를 override하여 버튼 주변에서 발생한 touch event도 버튼으로 전달하여 터치 영역을 확장시킬 수 있습니다.

View가 touch event를 받는 영역은 실제 view size로 결정됩니다. Button의 hitTest(_:with:)를 override하여 touch event가 button size보다 넓은 영역에서 발생했을 때에도 button을 반환하도록 하여 button 크기는 그대로 둔 채로 터치 영역만 확장할 수 있습니다.

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  // 상하좌우 10pt만큼 떨어진 영역 안에서 발생한 touch event까지 가져옴
  let isContained = self.bounds.insetBy(dx: -10, dy: -10).contains(point)
  return isContained ? super.hitTest(point, with: event) : nil
}

이 때, 확장된 touch 영역은 parent view의 영역(bounds)에 포함되어 있어야 합니다. 확장한 영역이 parent view를 벗어난다면 parent view에서도 확장된 touch 영역을 포함하기 위해 hitTest(_:with:)를 override해서 확장된 영역을 포함하도록 해야 합니다.

요약

  • UIViewhitTest(_:with:) method를 이용하여 touch event를 전달받을 view를 직접 결정할 수 있다
  • hitTest(_:with:)에서 nil을 반환하면 해당 view는 touch event를 받지 못하고, view hierarchy의 다음 view에서 hit testing을 진행한다.

참고

profile
iOS Developer

0개의 댓글