틈틈히 적어보는 개발 일기

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

📱 iOS, Swift

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

itllbegone 2022. 3. 6. 16:18
UIResponder에 대해 자료를 알아보던 중
Responder Chain에 대한 과정을 직접 확인하고 싶어
hitTest와 point를 활용하여 디버깅을 하던 중 흥미로운 점을 발견하였습니다

Stack처럼 쌓여진 view와 겹쳐지기만 한 view들의 hitTest 차이는?

 

그전에 Responder Chain이란?

대부분의 UIKit에서 이용할 수 있는 UI 객체들(UIApplication, UIViewController, UIView 등..)은 UIResponder를 채택하고 있는 Responder 객체인데, 이를 통해서 객체들에게 발생한 이벤트를 처리할 수 있게 됩니다.

이벤트가 발생하면 UIKit은 Responder에게 이벤트를 전달하게 되는데 해당 Responder가 이벤트를 처리하지 않을 경우에는 다른 상위의 Responder로 이벤트를 전달합니다. 

-> 즉 "내가 처리할 수 없다면 나보다 위에 있는 애가 처리해줄거야!" 라고 생각이 드네요.

 

실험 준비

// 활용할 UIView인 TestView
// id는 view들의 이름을 나타내며 UILabel을 붙여서 시각화 하였습니다.
final class TestView: UIView, Identifiable {
    typealias ID = String?
    var id: String?
    
    init(frame: CGRect, id: String) {
        super.init(frame: frame)
        self.id = id
        backgroundColor = .systemIndigo
        addIdLabel()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        fatalError("init(coder:) has not been implemented")
    }
    
    private func addIdLabel() {
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 20))
        label.text = self.id
        self.addSubview(label)
    }
    
    /*
     hitTest가 호출된 객체에서 hitTest를 수행하고, 그 결과 view의 id를 반환합니다.
     '자기 자신의 id를 반환하는게 아니에요!' hitTest를 수행한 결과view의 id를 반환합니다!!
    */
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hitTestView = super.hitTest(point, with: event) as? TestView
        if let id = hitTestView?.id { print(id) }
        return hitTestView
    }
    
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        return super.point(inside: point, with: event)
    }
}

 

실험 시작

func hitTest(_:with:) 의 정의 및 과정

출처: https://ios-development.tistory.com/114

hitTest(_:with:)은 자신을 포함하여 해당 위치(point)에 해당하는 가장 먼(stack의 가장 마지막) UIView를 반환합니다.

hitTest(_:with:)를 호출하게 되면 각각의 subview의 point(inside:with:) 메소드를 호출하게 되며 가장 마지막으로 true를 반환한 객체가 가장 앞에 있는 터치된 객체이므로 이를 hitTest(_:with:)에서 반환하게 됩니다.

 

위의 말을 단계별로 나타내면 다음과 같은 단계가 되겠네요!

  1. hitTest(_:with:) 호출 -> point(inside:with:) 호출
  2. 하위의 subview들에 대해서도 1. 을 반복
  3. hitTest(_:with:)의 결과가 nil 이라면 같은 Hierarchy에 있는 다른 view들에 대해서도 1. 반복

이제 직접 실험한 결과를 하나씩 살펴봅시다!

 

Case 1. 같은 Hierarchy의 subview1, 2, 3 차례대로 터치해보기 

예상한 대로 최상위의 View의 hitTest 결과 id만 출력이 된다.. 그런데?


Q. 터치를 한번만 했음에도 불구하고 출력은 2번씩 이루어진다.. 왜일까?

Apple 홈페이지에 누군가 나와 같은 궁금증이 있었나 보다.. 무려 8년 전에 동일한 질문을 올렸었고 이에 대한 답변 또한 찾아볼 수 있었다!

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

결론은 시스템상에서 point 값을 조정하면서 hitTest를 진행하기에 여러 번 호출이 되는 것이며 부작용은 없다고 한다!!

실제로 디버깅을 돌려보았을 때에도 hitTest와 point 모두 2번 호출되는 것을 확인할 수 있었다.

 

Case 2. Stack 형식으로 쌓여진 View를 Lv1, 2, 3 차례대로 터치해보기

앞선 형태와 크게 출력이 다르지는 않아 보인다. 그런데 횟수가..?

💡Stack_Lv3의 결과를 기준으로 설명을 드릴게요.

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hitTestView = super.hitTest(point, with: event) as? TestView // <- 여기서 super.hitTest의 결과를 받아오기 위해 point가 호출이 되어요!
        // 즉 여기 부분에서 코드가 멈추고 call stack이 쌓이기 시작하며 stack에서 하나씩 pop 될 때 마다 아래의 결과id가 출력이 될겁니다.
        if let id = hitTestView?.id { print(id) }
        return hitTestView
}

이전에 이야기한 '터치한 뷰를 찾는 과정'에서 hitTest(_:with:)가 point(inside:with:)을 계속 거쳐가면서 view들을 탐색한다고 했어요.

그렇다면 call stack이 [Lv1의 hitTest, Lv1의 point, Lv2의 hitTest, Lv2의 point, Lv3의 hitTest, Lv3의 point] 이렇게 쌓여 있는 상황일겁니다.

각각의 point들의 리턴값은 true 이므로 모든 super.hitTest가 nil이 아닌 값을 가지고 있을 거에요.

아까 hitTest의 정의 부분에서 살펴보았듯이 hitTest의 결과로는 point(inside:with:)의 리턴값이 true인 가장 맨앞에 위치하는 view이어야 하므로

Lv1, Lv2, Lv3의 hitTest(_:with:)는 모두 'Stack_Lv3 이라는 id를 가진 view가 반환이 될겁니다'

Stack_Lv3의 hitTest 결과
Stack_Lv2의 hitTest 결과
Stack_Lv1의 hitTest 결과

이렇게 쌓여 있는 call stack 만큼 하나씩 pop 되면서 출력 결과가 보여지게 되는 거죠!

그렇다면 Stack 구조에서 Lv1은 2번, Lv2는 4번, Lv3은 6번인 이유가 이제 슬슬 감이 잡혀갔습니다.

Case 1. 에서 출력이 2번씩 되는 이유는 밝혀졌으니(애플이 그렇게 만들었대!)

Lv2의 경우에는 call stack에 [Lv1, Lv2] 와 같이 쌓여있어 각각의 super.hitTest결과가 Stack_Lv2가 반환이 되기 때문에 4번, Lv1은 2번 이렇게 나오는 거였네요.

 

 

아무쪼록 실험 하는 중에 생각지도 못하게 출력이 나와서 굉장히 당황스러웠습니다. 그래도 덕분에 hitTest와 point가 어떻게 동작하는지 알 수 있는 기회가 된 것 같네요!

다음에는 hitTest에서 강제적으로 이벤트를 넘겨버려서 view 뒤편에 있는 다른 view에게 이벤트를 넘겨버리도록 강제하는 방법에 대해서 이야기 해보도록 할게요!

Comments