Responder Chain 알아보기

DAMIN KIM
daily-monster
Published in
21 min readMar 13, 2024

안녕하세요 수요괴물 damin 입니다!
오늘은 Responder Chain에 대해서 알아보려고합니다.
(잘못된 부분이 있다면 댓글로 피드백 부탁드립니다~!)

전체적인 개요는 위 아티클을 바탕으로 작성했고, 이해 안가는 기타 내용은 다른 블로그 들을 참고했으니 아래 Reference 링크를 확인해주세요!

OverView

Responder objects — instances of UIResponder — constitute the event-handling backbone of a UIKit app. Many key objects are also responders, including the UIApplication object, UIViewController objects, and all UIView objects (which includes UIWindow). As events occur, UIKit dispatches them to your app’s responder objects for handling.

Responder 객체는 UIApplication, UIViewController, UIView 객체를 포함합니다. why? 이 객체들이 UIResponder 클래스를 상속해서 만들어진 객체들이기 때문이기 때문이죠! (아래 사진 참고)

이 리스폰더 객체는 이벤트를 처리하거나, 직접 처리하지 못하면 다음 리스폰더 오브젝트로 넘기도록 되어있습니다. 그래서 아래 그림처럼 이벤트 발생 시 리스폰더 객체 가 이벤트를 처리 or 전달하는 일련의 과정을 Responder Chain이라고 한다.

Responder Chain을 그림으로 나타내보았습니다

앱이 이벤트를 전달 받았을 때 UIKit 은 자동으로 이벤트를 처리하기에 가장 적절한 리스폰더 오브젝트로 이벤트를 전달하고 이때 이 가장 적절한 리스폰더 오브젝트 가 바로 First Responder
→ event를 처음으로 받을 (UIKit가 지정한) 적절한 Responderfirst responder라고 할 수 있습니다.

  • 보통 이 Responder를 UITextField를 FirstResponder로 지정해서 키보드에 바로 띄울때 썼었는데 이때는 우리가 직접 지정해서 사용한 것이였던거죠

처리되지 않은 이벤트들은 리스폰더에서 다른 리스폰더로 실시간 리스폰더 체인 (Active responder chain)에 따라 이동하고 이 리스폰더 체인은 앱의 리스폰더 오브젝트에 따라 동적으로 구성됩니다.

위 그림에서 initail View가 UITextField이고 이게 FirstResponder로서 이벤트를 전달 받습니다. 근데 처리하지 않는다면?

→ UIKit은 UITextField의 슈퍼 뷰인 UIView로 전달, 여기서도 처리하지 않는다면?

→ UIViewController, UIWindow로 넘어가게되고 → 이후 AppDelegate로 전달 되어 이벤트 처리 or 사라집니다.

좌 우 그림의 차이?
이벤트를 받은 뷰가 VC의 루트뷰이냐 아니냐에 따라 전달이 View나 VC로 가느냐의 차이!

Determining an Event’s First Responder

UIKit이 어떤 오브젝트를 FirstResponder로 결정하는지는 발생한 Event Type에 따라 달라집니다.

여러개의 이벤트들이 있지만 우리가 주로 사용하는 건 맨위의 우리가 터치할때 발생하는 Touch Event 죠?

그 외 이벤트

  • Press: 물리 키에서 발생하는 이벤트
  • Editing menu: 텍스트를 꾹 누르면 발생 하는 select, copy 등의 이벤트
  • Remote-Control: 이름 그대로 헤드폰 등 외부기기에서 발생하는 이벤트
  • Shake-motion: 기기를 흔들때 발생하는 이벤트 → 리스폰더 체인이 아니라 core motion 오브젝트로 전달 된다고함
Editing menu 이벤트

UIControl & Target-Action Mechanism

UIButton, UITextField같은 UIControl을 서브클래싱한 오브젝트들은 target-action mechanism으로 이벤트를 처리합니다.

만약 단순히 위의 터치 이벤트 타입 만으로 유저의 수 많은 인터랙션에 따른 이벤트를 처리하는게… 말이 안되겠죠?ㅎㅎ

그래서 UIControl 에서 정의해놓은 control-specific한 event에 대해 action method만 구현해주면 됩니다.

여기서 말하는 control event는 보통 버튼 액션 넣을 때 쓰는 touchDown, touchUpInside, valueChanged(UISlider) 같은 것들입니다.

// Target-action. 
// Target은 self, touchUpInside 이벤트에 대해 buttonTapped 액션 메서드 실행
signInButton.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
...
@objc func buttonTapped(sender: UIButton) {
print("Sign in successful 🎉")
}
From UIControl

위 내용을 정리하면 UIControl을 호스팅하고 있는 오브젝트가 이벤트를 처리하지 않으면 target에 nil을 넘기고 Responder Chain을 발동 시킨다고 합니다.

Controls communicate directly with their associated target object using action messages. When the user interacts with a control, the control sends an action message to its target object. Action messages aren’t events, but they may still take advantage of the responder chain.

컨트롤은 연관된 target object와 action message로 대화를 한다. 유저가 컨트롤과 상호작용을 하게 되면, 컨트롤은 action message를 target object로 전달을 하게 된다. Action message 는 이벤트가 아니지만, responder chain의 이점을 이용할 수 있다. 만약 target object가 nil 이라면, UIKit은 해당 object부터 시작해 적절한 action method를 구현한 객체를 찾을 때 까지 responder chain을 순회하게 된다.

위 내용에 따르면 간단히 컨트롤의 타겟 오브젝트가 설정이 되어있으면 그 친구를 그냥 실행하면 되고 그렇지 않으면 액션 메서드를 구현한 오브젝트를 찾을 때까지 리스폰더 체인을 탐색합니다.

여기서 Action Message란 사용자가 UI 컨트롤(버튼, 스위치, 슬라이더 등)과 상호작용할 때, 해당 컨트롤이 목표 객체(target object)에게 보내는 메시지를 의미합니다.
예를 들어, 사용자가 버튼을 누르면, 그 버튼은 미리 정의된 목표 객체(대부분의 경우 뷰 컨트롤러)의 특정 메서드(액션 메서드)를 호출합니다. 이 메시지는 사용자의 행동을 앱의 로직과 연결하는 방식으로, 이벤트 시스템과는 다소 차이가 있지만, Responder chain을 통해 여러 객체에 전달될 수도 있습니다.

이벤트 시스템과 액션 메시지는 사용자 인터페이스의 상호작용을 처리하는 두 가지 중요한 메커니즘인데, 각각의 역할과 사용 방식에 차이가 있어요.

이벤트 시스템
이벤트 시스템은 사용자의 터치, 마우스 클릭, 키보드 입력 같은 입력을 추적하고 응답하는 데 사용되는 일반적인 방식입니다. 이벤트는 시스템에 의해 생성되며, 앱이나 웹 페이지에서는 이러한 이벤트를 감지하고 적절한 처리를 수행할 수 있는 이벤트 리스너(event listener)나 이벤트 핸들러(event handler)를 등록해둘 수 있어요. 이벤트 시스템은 보통 낮은 수준의 상호작용을 처리하며, 애플리케이션은 이벤트가 발생했을 때 실행될 콜백 함수나 메서드를 지정할 수 있습니다.

Action Message
반면, 액션 메시지는 특히 iOS 개발에서 볼 수 있는, 사용자 인터페이스 컨트롤(버튼, 스위치 등)이 사용자의 상호작용(예: 버튼 클릭)에 응답하여 특정 메서드(액션)를 호출하는 메커니즘입니다. 액션 메시지는 보다 추상화된 수준에서 사용자의 상호작용을 처리하며, 특정 UI 컨트롤이 연결된 타겟 객체에 메시지를 전달하는 방식으로 작동해요. 이 메커니즘은 사용자 인터페이스 요소가 수행해야 할 동작을 명시적으로 정의할 수 있게 해줍니다.

차이점
추상화 수준
: 이벤트 시스템은 보다 낮은 수준의 사용자 입력(터치, 클릭, 키 입력 등)을 직접 처리하는 반면, 액션 메시지는 사용자 인터페이스 요소의 상호작용에 의해 특정 동작을 실행하는 더 높은 수준의 추상화를 제공합니다.

사용 방식: 이벤트는 보통 이벤트 리스너를 등록하여 처리되며, 다양한 유형의 입력에 대응할 수 있습니다. 액션 메시지는 주로 UI 컨트롤과 연결된 타겟 객체의 메서드를 호출하는 방식으로, 특정 UI 이벤트에 대한 응답으로만 사용됩니다.

Gesture recognizer는 터치와 누르는 이벤트를 뷰보다 먼저 받는다.

만약 view’s gesture recognizer 가 연속되는 터치 이벤트를 받지 못한다면, UIKit은 이를 뷰로 전달한다. 만약 뷰가 touch를 처리하지 못한다면, UIKit은 responder chain을 따라서 터치 이벤트를 전달한다. (조금 더 자세한 내용을 알고 싶다면 Handling UIKit Gestures 를 확인해보라고 하네요. )
뷰보다 gesture recognizer가 이벤트를 우선으로 받고 그 이후 뷰 전달 뷰도 처리 못하면 responder chain 발동을 하게 됩니다!

Determining Which Responder Contained a Touch Event

= 어떤 리스폰더가 터치 이벤트를 받을 것이냐

UIKit은 touch event 가 어디에서 발생했는지 확인하기 위해서 view-based hit-testing 을 사용한다. 특히 UIKit은 touch location과 view hierarchy 에 있는 뷰 객체의 bounds 를 비교한다. UIView의 hitTest(_:with:) method는 view hierarchy를 순회하며, 특정 터치를 포함하는 가장 깊은 subview를 찾으며, 해당 view는 터치 이벤트의 first responder가 된다

hitTest

  • point : 리시버의 로컬 좌표계(bounds)로 지정된 점(point)
  • event: 이 메소드에 대한 호출을 보증(warranted. 보증하다, 단언하다, 장담하다 등)하는 이벤트입니다. 이벤트 처리코드 외부에서 이 메소드를 호출하는 경우에는 nil을 지정 할 수 있습니다.

hitTest를 통해 View 계층에서 터치 point를 포함하는 뷰 중 Farthest descendant = 가장 먼 자손. 즉, 사용자와 가까운 view를 리턴합니다.

This method traverses the view hierarchy by calling the point(inside:with:) method of each subview to determine which subview to send a touch event to. If point(inside:with:) returns true, this method continues to traverse the subview hierarchy until it finds the frontmost view that contains the specified point. If a view doesn’t contain the point, this method ignores its branch of the view hierarchy. You rarely need to call

각 뷰계층에서 point라는 메서드를 실행해서 true이면 다음 subView, false면 그 계층을 다 무시합니다. point 메서드를 직접 호출 할 필요는 없지만 특정 subView뷰로 부터 이벤트 처리를 하고 싶지 않으면 ovveride해서 쓸 수 있습니다.

This method ignores view objects that are hidden, that have disabled user interactions, or that have an alpha level less than 0.01. This method doesn’t take the view’s content into account when determining a hit, so it can return a view even if the specified point is in a transparent portion of that view’s content.

hidden이거나 alpha 값이 0.01 이하거나 user interation을 disabled 해놓은 뷰라면 무시. 이 메서드는 hit을 결정할 때 뷰의 컨텐츠를 고려하지 않는다. 즉, 콘텐츠의 투명한 부분에서 이벤트가 발생하였어도 뷰는 반환됨.

alpha 값이 0.01이하 정도로 작을때도 이벤트가 무시되네요..ㅎㅎ

This method doesn’t report points that lie outside the view’s bounds as hits, even if they actually lie within one of the view’s subviews. This situation can occur if the view’s clipsToBounds property is false and the affected subview extends beyond the view’s bounds.

A view의 clipToBounds가 false일때 A view의 SubView는 A View의 bounds를 벗어 날 수 있는데 벗어난 영역에 터치를 해도 이벤트는 A view에게 전달 되지 않는다

터치 이벤트가 발생하면, UIKit은 UITouch 객체를 만들어 view와 연결시킨다. 터치의 위치가 바뀌거나 다른 parameter가 변경 된다면, UIKit은 동일한 UITouch 객체를 새로운 정보로 업데이트 한다. 오로지 view만 변경되지 않는다. ( 만약 터치의 영역이 뷰를 벗어나더라도 view는 변경되지 않는다. ) 터치가 끝나면, UIKit은 UITouch 객체를 release 한다.

  • 이 특성 덕분에 테이블 뷰에 터치를 시작해서 손가락을 테이블뷰 바깥으로 이동을 하더라도, 테이블 뷰는 계속해서 스크롤이 됩니다.
  • touch Event에 시퀀스가 있는데 (began, moved, ended, canceled) touch 시퀀스가 끝날때까지 모든 touch는 계속 수신되고 있어요
  • hitTest를 통해 리턴할 뷰를 찾는 과정은 reverse pre-order DFS를 사용한다고 합니다.
  • 처음 루트뷰에서 그 다음 하위뷰 그 하위뷰의 하위뷰…해서 터치 포인트를 포함한 가장 먼 View를 발견시 그 뷰를 리턴합니다.

위와 같은 뷰계층이 있다고 했을때, 이 뷰는 빨간색 ViewA와 초록색 ViewB가 겹치는 부분이 존재하고, 계층은 View C부터 사용자에 가장 가깝고, 그 다음 View B, ViewA순으로 되어있어요. (뷰계층에서 가장 먼 자손은 View C)

사용자가 A,B 겹친부분을 터치했을때 UIWindow→MainView→ViewC로 탐색을 했다가 해당 touch point가 C에 없기 때문에

그 다음으로 B를 탐색하고, 그 하위뷰인 B1, B2중 B2의 subView 인덱스가 더 높기때문에 B.2 확인하지만 이 역시 touch point를 포함하지 않기 때문에 B.1을 확인 이때 B.1이 조건을 만족하기 때문에 hitTest로 View B.1이 리턴되게 됩니다.

여기서 만약 B.1의 터치 이벤트를 뒤로 넘기고 싶다면?

//1. hitTest override
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hitView: UIView? = super.hitTest(point, with: event)
// superview가 터치 이벤트를 받을 수 있도록,
// 해당 뷰 (subview)가 터치되면 nil을 반환하고 다른 뷰일경우 UIView를 반환
return hitView == self ? nil : hitView
}
//2. point override
override func point(inside point: CGPoint,with event: UIEvent?) -> Bool {
// 그냥 false 리턴
return false
//+ 이렇게 하면 나를 제외하지만 상위뷰는 포함된 영약(여집합?) 터치 구현 가능
// 기본 동작: 뷰의 경계 안에 있는 경우에만 터치를 인식합니다.
let isInside = super.point(inside: point, with: event)
// 추가 동작
return !isInside
}
//3. userInteractionEnabled 사용
viewB2.userInteractionEnabled = false

Altering the Responder Chain

= 리스폰더 체인을 변경하기

You can alter the responder chain by overriding the next property of your responder objects. When you do this, the next responder is the object that you return.

리스폰더의 next property를 override 해서 responder chain을 변경할 수 있다. 이를 할 때, next responder는 리턴하는 값이 된다.리스폰더의 next property를 override 해서 responder chain을 변경할 수 있다. 이를 할 때, next responder는 리턴하는 값이 된다.

많은 UIKit classes 들이 next property를 override 해서 특정한 객체를 리턴하고 있고
UIView는 본인이 ViewController의 루트뷰라면 VC를 아니면 그 superView를next로 리턴한다.
VC는 자기가 Window의 root vc라면 window를 혹은 자기가 presentedVC라면 presenting VC를 next로 리턴한다.
이런식으로 UIWindow에서 UIApplication 까지 가서 이벤트가 처리되거나 무시된다.
이에 대한 예제를 살펴보면

import UIKit

class CustomView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
// 터치 이벤트를 처리하는 로직
print("CustomView에서 터치 이벤트가 감지되었습니다.")

// 이벤트를 처리할 수 없는 경우, 다음 응답자에게 이벤트 전달
self.next?.touchesBegan(touches, with: event)
}
}

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let customView = CustomView()
customView.backgroundColor = .red
customView.frame = CGRect(x: 100, y: 100, width: 200, height: 200)
self.view.addSubview(customView)
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
print("ViewController에서 터치 이벤트가 감지되었습니다.")
}
}

위 코드처럼 CustomView에서 이벤트 처리할 수 없는 경우 next 프로퍼티를 통해 touchBegan 메서드를 호출하면 다음 Responder인 ViewController에서 touchBegan메서드가 호출되어 이벤트를 처리할 수 있습니다.

References

https://woozzang.tistory.com/144

--

--