iOS에서의 User Interaction 처리(+SwiftUI)

suyeon tami
7 min readMay 20, 2023

--

최근 회사에서 개발하다가 SwiftUI 뷰 위에 UIKit 뷰를 올릴 경우, UIKit 뷰에 User Event가 제대로 먹지(?) 않는 이슈를 발견했다.

원인을 찾아보다가, 이번 기회에 iOS에서의 User Interaction이 어떻게 이루어지는지 한번 정리를 하면 좋을 것 같아 이번 글을 쓰게 되었다.

User Interaction 이벤트 처리

앱에서는 UIResponder를 상속받는 responder 객체들을 이용해 이벤트를 처리한다.

UIApplication, UIViewController, UIView들은 모두 responder 객체들이다.

이벤트가 발생하면 UIKit는 가장 적절한 responser 객체에게 이벤트를 보내는데, 주로 first responder가 이벤트의 수신자가 된다.

(First responder란 유저 액션에 반응할 준비가 되어있는 컨트롤 객체를 의미하는데, 현재 어떤 객체가 first responder가 되는지는 hitTest(뒤에서 설명)를 이용해 찾을 수 있다.)

Responder Chain

Responder 객체들이 연결된 일련의 체인을 리스폰더 체인이라고 부르는데, 위의 사진을 보면 체인의 상위에는 화면 상위에 존재하는 UILabel, UIButton 등이 위치하고, 체인의 가장 하위에는 UIApplication이 존재하는 것을 볼 수 있다.

이벤트를 받은 responder 객체에서 이벤트를 처리할 수 없는 경우 리스폰더 체인의 다음 responder로 보내게 되는데, application 객체까지 이벤트가 도달했는데도 처리할 수 없는 경우 이벤트를 삭제한다.

UIResponder Chain을 관리하는 메서드

// responder chain의 다음 responder 객체를 리턴, 없을 경우 nil 리턴
var next: UIResponder?

// firstResponder 객체 리턴
var isFirstResponder: Bool

// 객체가 firstResponder가 될 수 있는지 여부 리턴
func canBecomeFirstResponder: Bool

// 객체가 firstResponder 상태를 포기할 수 있는지 여부 리턴
var canResignFirstResponder: Bool

// 객체를 Window의 firstResponder로 만들도록 UIKit에 요청
// 내부에서 canBecomeFirstResponder 값 참조함.
func becomeFirstResponder() -> Bool

// 객체가 Window의 firstResponder 상태를 포기하도록 UIKit에 요청
func resignFirstResponder() -> Bool

주로 텍스트필드에 무언가를 입력할 때에 becomeFirstResponder() 메서드를 호출해 키보드를 올리고, 입력이 끝났을 때에 resignFirstResponder() 메서드를 호출해 키보드를 내리는 방식으로 코드를 많이 작성하는 것 같다.

HitTest

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

func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 이벤트가 발생한 지점을 포함한 뷰가 아니라면 nil 리턴
if !point(inside: point, with: event) {
return nil
}

// 투명도, user interaction 여부, hidden 여부 확인 -> alpha == 0 or hidden or userInteraction == false일 때에 제스처 처리가 불가능한 이유
if alpha < 0.01 || !isUserInteractionEnabled || isHidden {
return nil
}

// 가장 하위의 서브뷰를 찾는 과정
for subview in subviews.reverse() {
if let hitView = subview.hitTest(point, with: event) {
return hitView
}
}
return nil
}

hitTest란, 특정한 지점(point)을 포함하는 뷰의 계층 구조 중에서 가장 멀리있는 자손(farthest descendant)을 리턴하는 메서드로써, firstResponder로 지정할 뷰를 찾기 위해 사용된다.

기본적으로는 이벤트가 발생한 지점에 위치한 가장 최하단의 서브뷰를 리턴(= 유저 기준 가장 가까이 있는 뷰)하는 방식으로 구현되어 있으나, 오버라이드를 통해 특정 뷰가 UI 제스처를 받지 못하게 하는 등 얼마든지 원하는 방식으로 커스텀할 수 있다.

hitTest는 DFS(와 유사한) 방식으로 firstResponder를 찾는다고 하는데, 더 자세한 동작 원리는 여기를 참고하면 좋을 것 같다.

SwiftUI와 UIKit를 동시에 사용할 경우의 이슈

글 도입부에서 적었듯이, SwiftUI 뷰 위에 UIKit 뷰가 올라가 있는 경우에 SwiftUI 뷰를 탭하면 탭 이벤트가 최상위의 스유 뷰가 아닌 그 아래의 UIKit 뷰로 전달되는 이슈가 있었다.

관련해서 구글링해도 별다른 정보가 없어서 Chat Gpt에 물어보니, SwiftUI 뷰가 UIKit 보다 터치 이벤트에 대해 더 높은 우선순위를 가지고 있기 때문이라는 답변을 들었다! (백퍼센트 사실인지 확신은 어렵지만…)

지피티의 답변

UIKit 뷰를 firstResponder로 지정해주거나 앞서 설명했던 hitTest 메서드의 오버라이드를 통해 UIKit 뷰의 우선순위를 높일 수 있는 방법들을 시도해 보았지만 달라지는 건 없었다.

하지만 삽질 끝에 적용 가능한 두 가지 방법을 찾을 수 있었다. (가장 좋은건 애플에서 알아서 고쳐주는 것이긴하다.)

1. allowsHitTesting(_ enabled: Bool) 사용 — SwiftUI에는 터치 이벤트가 전달되지 않아도 될 경우

SwiftUI에서 뷰의 allowHitTesting 여부를 조정하면, 말그대로 해당 뷰를 hitTesting에 넣을지 여부를 지정할 수 있게 된다.

따라서, 이 값을 false로 지정해 SwiftUI 뷰로는 이벤트가 전달되지 않도록 할 수 있다.

주의해야 할 점은 SwiftUI 뷰 전체에 이벤트가 전달되지 않기 때문에, UIKit뷰에 가려지지 않은 영역에는 이벤트가 전달되어야 한다면 이 방법은 적합하지 않다.

위는 블라인드 앱의 도입 화면인데, 팝업 뷰가 UIKit뷰, 그 이외의 뷰들은 SwiftUI 뷰로 구성되어 있다고 가정할 때 팝업 뷰를 제외한 영역에는 탭 이벤트가 처리되지 않아야 하므로 allowHitTesting 값을 false로 지정해 SwiftUI 뷰 전체의 이벤트 처리를 막을 수 있다.

2. UIVIewRepresentable로 감싸기 — SwiftUI, UIKit뷰 모두에 터치 이벤트가 전달되어야 할 경우

1번 방법이 적합하지 않을 경우, UIKit 뷰를 UIViewRepresentable로 감싸 아예 SwiftUI 뷰처럼 취급하는 방법도 있다.

하지만 이 방법은 1번에 비해 공수가 더 들어가기 때문에 1번을 적용할 수 없는 상황에서만 쓰는게 좋지 않을까 싶다.

위의 사진에서 아래 깔린 지도 뷰는 SwiftUI뷰, 상위의 검색창, 버튼 등등이 UIKit 뷰로 구성되어 있다고 가정할 때, 지도뷰도 터치가 가능해야 하고 버튼이나 검색창도 터치가 가능해야 하므로 1번 방법은 사용이 불가능하다. 이 경우 전체 뷰를 SwiftUI 뷰로 취급할 수 있도록 상위의 뷰들(검색창, 버튼 등)을 UIViewRepresentable로 감싸주어야 한다.

--

--