애플사이다의 iOS 개발 일지

[삽질 방지] UIButton이 Tap Gesture를 인식 못하는 원인 5가지 본문

iOS

[삽질 방지] UIButton이 Tap Gesture를 인식 못하는 원인 5가지

Applecider 2023. 4. 23. 22:12

버튼을 구현하는 건 굉장히 쉽다.

하지만 모든 작업을 다 한 것 같은데도 버튼이 Tap Gesture를 인식하지 못할 때가 있어서 답답했던 경험이 있다.

 

너무 간단한 기능이라서 오히려 적절한 검색 키워드를 찾거나 문제 원인을 파악하기가 더 어려웠다.

삽질하면서 찾아낸 원인 5가지를 정리해 봤다.


1. Storyboard Connection이 잘못된 경우

해결하기 가장 간단한 오류이다. Storyboard의 버튼과 @IBAction 메서드의 연결이 잘못된 경우이다.

 

여러 개의 버튼 또는 action 메서드를 복붙해서 만들었거나, 단순히 드래그를 잘못해서 발생한다.

아래처럼 Touch Up Inside 이벤트에 연결된 메서드가 1개 여야 정상동작한다.

코드로 UI를 그리는 작업에 익숙해졌는데, 반년만에 다시 Storyboard를 사용하면서 잠시 헤맸었다.

 

*현업에서도 Storyboard를 쓸까?

협업 시 Conflict이 발생해서 현업에서는 UI를 모두 코드로 구현하는 줄 알았는데 아니었다.

화면 구성이 단순하거나, 재사용이 필요 없다고 판단되는 화면은

빠르게 작업 가능한 Storyboard (정확하게는 Interface builder)를 쓰기도 한다.

2. 이벤트에 대한 action을 잘못 구현한 경우

코드로 UIButton을 구현하는 방법은 아래 코드처럼 세 가지 정도가 있다.

addTarget(_:action:for:) 또는 addAction(_:for:) 메서드, primaryAction 주입을 활용하는 것이다.

 

이때 이벤트가 발생했을 때 실행될 action을 잘못 구현했을 수 있다.

이것도 해결이 단순하니 설명은 생략했다.

// 방법-1. addTarget 메서드 활용
private lazy var backButton: UIButton = {
    let button = UIButton()
    // ...
    button.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside)
    return button
}()

@objc 
func backButtonTapped() {
    navigationController?.popViewController(animated: true)
}
// 방법-2. addAction 메서드 활용
private lazy var backButton: UIButton = {
    let button = UIButton()
    button.addAction(UIAction { [weak self] _ in
        self?.originalButtonTapped()
    }, for: .touchUpInside)
    // ...
    return button
}()
// 방법-3. 초기화 시 UIAction을 주입
private lazy var backButton: UIButton = {
    let button = UIButton(primaryAction: UIAction { [weak self] _ in
        self?.navigationController?.popViewController(animated: true)
    })
    // ...
    return button
}()

3. 버튼이 위치/크기를 정확히 잡지 못한 경우

스오플에서 찾지 못한 원인이었다.

설정한 layout constraints가 충분하지 않아서 버튼의 position/size가 명확히 잡히지 않은 경우이다.

xcode가 임의로 충돌하는 constraints를 제거해서 일단 view를 그리긴 한다.

그래서 화면만으로는 문제가 없어 보이지만 버튼을 탭했을 때 이벤트에 반응하지 않는다.

 

Debug view hierarchy에서 확인하면 

"Horizontal position is ambiguous for UIButton", "Width is ambiguous for UIButton" 등의 Layout issues가 뜬다.

이때 이벤트에 반응하지 않는 진짜 원인이 뭘까?
버튼의 위치가 화면상에 보이는 영역을 벗어났는데,

clipToBounds가 false (default)라서 

"눈에는 보이지만 탭 이벤트를 인식할 수 없는 상태"인 것으로 추측해 봤다.

 

*clipToBounds란?
true일 때 view bounds의 밖으로 벗어난 subviews를 자른다. (default = false)

4. imageView 또는 stackView 위에 버튼을 올린 경우

알고 보면 너무 간단하지만 오래 삽질했다.

 

imageView 또는 stackView의 subview로 버튼을 올리는 경우,

imageView의 isUserInteractionEnabled이 false (default)여서 탭 제스처를 무시해 버린다.

즉, 버튼의 superview가 제스처를 빼앗아 무시해 버린 것이다.

 

따라서 이것만 처리해 주면 해결된다.

bubbleImageView.isUserInteractionEnabled = true // 이제 subview로 이벤트를 전달함

bubbleImageView.addSubview(translationButton) // 버튼이 이벤트를 받음

 

UIButton은 UIControl을 상속받으므로 별도 처리를 하지 않아도 이벤트를 인식하지만

UIImageView는 아니라는 것을 다시 뼈에 새겨본다.

imageView.addSubview(button) 구조

5. 버튼을 isUserInteractionEnabled = false인 뭔가가 덮어서

4번과 비슷하게 다른 UI에게 제스처를 빼앗긴 경우이다.

view hierarchy를 통해 버튼을 덮고 있는 UI가 없는지 확인해 보자.


5번과 관련해서 헷갈리는 부분이 있어 정리해 봤다.

위의 스크린샷처럼 collectionView cell 위에 버튼을 올린 구조에서

collectionView에 gesture recognizer를 추가한 뒤, 버튼을 탭하면 어떻게 될까?

 

'gesture recognizer가 제스처를 빼앗아버려서 버튼이 동작하지 않겠지?'라고 예상했는데 아니었다. 🥹
(다시 생각해보면 당연히 버튼이 제스처를 빼앗는다. view hierarchy상 button이 가장 위에 있기 때문이다.)

 

버튼 영역을 탭했을 때 gesture recognizer는 동작하지 않고, 버튼에게 touch가 전달된다.

(단, cell 영역 중에서 버튼이 아닌 곳을 탭하면,
gesture recognizer가 제스처를 빼앗아서 collectionView의 didSelectItemAt 메서드가 호출되지 않는다. <- 이 때문에 헷갈린다.)

 

 

그런데 만약 gesture recognizer가 항상 제스처를 인식하도록 하고 싶다면?

또는 didSelectItemAt이 호출되도록 하고 싶다면?

gesture recognizer의 cancelsTouchesInView를 false로 바꾸면 된다.

버튼 영역을 탭했을 때 gesture recognizer와 버튼 모두에게 touch가 전달된다.
(cell 영역 중에서 버튼이 아닌 곳을 탭하면,

gesture recognizer와 didSelectItemAt 모두 동작한다.)

let tapGR = UITapGestureRecognizer(target: self, action: #selector(collectionViewDidTapped))
tapGR.cancelsTouchesInView = false  // GR이 항상 동작하게 하려면 필요
collectionView.addGestureRecognizer(tapGR)

 

- 검색 키워드 : how to not take way gesture from cell to collectionView, swift

How to add tap gesture to UICollectionView, while maintaining cell selection?

 

*cancelsTouchesInView란?

true (default)일 때 gesture recognizer가 제스처를 인식하면, 보류 (pending)된 제스처의 touches가 view로 전달되지 않고, 기존에 전달된 touches는 (touchesCancelled(_:with:) 메시지를 통해) 취소된다.

false일 때 multi-touch sequence의 모든 touches를 view가 전달받는다.

 

이때 '보류된 제스처의 touches'는 무슨 뜻일까?

사용자가 화면을 터치했을 때, gesture recognizer는 이게 Tab, Swipe, Long Press 등 중에서 어떤 건지 판단이 필요하다.

터치가 처음 발생한 시점 (touchesBegan)에는 알 수 없다. 

따라서 gesture recognizer가 제스처를 인식하는 동안 touch 객체의 전달이 지연된다.

'보류된 제스처의 touches'는 이러한 touch 객체들을 의미한다.

 

UIGestureRecognizer 공식문서의 cancelsTouchesInView 설명이 좀 더 친절하다.

요약해보면,

(default인 true일 때) gesture recognizer가 제스처를 인식했을 때, 해당 제스처의 touch 객체를 view에게 전달하지 않는다. 

If a gesture recognizer recognizes its gesture, it unbinds the remaining touches of that gesture from their view (so the window won’t deliver them). The window cancels the previously delivered touches with a (touchesCancelled(_:with:)) message. If a gesture recognizer doesn’t recognize its gesture, the view receives all touches in the multi-touch sequence.

 

즉, 원래는 responder chain에 따라 버튼이 touch event를 처리하게 되는데,

cancelsTouchesInView를 false로 바꾸면

gesture recognizer가 제스처를 인식했을 때 view (gesture recognizer가 장착된 collectionView)에게 touch가 전달되면서 

버튼 (버튼 영역을 탭했을 때) 또는 didSelectItemAt (cell 중에서 버튼 이외 영역을 탭했을 때)이 동작하게 된다.

 

(근데 cancelsTouchesInView를 false로 바꾼다고 해도 

버튼이 제스처를 빼앗아가는데, 왜 gesture recognizer가 제스처를 인식하게 되는걸까...?

나중에 다시 정리가 필요할 것 같다. 🥲)

 

- Reference

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

 

Comments