새소식

인기 검색어

iOS/iOS

[iOS] HitTest

  • -

Responder Chain에서 나온 hitTest

터치 이벤트에 반응한 View가 어떤것인지 알아보기 위해 필요한 것이 hitTest

HitTest

터치 이벤트가 발생한 포인트에 있는 view중 view hierarchy에서 가장 멀리 있는 하위 view

즉 최상단의 view를 반환

여기서 말하는 최상단의 View는 view hierarchy의 최상단 객체가 아니라

사용자가 보았을 때 가장 상위의 View를 말함

아래 사진에서 검은색 화살표 부분을 터치했을 때를 예시로 들면 ViewB를 말함

hitTest가 필요한 이유

hitTest를 사용하여 터치 이벤트를 받을 view를 정할 수 있다.

ViewB의 hitTest를 사용하면 위 그림과 같이 ViewB를 터치했을 때 ViewB를 터치한 것이 아니라

터치 이벤트가 발생한 포인트를 포함하고 있는 ViewA나 ViewA2를 터치한 것처럼 만들 수 있다.

hitTest는 어떻게 사용하나 ?

class ViewB: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hitview = super.hitTest(point, with: event)
        return hitview == self ? nil : hitview
    }
}

hitTest에서 nil을 반환하면 해당 view에서 이벤트를 처리하지 않고 이벤트를 처리할 다른 view를 찾으러 간다.

여기선 viewA2가 터치 이벤트를 처리하게 된다.

hitTest의 동작 원리

가장 최상단의 뷰 즉 view hierarchy에서의 최하단의 view를 찾기위해 hitTest는

reverse pre-order DFS를 사용하여 view hierarchy를 탐색한다

 

위의 view가 추가된 순서는 A → B → C 순이다.

  1. mainView에 ViewA 추가
    1. ViewA.1 추가
    2. ViewA.2 추가
  2. mainView에 ViewB 추가
    1. ViewB.1 추가
    2. ViewB.2 추가
  3. mainView에 ViewB 추가
    1. ViewB.1 추가
    2. ViewB.2 추가

여기서 ViewB.1을 터치하게 되면 hitTest는 reverse pre-order DFS탐색을 통해

window → mainView → ViewC → ViewB → ViewB.2 → ViewB.1 을 탐색하게 된다.

ViewC에서 subView인 ViewC.1, ViewC.2를 탐색하지 않는 이유는 그들의 상위 view인 ViewC가

터치가 일어난 point를 포함하고 있지 않기 때문에 subView들도 해당 point를 포함하고 있지 않을 것이기에

더 이상 아래(ViewC.1, ViewC.2)를 탐색하지 않고 옆(ViewB, ViewA)을 탐색하게 된다.

Ex

import UIKit

extension UIView {
    var name: String { String(describing: type(of: self)) }    
}

extension UIWindow {
    
    open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print(name, #function)
        
        let hitView = super.hitTest(point, with: event)
        
        if hitView == self {
            print(name, "hit")
        }
        
        return hitView
    }

}

class View: UIView {
        
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print(name, #function)
        
        let hitView = super.hitTest(point, with: event)
        
        if hitView == self {
            print(name, "hit")
        }
        
        return hitView
    }
 
}

final class MainView: View { }
final class ViewA: View {}
final class ViewA1: View {}
final class ViewA2: View {}

final class ViewB: View {}
final class ViewB1: View {}
final class ViewB2: View {}

final class ViewC: View {}
final class ViewC1: View {}
final class ViewC2: View {}

A1 터치

Window → MainView → ViewC → ViewB → ViewA → ViewA2 → ViewA1 hit

A2 터치

Window → M ainView → ViewC → ViewB → ViewA → ViewA2 hit

B1 터치

Window → MainView → ViewC → ViewB → ViewB2 → ViewB1 hit

B2 터치

Window → MainView → ViewC → ViewB → ViewB2 hit

C1 터치

Window → MainView → ViewC → ViewC2 → ViewC1 hit

C2 터치

Window → MainView → ViewC → ViewC2 hit

 

MainView 터치

Window → MainView → ViewC → ViewB → ViewA → MainView hit

여기서 hitTest가 동일하게 2번 호출되는데 터치한 뷰에 따라 아래와 같이 호출되는 것을 확인할 수 있다.

  • A1
    • C → B → A2
  • A2
    • C → B → A1
  • B1
    • C → B2 → A
  • B2
    • C → B1 → A
  • C1
    • C2 → B → A
  • C2
    • C1 → B → A
  • MainView
    • C -> B -> A

공식문서와 구글링을 통해 찾아보니 hitTest는 내부에서 point를 호출하여 계속해서 view들을 탐색하기 때문에 hitTest가 여러번 호출되는 것은 오류가 아니다라는 것이다.

https://lists.apple.com/archives/cocoa-dev/2014/Feb/msg00118.html

 

그렇기에 hitTest와 point를 출력해보면 여러번 호출되는 것이다.

 

class View: UIView {
        
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        print(name, #function)
        return super.point(inside: point, with: event)
    }
    
}

ViewC2 터치시

 

hitTest가 여러번 호출되어도 터치된 view를 찾기만 하면 되기 때문에 터치된 view를 찾고 난 뒤

responder에게 터치된 view만 제대로 전달해주면 된다는 것

실제로 responder를 출력해보면 문제가 없다.

extension UIView {
    
    open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print(name, #function)
    }
    
}

MainView 터치

 

 

hitTest를 오버라이드 하여 하위 view의 hit을 가로챌 수 있다.

final class ViewB: View {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print()
        print("Override HitTest", name)
        print()
        return self
    }
}

viewB의 hitTest를 override하여 viewB의 subView인 viewB1, viewB2의 터치이벤트를 가로챘다.

 

주의할 점

// hitTest 내부

func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
        return nil
    }
    if self.point(inside: point, with: event) {
        for subview in subviews.reversed() {
            let convertedPoint = subview.convert(point, from: self)
            if let hitView = subview.hitTest(convertedPoint, with: event) {
                return hitView
            }
        }
        return self
    }
    return nil
}
  • isUserInteractionEnabled = false
  • isHidden = true
  • alpha ≤ 0.01

위 경우에 대해서는 hitTest를 무시한다.

 

https://github.com/junbok97/iOS-Study/tree/main/%EB%B8%94%EB%A1%9C%EA%B7%B8/HitTest

 

iOS-Study/블로그/HitTest at main · junbok97/iOS-Study

Contribute to junbok97/iOS-Study development by creating an account on GitHub.

github.com

 

 

참고

hitTest(_:with:) | Apple Developer Documentation

 

hitTest(_:with:) | Apple Developer Documentation

Returns the farthest descendant in the view hierarchy of the current view, including itself, that contains the specified point.

developer.apple.com

 

iOS ) hitTest

 

iOS ) hitTest

안녕하세요 :) Zedd입니다. 라이브러리를 사용하면서 소스 보면 가아끔 hitTest가 있었는데, 뭐지?하고 그냥 지나쳤던 기억이...오늘 제대로 공부해볼려고 해용이를 위해서..UIResponder를 썼었죠.. hitTe

zeddios.tistory.com

 

View 계층 탐색 알고리즘과 Hit-Testing in iOS

 

View 계층 탐색 알고리즘과 Hit-Testing in iOS


explain how to find UIView object to handle events with reverse pre-order depth-first traverse algorithm and Hit-testing

lena-chamna.netlify.app

 

[iOS][Swift] Responder Chain, hitTest, point (hitTest, point의 호출 로직

 

[iOS][Swift] Responder Chain, hitTest, point (hitTest, point의 호출 로직)

UIResponder에 대해 자료를 알아보던 중 Responder Chain에 대한 과정을 직접 확인하고 싶어 hitTest와 point를 활용하여 디버깅을 하던 중 흥미로운 점을 발견하였습니다 그전에 Responder Chain이란? 대부분의

itllbegone.tistory.com

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.