(Swift) Tap and Hold 구현하기

tap and hold 구현하기

UIButton은 기본적으로 단일 탭에 대해서 액션 메시지를 발신하게끔 디자인되어 있고, 따라서 별도의 UITapGestureRecognizer가 없어도 동작할 수 있다. 대신에 누르고 있는 동작에 대해서는 별도의 처리가 필요하다.

여기서는 기본적인 탭 카운터를 구현하면서 버튼을 누르고 있으면 빠르게 카운터가 올라가는 기능을 구현해보도록 하겠다.

기본 준비

Xcode 프로젝트를 하나 생성하고, 스토리보드에는 UILabel 하나와 UIButton 하나를 뷰에 올려서 배치한다. 이 둘은 각각 뷰 컨트롤러에 아웃렛으로 연결해준다.

다음은 기본적인 탭 카운터에 대한 뷰 컨트롤러의 코드이다.

class ViewController : UIViewController {
    @IBOutlet weak var button: UIButton!
    @IBOutlet weak var label: UILabel!
    var tap: Int = 0 {
        didSet {
            label.text = "\(tap)"
        }
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // 버튼에 액션을 연결하는 것은 인터페이스 빌더에서 해도 된다.
        label.text = "tap the button"
        button.addTarget(self, action: #selector(self.tapped), for: .touchUpInside)
    }

    @IBAction func tapped() {
        tap += 1
    }
}

이상의 코드를 작성하고 앱을 실행하면, 메시지와 버튼이 표시된다. 버튼을 누를 때마다 컨트롤러의 tap값이 1씩 증가하고, 그에 따라 레이블의 표시되는 값이 변경된다.

UILongPressGestureRecognizer

터치를 누르고 있는 상태를 감지하는 것은 하나의 단일 터치 액션이 아닌 일정한 시간 간격에 따른 연속동작이므로 이를 이벤트 핸들링 메소드를 이용해서 구현하는 것이 쉽지 않다. 다행히 UIKit은 UILongPressGestureRecognizer라는 클래스를 제공하여 이러한 제스쳐를 감지한다. 이 ‘길게 누르기’ 동작은 다음의 몇 가지 단계로 나뉘어진다.

  1. 손가락이 화면에 닿는다.
  2. 일정한 시간 한계값에 이를 때까지 터치가 유지된다.
  3. 임계 시간 이내에 특정한 범위 밖으로 터치가 이동하는 경우에는 패닝이나 스와이프가 되어야 하므로 제스쳐가 성립하지 않는다.
  4. 임계 시간을 넘기면 press 제스쳐가 성립한다. 이후에는 손가락이 움직여도 제스쳐가 유지되는 것으로 본다.
  5. 손가락을 떼면 제스쳐가 종료된다.

여기서는 손가락을 누르고 있는 동안에 계속해서 데이터의 변경이 일어나야 하므로 제스쳐 하나를 일련의 이벤트 시퀀스로 봐야 한다. 또 누르고 있는 중간에 손가락이 움직이지 않는다면 이벤트가 업데이트 되지 않으므로 타이머를 이용해서 값을 주기적으로 업데이트한다.

  1. 제스쳐가 성립되면 제스쳐의 상태는 .began이 된다. 이 때 타이머를 생성하고 런루프에 추가해야 한다.
  2. 제스쳐가 움직이는 이벤트는 무시한다.
  3. 제스쳐가 종료 혹은 취소되면 타이머를 해제(invalidate)한다.

타이머

Swift3에서는 NSTimer가 아니라 Timer를 이용한다. 최신 OS 버전에서는 타이머가 수행해야 하는 동작을 타깃-액션 방식이 아니라 클로저를 이용해서 정의할 수도 있다. 자세한 것은 Timer 클래스의 레퍼런스 페이지를 참고하자.

class ViewController : UIViewController {
    var timer: Timer? // 1

    override func viewDidLoad() {
        super.viewDidLoad()
        ...
        let press = UILongPressGestureRecognizer()
        press.addTarget(self, action: #selector(self.pressed(_:)))
        button.addGestureRecognizer(press)
    }

    func pressed(_ gesture: UIGestureRecognizer) {
        guard let ges = gesture as? UILongPressGestureRecognizer else { return }
        switch ges.state {
        case .began:
            timer?.invalidate()
            timer = Timer.scheduledTimer(withTimeInterval: 0.1, target: self, selector: #selector(self.tapped), userInfo: nil, repeats: true)
        case .ended, .cancelled:
            timer?.invalidate()
        default:
            break
        }
    }
}       

타이머는 다음과 같이 만들 수도 있다.

timer = Timer(timeInterval: 0.1, repeats: true, block: { [unowned self] (_) in 
    self.tap += 1
})

이 때 유의해야하는 점은 타이머 객체가 클로저를 소유하게 되므로 다음과 같은 참조 관계가 발생한다는 것이다.

ViewController ==> timer ==> closure
      ^=========================|

이는 강한 참조 순환을 발생시켜 메모리릭의 원인이 된다. 따라서 타이머 생성시에 넘겨주는 클로저는 반드시 [unowned self] 캡쳐 리스트를 추가해주어야 한다. 참고로 이 클로저의 제 1 파라미터는 타이머 객체 자신이다.

여담

Timer 객체를 생성만 한 경우에는 이 타이머는 아무 일도 하지 않는다. fire()를 호출하는 것은 단지 수행하기로 한 액션을 1회만 발화하며, 반복적인 트리거링을 위해서는 수동으로 런루프에 연결해야 한다. 이는 다음과 같이 처리할 수 있다.

RunLoop.main.addTimer(timer, for: .defaultRunLoopMode)

참고로 invalidate() 를 받아 해제된 타이머는 그대로 소멸하므로 런루프에서 따로 제거하지 않아도 자동으로 제거된다.