Danny의 iOS 컨닝페이퍼
article thumbnail

[TIL #30] 2023 / 06 / 14 ~ 2023 / 06 / 18

현재 프로젝트에서 하위 뷰와 상위 뷰가 겹쳐지게 만들었습니다.

그런데 상위 뷰(아래에 위치한 뷰)를 배경색만 clear로 해주면 터치가 될 줄 알았는데, 안되더라고요...

 

해결 방법을 찾아보다가,

hitTest와 point란 놈들을 통해 각 하위 뷰의 터치 이벤트를 전달이 되더라고요.

그래서 이렇게, 한번 정리를 해보려고 합니다.

 

하단의 참고 링크들을 참고하여, 제 생각을 정리해 봤습니다.

 

 

hitTest

공식문서에서는 hitTest를 이렇게 설명하고 있습니다.

"현재 뷰(UIView)뷰 계층 구조에서, 자기 자신(self)을 포함하고,

지정된 지점(터치된 Point)을 포함하는 가장 먼 자식 뷰(?)를 반환합니다."라고 되어있네요.

 

그리고 Discussion에서 hitTest의 기본 동작은

"자기 자신(self)을 포함하고, 지정된 지점(터치된 point)을 포함하는 가장 앞쪽의 뷰(최하위 뷰)를 찾을 때까지,

뷰의 서브뷰 계층(Child View) 구조를 탐색한다"라고 나와있더라고요.

 

그러니까 위의 설명

"뷰 계층 구조에서 가장 먼자식 뷰(Child View)"는 가장 앞쪽의 뷰(최하위 뷰)라고도 해석될 것 같아요.

 

참고. 상위 뷰, 하위 뷰는 기준이 무엇인가에 따라 다르게 해석될 수 있습니다.

혼동을 방지하게 위해, 이 글에서는 무조건 뷰 계층 구조상의 기준으로 나타내겠습니다.

 

 

👉 먼저 hitTest의 내부 구현 설명을 위해 간단히 그림 예제를 갖고 왔습니다.

class ViewController: UIViewController {

    let viewA = UIView()
    let viewB = UIView()
    let viewC = UIView()

    override func loadView() {
        self.view.addSubview(viewA)
        viewA.addSubview(viewB)
        viewB.addSubview(viewC)
    }
}

위의 코드를 보시면, 일단 view가 stack 형식으로 구성돼 있습니다.

viewA안 쪽에, viewB이 그리고 viewB 안쪽에 viewC로 뷰 계층이 구성돼 있죠.

 

다들 아실 테지만, 뷰가 만들어질 때,

 가장 처음 쌓인 뷰(viewA)는 가장 밑쪽에 위치하고 가장 늦게 쌓인 뷰(viewC)는 가장 위쪽에 위치하게 됩니다.

 

최상위 뷰 = viewA, 최하위 뷰 = viewC

 

 

👉 예제와 공식문서를 연관 지어 설명을 해보면,

 

hitTest는 "뷰 계층 구조에서 가장 먼자식 뷰(Child View)" 다른 말로는 "가장 앞쪽의 위치하는 뷰(최하위 뷰)를 리턴합니다.

 

viewA의 자식 뷰(Child View)는 viewB이고, viewB의 자식 뷰(Child View)는 viewC와 같이

뷰가 stack 형식으로 쌓여 있으므로 마지막에 추가된, 최하위 뷰(viewC)를 리턴하겠네요.

 

일단은 우리는 어떤 view를 리턴하는지 알게 됐습니다.

 

 

👉 우선, 들어가기 앞서 hitTest의 탐색 방식을 알고 가야 합니다.

 

⭐️ hitTest는 Deep First Search(DFS)는 깊이 우선 탐색 방식 알고리즘을 사용한다고 합니다. ⭐️

뷰 계층이 다를 때, 즉 view가 stack 형식으로 쌓였을 때, 깊이 우선 탐색 방식으로 뷰를 탐색해 나갑니다!

 

아래 그림에서 오른쪽은 Breadth First Search(BFS)는 너비 우선 탐색 방식인데 참고만 하세요.

이미지 출처: https://lena-chamna.netlify.app/post/hit_testing_in_ios/

 

즉, hitTest의 탐색 순서 최상위 뷰부터, 역순으로 탐색을 진행하게 되겠죠. (역순 깊이 우선 탐색)

 

다시 말해, hitTest의 탐색 순서는 viewA -> viewB -> viewC 순으로 를 탐색하게 될 것입니다.

 

 

👉 탐색의 순서를 알아봤으니, 그림과 함께 기본 hitTest는 어떤 식으로 동작하는지 확인해 봅시다.

 

만약, 그림에서 스티커를 클릭했을 때, 상위 뷰들(viewA, viewB)은 아무런 이벤트 전달 없이 그냥 넘어가게 되고,

최하위 뷰(viewC)를 만났을 때, 터치한 지점의 터치 이벤트와 함께 UIView리턴하도록 내부 구현이 돼 있어요!

 

사실 상위 뷰들(viewA, viewB)self리턴한다고 하는데,

사실 내부 구현에서는 아무런 터치 이벤트 전달이 없으므로 그냥 넘어간다고 생각해도 될 것 같아요.

 

뭔가 당연한걸, 왜 이렇게 길게 설명했냐? 궁금하실 텐데...

만약, 특정 view의 터치 이벤트를 사용 or 숨겨야 될 때, hitTest 메서드를 재정의해서 새로운 로직을 짜줘야 하는데,

hitTest의 원리를 알면 도움이 될 것 같아서, 이렇게 길게 설명하게 됐네요.

 

 

hitTest 메서드 살펴보기

func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?

point: 탐색하려는 좌표입니다. 사용자의 터치가 일어나고 있는 좌표입니다.

 

event: 공식문서에는 "이 메서드에 대한 호출을 보장하는 이벤트입니다."라고 나와있는데,

현재 터치되고 있는 터치 이벤트에 대한 정보를 전달하는 것 같네요.

 

👉 위에서 설명을 못 한 부분 있는데, hitTest 메서드에서 UIView를 리턴하려면 몇 가지 조건이 있습니다. 

알기 쉽게, 반대로 nil리턴하는 경우를 알아봅시다.

 

1. 뷰가 사용자와 상호작용이 비활성화 됐을 경우 (isUserInteractionEnabled = false)

2. 뷰가 숨겨졌을 경우 (isHidden = true)

3. 뷰의 알파 값이 0.01 미만일 경우 (alpha <= 0.01)

4. 지정된 지점(터치된 지점)이 뷰 계층 구조로 부터 완전히 외부에 위치해 있을 경우

 

이때, 리턴 값으로 nil을 받게 됩니다.

 

 

hitTest 내부 구현부 살펴보기

자, 이번엔 진짜 hitTest 내부 구현부 로직을 코드와 함께 살펴봅시다.

 

 

👉 hitTest의 구현부 로직의 순서도입니다.

복잡해 보이지만, 아래 구현부 코드와 비교하면서 읽어 보면 어떤 흐름인지 아실 겁니다.

 

👉 구현부 로직

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    // hitTest에서 UIView를 리턴에 필요한 기본 조건들
    if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
        return nil
    }

    if self.point(inside: point, with: event) {
    	// 역순으로 subviews를 확인. 
        // 순서도에서 if i >= 0 { (i = self.subview.count - 1) } 부분과 같은 로직
        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
}

 

👉 위의 예제(그림)와 구현부를 간단히 비교해서 정리하면

 

설명하기 전에 다시 강조!

[최하위 뷰: 맨 앞쪽에 존재하는 뷰(viewC)], [최상위 뷰: 맨 뒤쪽에 존재하는 뷰(viewA)]입니다.

 

hitTest 메서드는 터치한 지점의 뷰 계층 구조를 탐색합니다.

동작 순서는 맨 앞쪽에 존재하는 뷰(viewC)를 찾을 때까지 뷰의 자식 뷰 계층을 탐색하게 되죠.

 

다시 말해,

상위 뷰(viewA)에서 하위 뷰(viewC)뷰 계층을 탐색하고, 맨 앞쪽에 존재하는 뷰(viewC)에서 함수가 종료됩니다.

 

 

👉 조금 더 자세히 설명해 보면,

 

hitTest 메서드가 실행이 되면, 터치한 지점의 뷰 계층 구조를 탐색합니다.

 

여기서 중요한 부분은 뷰 계층 구조 탐색 순서를 알아야 합니다.

hitTest의 탐색 순서는 상위 뷰 -> 하위 뷰로 탐색(역순 깊이 우선 탐색)을 한다고 했죠?

 

상위 뷰에서 하위 뷰로 올라가면서 ( if i >= 0 { (i = self.subview.count - 1) } ) 

자체적으로 point 메서드를 호출하여 터치된 지점의 뷰의 존재 여부를 판단을 하게 되죠.

 

갑자기 point 메서드가 나왔네요. point 메서드 터치된 지점의 뷰를 판단해 주는 메서드입니다.

 

조금 더 point 메서드에 대해 알아보면,

현재 터치되고 있는 좌표 뷰의 경계 내에 있는지 확인하고 경계 내에 있으면 ture, 아니면 falseBool값으로 리턴을 합니다.

즉, 뷰가 있으면 true, 뷰가 존재하지 않으면 false로 값을 리턴하여 뷰의 존재를 판단해 주는 거죠.

 

다시 hitTest 구현부로 돌아와서,

그렇다면, point 메서드에서 뷰가 없다(false) nil로 함수를 종료시켜 버리고

뷰가 존재(true)한다면, 그 지점(point)의 터치 이벤트를 갖고 UIView를 리턴해 주겠죠?

 

하지만 한 번만 UIView를 리턴하는 게 아닌, 최하위 뷰를 찾을 때까지,

계속해서 함수를 순회하여 최하위 뷰일 때, 터치 이벤트와 함께 UIView를 리턴하고 함수가 종료됩니다!

 

즉, 터치된 지점의 맨 앞쪽에 존재하는 뷰(viewC)를 찾을 때까지 함수를 계속 순회하고,

최하위 뷰(viewC)를 찾으면 터치 이벤트 함께 리턴해 줍니다.

 

 

어후... 같은 말을 많이 쓴 것 같긴 한데, 아무튼 개념은 일단 정리된 것 같고, 

테스트를 한 번 해봅시다.

 

 

hitTest 테스트

hitTest의 탐색 방식은 Deep First Search(DFS)는 깊이 우선 탐색 방식이라고 하는데,

다른 계층일 때는 DFS방식으로 찾는다고 하지만, 뷰가 같은 계층일 때는 어떤 순서로 뷰를 찾아나가는지 궁금해서

hitTest, point의 순서를 한번 테스트를 해봤습니다.

 

일단 커스텀 뷰는 이렇게 만들어 줬습니다.

class CustomView: UIView {

    let name: String
    private lazy var lable = UILabel(frame: CGRect(x: 20, y: 10, width: 150, height: 30))
    
    init(frame: CGRect, name: String, color: UIColor) {
        self.name = name
        super.init(frame: frame)
        
        self.backgroundColor = color
        self.addSubview(lable)
        lable.text = name
        
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
    }
    
    // 클릭 시, 랜덤 색상 변경
    @objc func tapped() {
        backgroundColor = UIColor(red: CGFloat(drand48()), green: CGFloat(drand48()), blue: CGFloat(drand48()), alpha: 1)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let checkMyPoint = super.point(inside: point, with: event)
        print("터치된 지점의 뷰: \(name) - \(checkMyPoint)")
        
        // super.hitTest에서 내부적으로 point메서드가 실행되고, 뷰의 유무를 판별함.
        guard let hitView = super.hitTest(point, with: event) as? CustomView else { return nil }
        print("터치 이벤트와 함께 리턴하는 뷰: \(hitView.name)")
        return hitView
    }
}

 

 

1. 같은 계층에 만들어진 뷰를 클릭할 때, hitTest에서 일어나는 일

class ViewController: UIViewController {
    
    var viewA: CustomView!
    var viewB: CustomView!
    var viewC: CustomView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupViews()
    }
    
    func setupViews() {
        viewA = CustomView(frame: CGRect(x: 0, y: 80, width: 400, height: 300),
                           name: "ViewA",
                           color: .systemGray)
        viewB = CustomView(frame: CGRect(x: 100, y: 180, width: 300, height: 200),
                           name: "ViewB",
                           color: .systemBlue)
        viewC = CustomView(frame: CGRect(x: 200, y: 280, width: 200, height: 100),
                           name: "ViewC",
                           color: .systemMint)
     
        view.addSubview(viewA)
        view.addSubview(viewB)
        view.addSubview(viewC)
    }
}

 

 

많이도 찍히네요... 왜 이렇게 많이 찍히는지는 감도 안 잡히지만,

아마도 내부의 어떠한 메커니즘? 때문에 그런 것 같아 보이는데, 이건 패스하는 걸로...

 

point 메서드가 뷰를 탐색하는 패턴을 자세히 살펴보면, ViewC -> ViewB -> ViewA 순으로 찍히는 걸 확인할 수 있어요.

그리고 point 메서드 값이 true면, 뒤에 있는 뷰는 탐색도 안하고 리턴을 해주네요.

 

즉, 뷰들이 모두 같은 계층에 있다면, 뷰가 추가된 역방향 순으로 point 메서드에서 뷰를 탐색하고(최하위층부터 탐색),

만약, 뷰가 존재(true)한다면 hitTest에서는 터치된 뷰를 바로 리턴하고, 존재하지 않는(false)다면 탐색을 계속해 나갑니다.

 

 

2. 뷰가 Stack 형식으로 쌓여 있을 때, hitTest에서 일어나는 일

class ViewController: UIViewController {
   
    var viewStackA: CustomView!
    var viewStackB: CustomView!
    var viewStackC: CustomView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupViews()
    }
    
    func setupViews() {
        viewStackA = CustomView(frame: CGRect(x: 0, y: 450, width: 400, height: 300),
                           name: "View-StackA",
                           color: .systemOrange)
        viewStackB = CustomView(frame: CGRect(x: 100, y: 100, width: 300, height: 200),
                           name: "View-StackB",
                           color: .systemPurple)
        viewStackC = CustomView(frame: CGRect(x: 100, y: 100, width: 200, height: 100),
                           name: "View-StackC",
                            color: .systemPink)
        
        view.addSubview(viewStackA)
        viewStackA.addSubview(viewStackB)
        viewStackB.addSubview(viewStackC)
    }
}

 

 

여기서는

point 메서드가 View-StackA -> View-StackB -> View-StackC 이런 순으로 탐색을 해주네요.

위의 설명한 방식 Deep First Search(DFS)으로 탐색하는 걸 확인할 수 있어요.

 

같은 계층에서 만들어 준 뷰들과 스택 형식의 뷰들의 동작 방식이 완전히 반대네요.

(같은 계층 : 최하위 뷰 -> 최상위 뷰), (스택 계층: 최상위 뷰 -> 최하위 뷰)

⭐️ Note: 터치 이벤트(hitTest) 로직을 짤 때, 뷰의 계층 및View Stack을 염두에 둬서 만들어 줘야 한다! ⭐️

 

그리고 View-StackA를 클릭했을 때, View-StackC의 탐색을 시도조차 안 하는걸 볼 수 있네요.

아마도, 탐색 도중 View-StackB가 false라면 View-StackB 위에 View-StackC이 무조건 없다는 뜻이므로

다음 Stack을 탐색할 필요가 없어서 그런 것 같습니다.

 

 

프로젝트에 적용하기

프로젝트에서 확인을 위한 알람 뷰(하위 뷰)와 지도 뷰(상위 뷰)를 같은 뷰 계층에 만들었는데,

지도 뷰와 상호작용이 안 되더라고요.

 

hitTest의 기본 동작계속 순회하여 최하위 뷰를 찾고,

최하위 뷰를 찾으면, 터치된 지점의 이벤트와 뷰를 함께 리턴해준다고 했죠.

 

여기서 뷰 계층 구조를 보니,

최하위 뷰(알람 뷰)로 가득 차 있어서, 최상위 뷰(지도 뷰)와 상호 작용이 안 되던 거였네요.

(기본 hitTest 메서드는 최상위 뷰만 리턴하기 때문에 그렇죠!)

 

hitTest를 공부해 보니, 왜 지도와 상호작용이 안 되는지 대충 이유를 알게 됐어요.

 

그럼 터치가 가능해지도록 hitTest 메서드를 재정의 해봅시다.

 

일단, hitTest에서 알람 뷰를 터치할 때, 아무 동작도 안 하게 만들면 뒤의 뷰와 상호작용이 가능하겠죠?!

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    let hitView = super.hitTest(point, with: event)
    // 알람 뷰일 때, 아무 뷰도 반환하지 않음.
    if self == hitView { return nil }

    return hitView
}

 

저는 여기서 버튼 영역을 제외하고 모든 부분을 맵 뷰와 상호작용해도 문제가 없을 것 같아서, 이런 식으로도 만들어 봤습니다.

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    let hitView = super.hitTest(point, with: event)
    
    // UIButton일 때, 터치 이벤트가 포함된 뷰(버튼)를 리턴, 나머지 동작 없음
    if hitView is UIButton {
        return hitView
    } else {
        return nil
    }
}

 

참고로 이런 식으로 point 메서드를 재정의해서 사용도 가능한데,

이 방법은 터치를 원하지 않는 뷰를 따로 isUserInteractionEnabled로 false 처리해줘야 하고, 뭔가 더 복잡하네요.

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    for subview in subviews {
        if !subview.isHidden,
           subview.isUserInteractionEnabled,
           subview.point(inside: convert(point, to: subview), with: event) {
            return true
        }
    }
    return false
}

 

 

참고

https://smnh.me/hit-testing-in-ios 

https://lena-chamna.netlify.app/post/hit_testing_in_ios/

https://zeddios.tistory.com/536

https://ios-development.tistory.com/327

https://itllbegone.tistory.com/11

https://stackoverflow.com/questions

반응형
profile

Danny의 iOS 컨닝페이퍼

@Danny's iOS

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!