[UIPanGestureRecognizer] UIGestureRecognizer 1탄 - 아래로 드래그해서 모달창 dismiss하는 방법

2021. 10. 31. 17:33UIKit, SwiftUI, H.I.G

1️⃣ UIGestureRecognizer 시리즈를 시작하며 알고 넘어가야 할 기본 개념

iOS 앱 개발에서 사용자의 제스처와 관련된 부분을 관리하고 처리할 때 사용하는 클래스로는 UIGestureRecognizer가 있다.

@MainActor
class UIGestureRecognizer : NSObject

사용자가 화면(UIView)에서 수행하는 다양한 터치 동작(ex. 탭, 스와이프, 핀치, 드래그, 회전)을 감지하고, 프로그램을 이에 맞는 응답을 처리하기 위해 사용된다고 보면 된다.

이 자체 클래스로 사용을 하는 것은 아니고 해당 클래스는 여러 가지 다양한 구체 제스처 인식기(GestureRecognizer)의 기반이 되어준다.

그럼 구체적인 제스처는 어떤 것들이 있는지 하나씩 알아보자.

1. UITabGestureRecognizer : 화면을 한 번 또는 여러 번 클릭하는 제스처
2. UIPinchGestureRecognizer : 두 손가락을 사용하여 화면을 집거나 벌리는 제스처
3. UIRotationGestureRecognizer : 두 손가락을 사용하여 화면을 회전시키는 제스처
4. UISwipeGestureRecognizer : 손가락을 상하좌우 방향으로 (빠르게) 화면을 쓸어내는 스와이프 제스처
5. UIPanGestureRecognizer : 손가락을 상하좌우 방향으로 (스와이프보다 느리게) 화면을 드래그하는 제스처
6. UIScreenEdgePanGestureRecognizer : 화면의 가장자리에서 시작하는 Pan 제스처
7. UILongPressGestureRecognizer : 화면을 길게 누르는 제스처 (힘을 주는 포스터치는 이에 해당하지 않는다.)

UIGestureRecognizer에서 파생되는 여러 제스처 인식기

각 세부 제스처별로 세부 사용에는 차이가 다소 있겠지만, 공통적으로 제스처를 사용할 때 필요한 절차를 아래와 같이 설명할 수 있다.

제스처 인식기(GestureRecognizer) 초기화 -> 뷰에 제스처 인식기 추가(view.addGestureRecognizer) -> 제스처 인식이 이루어졌을 때 동작되는 메서드 구현과 연결
한 제스처 인식기에는 하나 이상의 대상-동작 쌍(target-action pair)이 있어야 하며, 동작 함수는 아래 두 가지 형태 중 하나를 갖게 된다.

@IBAction/@objc func myActionMethod()
@IBAction/@objc func myActionMethod(_ sender: UIGestureRecognizer)


제스처는 한 번만 발생하는 이산 제스처(Discrete Gesture)와 끊기지 않고 여러 번의 액션이 발생하는 연속 제스처(Continuous Gesture)로 나뉘어진다.
*참고로 더블 탭 같은 경우는 한 번의 UITabGesture로 액션이 전송되기 때문에 Discrete Gesture에 해당한다고 볼 수 있다.

이때 제스처의 종류에 따라 상태를 처리하는 방법이 조금씩 달라진다.
내가 사용하고자 하는 제스처가 Discrete인지 Continuous인지에 따라서 제스처를 인식하는 방법이 달라질 수 있다는 점 알고 넘어가자!

  • Discrete Gesture : Possible 상태에서 시작 -> 제스처 인식이 성공한 경우 Recognized(Ended) 상태, 인식을 실패한 경우 Failed 상태 처리
  • Continuous Gesutre : Possible 상태에서 시작 -> 제스처가 시작할 때 Began 상태 -> 선택 사항으로 제스처가 결과에 도달하기 전까지 Changed 상태 반영 가능 -> Ended 또는 Cancelled 상태 도달

 

2️⃣ UIPanGestureRecognizer 개념

내가 이번 글에서 구현하고 싶었던 기능은 제목과 같이 "아래로 드래그해서 모달창 dismiss 하는 방법"이었다.

이 기능을 구현해 주기 위해서는 위의 7가지 제스처 중 Swipe가 아니라 UIPanGestureRecognizer를 활용해야 했다.
*Swipe는 손가락이 움직이는 방향을 인식하는 Discrete Gesture, Pan은 손가락이 움직이는 방향, 위치, 속도를 인식해서 처리하는 Continuous Gesture라는 점에서 차이가 있다고 보면 된다.

UIPanGestureRecognizer 공식문서 내용

위에서 설명했다시피 UIPanGestureRecognizer는 UIGestureRecognizer를 상속받는 서브 클래스에 해당한다.

PanGesture는 사용자가 뷰를 누르는 상태에서 한 손가락 또는 여러 개의 손가락으로 팬(드래그)하는 동작을 인식한다.

Continuous Gesture이기 때문에 상태는 .began (최소/최대 인식 가능한 손가락 수(minimumNumberOfTouches, maximumNumberOfTouches)를 설정한 것에 기반)에서 .changed로, 그리고 마지막에는 .ended로 이어지게 될 것이다.
또한, 액션 메서드에서는 사용자의 제스처 변환 값을 조회(translation(in: UIView?))하거나 속도 값을 조회(velocity(in: UIView?))하는 작업을 수행할 수 있다.

 

3️⃣ UIPanGestureRecognizer 코드

구체적으로 그럼 팬 제스처를 구현하는 코드는 어떻게 되는지 하나씩 따라가 보자.

1) 제스처 초기화 및 추가

우선, 제스처를 초기화하고 뷰 컨트롤러의 뷰와 제스처를 연결해 줬다.
UIPanGestureRecognizer 객체를 self(뷰 컨트롤러)와 연결하고, 제스처가 인식되었을 때는 action 파라미터를 통해 panGestureDismiss 메서드와 연결될 수 있도록 했다.

func setPanGesture() {
    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureDismiss))
    self.view.addGestureRecognizer(panGesture)
}

 

2) PanGesutureDismiss objc 메서드

위에서 설명한 대로 Continuous Gesture의 상태에 따라 개별적으로 처리를 해줬다.

제스처가 처음 시작했을 때(.began)는 처음 제스처가 시작된 지점을 initialTouchPoint에 저장한다.

제스처가 움직이는 도중(.changed)에는 현재 제스처가 이루어지고 잇는 터치 지점(touchPoint)에 따라 뷰의 프레임을 이동시킨다.
즉, 사용자가 손가락을 아래로 움직이면 뷰도 손가락에 맞춰서 따라 내려갈 수 있도록 구현한 지점이다.

제스처가 끝나거나(.ended) 취소(.cancelled)되었을 때는 손가락이 200 포인트만큼 움직였는가를 기준으로 화면을 dismiss 할지 말지를 지정했다.
만약 200 이상 움직인 경우는 화면을 dismiss 시키고, 200 미만으로 움직인 경우에는 뷰를 애니메이션과 함께 원래 위치로 복귀시켰다.

var initialTouchPoint = CGPoint(x: 0, y: 0)

@objc func panGestureDismiss(_ sender: UIPanGestureRecognizer) {
    let touchPoint = sender.location(in: self.view.window)
    
    // 처음 제스처를 시작했을 때
    if sender.state == .began {
        initialTouchPoint = touchPoint
    } 
    
    // 제스처가 움직이는 도중
    else if sender.state == .changed {
        if touchPoint.y - initialTouchPoint.y > 0 {
            // 제스처의 위치가 바뀔 경우, y만큼 프레임을 내려 뷰를 내림
            self.view.frame = CGRect(x: 0, y: touchPoint.y - initialTouchPoint.y, width: self.view.frame.width, height: self.view.frame.height)
        }
    } 
    
    // 제스처가 끝나거나, 동작을 멈췄을 경우
    else if sender.state == .ended || sender.state == .cancelled {
        if touchPoint.y - initialTouchPoint.y > 200 {
            self.dismiss(animated: true, completion: nil)
        } else {
            UIView.animate(withDuration: 0.3) {
                self.view.frame = CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height)
            }
        }
    }
}

위와 같은 코드는 아래와 같은 최종 구현 화면으로 이어진다!

최종 구현 화면!