[IOS] UIGestureRecognizer 사용해서 지도 조작하기
IOS

[IOS] UIGestureRecognizer 사용해서 지도 조작하기

출시 프로젝트에서 사용한 네이버 지도는 기본적인 제스처가 설정돼있다.

일반적으로 Pan 제스처는 카메라를 상하좌우로 이동시키고, Pinch 제스처는 카메라의 확대 축소를 담당한다.

 

그런데 사용자 주변의 특정 장소를 찾는다는 앱의 특성 상 사용자의 위치를 중심으로 지도의 카메라가

움직이는 새로운 조작법을 만들고 싶었다.

 

이 글은 게임 포켓몬고의 제스쳐 액션을 UIGestureRecognizer를 통해 구현한 경험을 정리한 글이다.


1. 분석

포켓몬고의 메인 화면에서 Pan제스처는 카메라의 회전을 담당한다.

캐릭터를 중심으로 원을 그리면서 Panning하면 해당 방향(시계 또는 반시계)으로 카메라가 회전한다.

이 제스처로 사용자 위치 주변을 360도로 둘러볼 수 있다. 

 

또한 포켓몬고의 Pinch 제스처는 카메라의 줌과 기울기(틸트)를 동시에 조절한다.

손가락을 확대하면 카메라가 낮아지면서 내 캐릭터에 가까이 다가가고

손가락을 축소하면 카메라가 높아지면서 캐릭터로부터 멀어지므로 

내 주변의 시야를 넓히거나 좁힐 수 있다.

 

2. 구현

1) UIPanGestureRecognizer

Pan 제스처를 카메라의 회전으로 바꾸기 위해서 UIPanGestureRecognizer

translation(in:)location(in:) 메서드를 활용할 것이다.

 

UIPanGestureRecognizer는 Pan 제스처를 인식할 때마다 Target에 있는

지정된 액션 메서드를 호출하며 translationlocation이라는 특정 값을 보고한다.

 

UIPanGestureRecognizer의 메서드를 사용해서 보고되는 값들을 확인할 수 있는데,

이 값들은 특정 뷰의 좌표계 안에서 발생한 제스처가 CGPoint 타입으로 번역된(interpret) 것이다.

 

location(in:)은 좌표계 평면에서 Pan 제스처가 발생한 위치 좌표를 리턴하고

translation(in:)은 처음 Pan 제스처가 시작된 위치와 현재 제스처가 발생한 위치와의 변위차를 리턴한다.

 

location과 translation

 

따라서 UIPanGestureRecognizer의 액션 메서드가 호출될 때마다 바로 직전의 translation과 

현재 translation을 비교해서 x축, y축으로 얼마만큼 움직였는지 그 변화량을 계산할 수 있다.

 

유의할 점은 translation 값은 처음 제스처가 발생한 위치를 기준으로 하기 때문에

매번 액션 메서드 호출 시 마다 translation을 (0,0)으로 초기화 해주어야 한다.

초기화 해준 뒤 바로 다음에 보고받는 translation 값이 바로 이전 위치와 현재 위치 사이의 변화량이다.

 

이렇게 구한 위치 변화량을 카메라가 회전하는 정도(각)로 변경해주어야 한다.

 

위치 변화량을 각 변화량으로 변환

만약 위 그림처럼 카메라가 뷰의 중심을 기준으로 회전한다고 가정했을때

v1은 시작점이 뷰의 중심이고 끝점이 현재 제스처 위치를 가리키는 벡터이고

v2는 v1에 위치 변화량(translation)을 더한 벡터이다.

 

이때 각 변화량(θ)은 두 벡터 v1과 v2 사이의 각과 같다.

이 변화량은 단위가 라디안이므로 180/Pi를 곱해서 각도를 구해주면 된다.

@objc private func rotateHandler(_ sender: UIPanGestureRecognizer) {
        
        let translation = sender.translation(in: mapView)
        let location = sender.location(in: mapView)
        
        if sender.state == .began {

        } else if sender.state == .changed {
            // rotating map camera
            
            let bounds = mapView.bounds
            let vector1 = CGVector(dx: location.x - bounds.midX, dy: location.y - bounds.midY)
            let vector2 = CGVector(dx: vector1.dx + translation.x, dy: vector1.dy + translation.y)
            let angle1 = atan2(vector1.dx, vector1.dy)
            let angle2 = atan2(vector2.dx, vector2.dy)
            let delta = (angle2 - angle1) * 180.0 / Double.pi
            
            let param = NMFCameraUpdateParams()
            param.rotate(by: delta)
            let update = NMFCameraUpdate(params: param)
            
            mapView.moveCamera(update)

        } else if sender.state == .ended {

        }
        
        sender.setTranslation(.zero, in: mapView)
        
    }

 

2) UIPinchGestureRecognizer

UIPinchGestrueRecognizer는 화면에 터치한 두 손가락이 멀어지거나 가까워 질 때마다

scale이라는 CGFloat 타입의 값을 연속적으로 Target에게 보고한다.

 

scale 프로퍼티는 제스처가 처음 시작됐을 때보다 두 손가락의 거리가 얼마나 달라졌는가를 실수로 나타낸다.

이 값으로 거리 변화량을 계산하여 카메라의 줌과 기울기 값에 적용할 수 있다.

 

제스처가 처음 시작될 때 scale 값이 1.0이므로 액션 메서드를 통해 값이 보고될 때마다

scale값을 1.0으로 초기화해주면 바로 직전 제스처와 현재 제스처 사이의 거리 변화량을 구할 수 있다.

위 그림에서 현재 scale 값이 직전보다 0.2만큼 증가했으므로 두 손가락의 거리 변화량은 0.2이다.

 

이 변화량을 카메라의 줌 값에 더해주면 Pinch 제스처에 따라 카메라를 선형적으로 확대 또는 축소할 수 있다.

 

하지만 카메라의 줌과 기울기는 가질 수 있는 값의 범위가 다르기 때문에

이 변화량을 그대로 기울기에 더할 경우 기울기가 거의 변하지 않는 것 처럼 보인다. 

 

즉, 줌은 값 범위가 작기 때문에 0.2 라는 값을 더한 것으로도 큰 변화가 발생하는 반면에

기울기는 값 범위가 커서 0.2를 값 범위에 맞는 다른 수치로 변경해주어야 한다.

 

따라서 [최소 줌, 최대 줌] 범위에서의 거리 변화량이 [최소 기울기, 최대 기울기] 에서

어떤 값을 갖는지 계산하고 기울기 값에 더해주면 된다.

@objc private func zoomAndTiltHandler(_ sender: UIPinchGestureRecognizer) {
        
        let zoom = currentZoom
        let tilt = currentTilt
        
        let minZoom = mapView.minZoomLevel
        let maxZoom = mapView.maxZoomLevel
        
        if sender.state == .began {

        } else if sender.state == .changed {
            
            let deltaZoom = sender.scale-1
            let deltaTilt = (maxTilt - minTilt) * deltaZoom / (maxZoom-minZoom)
            
            
            let param = NMFCameraUpdateParams()
            param.zoom(by: deltaZoom)
            param.tilt(by: deltaTilt)
            
            let update = NMFCameraUpdate(params: param)
            mapView.moveCamera(update)
            

        } else if sender.state == .ended {
        
        }
        sender.scale = 1
        
    }