article thumbnail image
Published 2023. 11. 10. 22:45

"UIResponder 객체"들은 앱 내의 "User Interaction으로 일어난 Event"에 반응하고 처리한다.

많은 UIKit의 객체들이 Responder인데, 

대표적으로 UIApplication, UIViewController, UIView(UIWindow)이 있다.

 

 

Event

Swift에서 User Interaction을 통해 일어난 Event들에는 다양한 타입이 존재하는데, 

  • touch: 스크린을 터치
  • motion: 흔드는 것과 같은 device의 모션
  • remote-control: 에어팟에서 두 번 터치와 같은 원격제어 이벤트
  • press: 전원버튼과 같은 물리적인 버튼을 눌렀을 경우 

등등이 있다. 

더 자세한 정보는 공식문서를 참고바란다.

 

"Responder"는 이러한 이벤트의 타입별로 다양한 메서드가 제공이 되는데, 

UIKit은 이벤트가 발생하게 되면 Responder의 적절한 메서드를 호출한다.

 

 

Responder Chain

만약 Event를 받은 Responder가

해당 이벤트를 처리하지 않게 되면UIKit은 "Responder Chain"에서 다음(상위) Responder로 전달하게 된다. 

 

UIKit은 이 Responder Chain을 정해진 규칙에 따라 생성한다.

Root View의 next Responder는 ViewController가 되고, 

ViewController의 next Responder는 UIWindow가 되고,

UIWindow의 next Responder는 UIApplication이 된다. 

최상위 Responder는 UIApplicatoinDelegate가 된다. 

 

또한, UIKitResponder Chain을 동적으로 관리한다.

UIView는 앞서 말했듯, Responder 객체이기 때문에, 

addSubview(_:) 메서드를 호출하게 되면, 

해당 view는 subview의 다음 Responder가 되게 된다.

 

만약 최상위 Responder인 UIApplicationDelegate에서도 해당 이벤트가 처리되지 않으면, 

UIKit은 이벤트를 버리게 된다.

 

다음 responder는 UIRespondernext 프로퍼티를 통해 확인할 수 있다.

var next: UIResponder? { get }

 

First Responder

"First Responder"란 이벤트를 핸들링하기 가장 좋은 Responder객체이며,

UIKitFirst Responder에게 맨 처음 이벤트를 보낸다.

만약 First Responder가 이벤트를 처리하지 않으면, Responder Chain을 통해 다음 Responder로 이벤트가 전달된다.

 

이러한 FirstResponder는 이벤트에 따라 다른 방식으로 지정된다. 

 

touch와 press이벤트의 경우에는

UIKit이 이벤트가 발생한 View를 firstResponder로 지정한다.

 

반면, motion이벤트의 경우는 UIResponder객체에서 isFirstRespondertrue인 객체를 firstResponder로 지정한다.

// First Responder인 경우 true를 아니라면 false를 리턴한다.
var isFirstResponder: Bool { get }

 

// 만약 first Responder가 될 수 있으면 true를 리턴하며, default값은 false이다. 
// 특정 view를 first Responder가 될 수 있게 하려면, 이 프로퍼티를 오버라이드 하면된다.  
var canBecomeFirstResponder: Bool { get }

// UIKit에게 first responder로 지정하도록 요청하고, 성공하면 true를 리턴한다. 
// 만약 canBecomeFirstResponder가 false라면 거절된다.
func becomeFirstResponder() -> Bool

 

// first Responder를 해제할 수 있으면 true를 리턴하며, default값은 true이다.
var canResignFirstResponder: Bool { get }

// 해당 객체의 firstResponder를 해체를 요청한다. 
// default는 canResignFirstResponder의 값을 따른다. 
func resignFirstResponder() -> Bool

 

 

touch, press Event의 firstResponder가 된 View라 해서 isFirstRespondertrue가 되지는 않는다.

 

Determine which responder contained a touch event 

앞서 Touch Event의 경우,

UIKitTouch가 발생한 view를 직접 First Responder로 지정한다 했는데, 

그렇기 위해서 어떤 View에서 Touch Event가 발생했는지 결정할 필요가 있다. 

 

hitTest(_:with:)

UIKit은 어떤 View에서 Touch Event가 발생했는지 체크하기 위해

UIView의 메서드인 hitTest(_:with:)를 통해

Hit Event가 발생한 위치와 View의 bound(경계선)을 비교한다.

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

hitTest(_:with:)메서드는 event가 발생한 View 중 가장 깊은 view를 리턴한다. 

만약 View의 Bound를 넘어간 경우 nil을 리턴한다.

 

hitTest(_:with:)메서드는 내부적으로 point(insde:with:)메서드를 호출한다.

point(insde:with:)메서드는 event가 View의 bound내부에서 일어났다면 true를 리턴한다. 

 

만약 true를 리턴한다면, subView의 hitTest(_:with)를 호출하여, 해당 과정을 반복한다.

결국에 hitTest(_:with)가장 깊은 Subview(frontmost view)를 리턴하게 된다.

 

특정 View에서 hitTest(_:with:)메서드가 nil을 리턴한다면 (event가 View외부에서 일어났다면)

subView들의 hitTest(_:with:)는 호출되지 않는다.

즉, 그 View의 Subview들은 모두 무시된다.

 

 

만약 위와 같이 빨간색 View의 Subview로 button을 view의 Bound를 넘어서 위치시키는 경우, 

View Bound 지역을 벗어난 지역의 button은 눌러도 touch 이벤트가 전달되지 않는다.

 

이렇게 UIKit은 hitTest(_:with:)메서드를 통해 touch가 발생한 View를 찾아, 

해당 View를 First Responder로 지정하여 이벤트를 보낸다.

 

앞서 말했듯, 이 이벤트는 Responder객체에 적절한 메서드를 통해 전달되며, 

해당 이벤트를 처리하고 싶으면 메서드를 오버라이드하면 된다. 

만약 메서드를 구현하지 않으면 이벤트는 next Responder에게 전달된다.

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
	 // handling event
}

 

만약 이벤트를 처리하고 next responder에게 이벤트를 전달하고 싶다면, 

다음과 같이 내부에서 super.touchesBegan을 호출하면 된다.

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
	super.touchesBegan(touches, with: event)
	// handling event
}

 

 

inputView

inputViewResponder가 first Responder가 되면 띄어지는 View이다. 

정확히는 isFirstRespondertrue가 되었을 동안 띄어지는 View이다.

var inputView: UIView? { get }

UITextField 혹은 UITextView와 같이 UIKeyInput 프로토콜을 채택한 경우, 

first Responder가 된 경우, inputView로 키보드를 띄우게 된다.

 

이러한 특성 때문에, UITextField, UITextViewcanBecomeFirstResponder프로퍼티는 true로 구현되어 있다.

 

왜 UITextField는 외부 View를 터치해도 키보드가 안사라질까? 

앞서 말했다 싶이, firstResonder로 지정되는 방법은 크게 2가지가 있다. 

하나는 isFirstResponder가 true인 것과, 

touch와 press와 같이 시스템에서 직접 지정하는 방식이다. 

 

즉, 터치 이벤트로 지정된 firstResponder는 isFirstResponder랑 아무런 연관이 없다. 

쉽게 말해, 특정 View에서 터치 이벤트가 발생해도 UITextField의 isFirstResponder는 변하지 않는다. 

따라서, 키보드가 사라지지 않는다. 

 

그동안 ViewController에서 키보드를 사라지게 하기 위해 흔히 다음과 같은 코드를 사용했는데, 

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?){
    self.view.endEditing(true)
}

해당 코드는 isFirstResponder를 false로 설정한다. 즉 firstResponder를 resign한다.

 

Custom Input View

이를 커스텀하기 위해선, canBecomeFirstResponder 프로퍼티를 true로 오버라이드해주고,

touchesBegan에서 becomeFirstResponder()를 호출해 준다.

override var canBecomeFirstResponder: Bool { true }

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
	self.becomeFirstResponder()
}

 

그 후 inputView를 지정해 주고, inputView의 height를 지정해 주면, 

private let customInputView = UIView()
override var inputView: UIView? { customInputView }

inputView?.frame = CGRect(x: 0.0, y: 0.0, width: 0, height: 300)

다음과 같이 Custom InputView가 뜨게 된다.

 

 

vs. Gesture Recognizer

Gesture recognizers receive touch and press events before their view does. If a view’s gesture recognizers fail to recognize a sequence of touches, UIKit sends the touches to the view. If the view doesn’t handle the touches, UIKit passes them up the responder chain. For more information about using gesture recognizer’s to handle events, see 
Handling UIKit gestures.

Apple 공식 문서에 의하면, Gesture Recognizer는 View에서 가장 처음 이벤트를 수신한다.

만약 Gesture Recognizer가 없다면, Responder를 통해 이벤트를 전달하고,

이 역시 처리되지 않으면 Responder Chain을 통해 Next Responder에게 전달한다.

 

실제로 UITapGestureRecognizer가 없는 경우, 

정상적으로 touch 이벤트가 종료되지만, 

UIResponder: touchesBegan
UIResponder: touchesEnded

 

UITapGestureRecognizer가 있는 경우는 다음과 같이 touch이벤트가 취소된다.

UIResponder: touchesBegan
UITapGestureRecognizer: Gesture
UIResponder: touchesCancelled

 

 

vs. UIControl 

UIControl의 경우는 addTarget방식을 사용하여, 

Target Object와 Action Message를 통해 직접 소통한다.

func addTarget(
    _ target: Any?,
    action: Selector,
    for controlEvents: UIControl.Event
)

 

UIControl은 이 Target Object를 찾기 위해 Responder Chain을 이용한다.

 

실제로 다음과 같이, Button의 touchesBegan이 호출이 되고, 

Target Object의 Action Method가 호출이 된 후

Button의 touchesEnded가 호출되는 것을 볼 수 있다.

UIResponder(UIButton): touchesBegan
UIControl(UIViewController): Tap
UIResponder(UIButton): touchesEnded

 

 

따라서, UIControl 객체에서 touchesBegan을 오버라이드하여,

super.touchesBegan을 호출해주지 않는 경우, target Object를 찾지 못한다. 

final class Button: UIButton {
	override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
//		super.touchesBegan(touches, with: event)
		print("UIResponder: touchesBegan")
	}
    ...
}
UIResponder(UIButton): touchesBegan
UIResponder(UIButton): touchesEnded

 

하지만, UIControl의 경우 Responder Chain을 통해 Target Object를 찾는 것뿐이지, 

실제로 next Responder로 이벤트를 전달하지는 않는다.

 

또한, GestureRecognizer와 같이 사용하는 경우 UIControl을 통한 Action Message는 무시된다.

UIResponder(UIButton): touchesBegan
UITapGestureRecognizer(UIButton): Gesture
UIResponder(UIButton): touchesCancelled

이는 앞서 말했듯 GestureRecognizer는 Responder보다 먼저 이벤트를 처리하며, 

만약 GestureRecognizer가 없는 경우, Responder에게 이벤트를 전달한다. 

하지만, UIControl은 Responder Chain을 통해 이벤트를 처리할 Target Object를 찾는다. 

 

 

References

Apple Documentation

'iOS > iOS' 카테고리의 다른 글

[iOS] Render Loop & Hitch(2)  (1) 2024.02.24
[iOS] Render Loop & Hitch(1)  (0) 2024.02.22
[iOS] Custom Drop Down  (1) 2023.10.31
[iOS] LocalDB(3) - Core Data CRUD  (0) 2023.10.25
[iOS] Local DB(2) - Core Data Concept  (0) 2023.10.09
복사했습니다!