본문 바로가기
Unity/API - RectTransform

RectTransform 이해하기: Pan & Pinch Zoom 구현

by PlaneK 2020. 8. 18.

Pan & Pinch Zoom 구현

터치한 영역을 기준으로 확대/축소 되는 '핀치 줌'과

두 손가락 '패닝'을 

RectTransformscaleanchoredPosition 속성으로 구현해보자.

일단 튜토리얼 문서에서 Mobile Input을 간단히 알아보자.

 

Getting Mobile Input - Unity Learn

Modern mobile devices have screens that can receive accurate multitouch inputs from the user and from multiple device sensors. In this tutorial you will learn how to get inputs from the touchscreen and accelerometer, and build a basic "pinch to zoom" mecha

learn.unity.com

위 유니티 Tutorial 문서에는 Mobile Input의 사용 사례로 Zoom을 소개한다.
CameraFieldOfView 또는 orthographicSize 속성으로 Zoom을 구현했는데,
offset을 적용하지 않아서 항상 스크린의 중심을 기준으로 Zoom 된다.

 

더보기

# 전체 소스 ...

using UnityEngine;
 
public class ZoomAndPanStudy : MonoBehaviour
{
    [SerializeField] private RectTransform _zoomTargetRt;
 
    private readonly float _ZOOM_IN_MAX = 16f;
    private readonly float _ZOOM_OUT_MAX = 1f;
    private readonly float _ZOOM_SPEED = 1.5f;
    
    private bool _isZooming = false;
 
    private void Update()
    {
        if (Input.touchCount == 2)
        {
            ZoomAndPan();
        }
        else
        {
            _isZooming = false;
        }
    }
 
    private void ZoomAndPan()
    {
        if (_isZooming == false)
        {
            _isZooming = true;
        }
 
        /* get zoomAmount */
        var prevTouchAPos = Input.GetTouch(0).position - Input.GetTouch(0).deltaPosition;
        var prevTouchBPos = Input.GetTouch(1).position - Input.GetTouch(1).deltaPosition;
        var curTouchAPos = Input.GetTouch(0).position;
        var curTouchBPos = Input.GetTouch(1).position;
        var deltaDistance =
            Vector2.Distance(Normalize(curTouchAPos), Normalize(curTouchBPos))
            - Vector2.Distance(Normalize(prevTouchAPos), Normalize(prevTouchBPos));
        var currentScale = _zoomTargetRt.localScale.x;
        var zoomAmount = deltaDistance * currentScale * _ZOOM_SPEED; // zoomAmount == deltaScale
 
        /* clamp & zoom */
        var zoomedScale = currentScale + zoomAmount;
        if (zoomedScale < _ZOOM_OUT_MAX)
        {
            zoomedScale = _ZOOM_OUT_MAX;
            zoomAmount = 0f;
        }
        if (_ZOOM_IN_MAX < zoomedScale)
        {
            zoomedScale = _ZOOM_IN_MAX;
            zoomAmount = 0f;
        }
        _zoomTargetRt.localScale = zoomedScale * Vector3.one;
 
        /* apply offset */
        // offset is a value against movement caused by scale up & down
        var pivotPos = _zoomTargetRt.anchoredPosition;
        var fromCenterToInputPos = new Vector2(
                Input.mousePosition.x - Screen.width * 0.5f,
                Input.mousePosition.y - Screen.height * 0.5f);
        var fromPivotToInputPos = fromCenterToInputPos - pivotPos;
        var offsetX = (fromPivotToInputPos.x / zoomedScale) * zoomAmount;
        var offsetY = (fromPivotToInputPos.y / zoomedScale) * zoomAmount;
        _zoomTargetRt.anchoredPosition -= new Vector2(offsetX, offsetY);
 
        /* get moveAmount */
        var deltaPosTouchA = Input.GetTouch(0).deltaPosition;
        var deltaPosTouchB = Input.GetTouch(1).deltaPosition;
        var deltaPosTotal = (deltaPosTouchA + deltaPosTouchB) * 0.5f;
        var moveAmount = new Vector2(deltaPosTotal.x, deltaPosTotal.y);
 
        /* clamp & pan */
        var clampX = (Screen.width * zoomedScale - Screen.width) * 0.5f;
        var clampY = (Screen.height * zoomedScale - Screen.height) * 0.5f;
        var clampedPosX = Mathf.Clamp(_zoomTargetRt.localPosition.x + moveAmount.x, -clampX, clampX);
        var clampedPosY = Mathf.Clamp(_zoomTargetRt.localPosition.y + moveAmount.y, -clampY, clampY);
        _zoomTargetRt.anchoredPosition = new Vector3(clampedPosX, clampedPosY);
    }
 
    private Vector2 Normalize(Vector2 position)
    {
        var normlizedPos = new Vector2(
            (position.x - Screen.width * 0.5f) / (Screen.width * 0.5f),
            (position.y - Screen.height * 0.5f) / (Screen.height * 0.5f));
        return normlizedPos;
    }
}
 

# 전체 소스 닫기

더보기

# Pinch zoom - 기본원리 ...

Pinch zoom - 기본원리

Zoom을 할 대상 RectTransform

Render ModeScreenSpace - CameraCanvas의 하위 개체로 가정한다.

Mobile Input의 정보로 delta값을 계산해

그 값을 RectTransform.scale에 더해줘서 zoom을 구현한다.

그리고 Zoom의 기준점을 중앙으로부터

터치하는 곳으로 옮기기 위해 offset 만큼 pivot을 이동시킨다.

# Pinch zoom - 기본원리 닫기

더보기

# Pinch zoom - 줌 값, zoomAmount 구하기 ...

Pinch zoom - 줌 값, zoomAmount 구하기

Scale에 반영할 줌의 양인 zoomAmount를 구해보자. 줌 값은 다음 조건을 충족해야한다.

1) 확대와 축소를 구분할 수 있다.

2) 디스플레이의 해상도에 상관없이 제스처의 동일 범위에 대해 줌 양이 동일해야한다.

3) Scale이 커지면 커질수록 줌 양이 따라서 커져야 한다.

예를들어 일정한 배율로 확대할 경우, 배율이 높을 수록 상대적으로 덜 확대되는 것 처럼 보인다.
따라서 Scale이 크면 클수록 줌 양이 커져야 상대적으로 일정한 양으로 줌이 되는 것 처럼 보인다.

 

var prevTouchAPos = Input.GetTouch(0).position - Input.GetTouch(0).deltaPosition;
var prevTouchBPos = Input.GetTouch(1).position - Input.GetTouch(1).deltaPosition;
var curTouchAPos = Input.GetTouch(0).position;
var curTouchBPos = Input.GetTouch(1).position;

일단 델타 값을 구하기 위해 각 input으로부터 현재 터치와 이전 터치의 position을 구하자.

var deltaDistance =
    Vector2.Distance(Normalize(curTouchAPos), Normalize(curTouchBPos))
    - Vector2.Distance(Normalize(prevTouchAPos), Normalize(prevTouchBPos));

구한 position으로 현재 터치와 이전 터치의 Distance를 구하고 거리 차이인 deltaDistance를 계산한다.

이 델타 값은 양수 또는 음수로 나타나기 때문에 확대, 축소를 구분할 수 있다.

private Vector2 Normalize(Vector2 position)
{
    var normlizedPos = new Vector2(
        (position.x - Screen.width * 0.5f) / (Screen.width * 0.5f),
        (position.y - Screen.height * 0.5f) / (Screen.height * 0.5f));
    return normlizedPos;
}
 

Input.GetTouch 함수는 픽셀좌표를 반환한다. 

그래서 각 디바이스에 동일한 제스쳐 범위를 취해도 해상도가 달라서 반환되는 좌표가 다를 수 있다.

얻어온 Input 좌표를 Normalize 해서 동일한 위치에 대해 모든 디바이스가 같은 값을 반환하도록 하자.

var zoomAmount = deltaDistance * currentScale * _ZOOM_SPEED; // zoomAmount == deltaScale

현재 스케일 값인 currentScale을 배수로 해서 배율에 따라 줌 값을 가변시킨다.

(_ZOOM_SPEED는 크기가 큰 디스플레이의 제스쳐 범위을 덜어주기 위한 값이다.)

# Pinch zoom - 줌 값, zoomAmount 구하기 닫기

더보기

# Pinch zoom - Clamp와 Zoom ...

Pinch Zoom - Clamp 와 Zoom

/* clamp & zoom */
var zoomedScale = currentScale + zoomAmount;
if (zoomedScale < _ZOOM_OUT_MAX)
{
    zoomedScale = _ZOOM_OUT_MAX;
    zoomAmount = 0f;
}
if (_ZOOM_IN_MAX < zoomedScale)
{
    zoomedScale = _ZOOM_IN_MAX;
    zoomAmount = 0f;
}
_zoomTargetRt.localScale = zoomedScale * Vector3.one;

zoomedScale은 방금 구했던 zoomAmount를 반영한 값이다

zoomedScale을 체크해서 최소, 최대 배율범위를 벗어나지 않도록 클램프 시키자.

클램프된 zoomedScale 값을 줌 대상의 localScale 속성에 대입하자.

그러면 pivot을 중심으로 Scale이 커지거나 작아진다.

클램프 할 때 zoomAmount를 0으로 초기화 하는 이유는 offset을 0으로 만들기 위함이다.
줌을 안했으면 offset만큼 옮겨주지 않아도 되기 때문이다.

# Pinch zoom - Clamp와 Zoom 닫기

더보기

# Pinch zoom - offset 적용 ...

Pinch Zoom - offset 적용

스케일을 조정해서 줌을 했지만 offset을 적용하지 않았기 때문에 중앙을 기준으로 zoom 됐다.

줌 기준점을 터치한 영역의 중앙으로 보여지기 위해서

스케일이 커진 대상의 pivot을 이동시켜야 한다.

Scale이 커지면서 터치영역이 이동했다. 그 이동을 상쇄시켜야 한다.

offset을 구하기 위해 우선 pivot을 기준으로하는 input 벡터가 필요하다.

var pivotPos = _zoomTargetRt.anchoredPosition;
var fromCenterToInputPos = new Vector2(
        Input.mousePosition.x - Screen.width * 0.5f,
        Input.mousePosition.y - Screen.height * 0.5f);
var fromPivotToInputPos = fromCenterToInputPos - pivotPos;

Input 포지션의 x와 y를 각각 해상도의 중간값 만큼 감해줘서

스크린 중앙으로부터의 벡터로 만들자.

input 좌표기준을 pivot과 일치시키기 위한 작업이다.

pivot의 앵커 위치는 중앙이기 때문에 pivotPos는 (0, 0)이며 Canvas의 설정대로 Screen좌표 상을 이동한다.

그에 반해 Input.mousePosition좌측 하단을 (0, 0), 우측 상단을 (x, y)로 된 좌표를 기준으로 값을 반환한다.

(Input.mousePosition 은 터치들의 중간 지점을 ScreenSpace좌표로 반환함.)

그리고 pivot으로부터 input까지의 벡터를 구하면 offset의 재료가 된다.

var offsetX = (fromPivotToInputPos.x / zoomedScale) * zoomAmount;
var offsetY = (fromPivotToInputPos.y / zoomedScale) * zoomAmount;
_zoomTargetRt.anchoredPosition -= new Vector2(offsetX, offsetY);

대상이 x2, x3으로 Scale이 커져도 scale이 커짐으로써 이동하는 양은 x1의 양과 동일하게 유지된다.

그런데 Scale이 커지면 fromPivotToInput이 배율만큼 커지게 된다.

다시 x1 배율의 크기로 되돌리기위해 zoomedScale을 인수로 나눠줘야 한다.

그렇게 구해진 offset을 pivot에 빼기연산을 해주면

줌 되는 기준이 스크린 중앙이 아닌 터치영역 가운데로 위치하게 된다.

# Pinch zoom - offset 적용 닫기

더보기

# Pan - moveAmount 구하기 ...

Pan - moveAmount 구하기

/* get moveAmount */
var deltaPosTouchA = Input.GetTouch(0).deltaPosition;
var deltaPosTouchB = Input.GetTouch(1).deltaPosition;
var deltaPosTotal = (deltaPosTouchA + deltaPosTouchB) * 0.5f;
var moveAmount = new Vector2(deltaPosTotal.x, deltaPosTotal.y);

패닝은 간단하다. 대상의 pivpot은 RenderMode가 ScreenSpace인 Canvas를 상위개체로 한다.

그래서 Screen 좌표와 일치하기 때문에 ScreenSpace 좌표를 반환하는 

Input.GetTouchdeltaPosition을 사용하면 된다.

단, 두손가락 패닝이므로 delta값이 의도보다 2배 오버됐으므로 0.5를 인수로 계산한다

# Pan - moveAmount 구하기 닫기

더보기

# Pan - Clamp와 Pan ...

Pan - Clamp와 Pan

/* clamp & pan */
var clampX = (Screen.width * zoomedScale - Screen.width) * 0.5f;
var clampY = (Screen.height * zoomedScale - Screen.height) * 0.5f;
var clampedPosX = Mathf.Clamp(screenTr.localPosition.x + moveAmount.x, -clampX, clampX);
var clampedPosY = Mathf.Clamp(screenTr.localPosition.y + moveAmount.y, -clampY, clampY);
screenTr.anchoredPosition = new Vector3(clampedPosX, clampedPosY);

대상이 Screen 범위 내에서만 이동하도록 Clamp를 걸자.

Screen에 여백을 안보이게 하려면 일단 Screen과 대상 RectTransform의 견적을 봐야한다.

X축의 경우는 위와 같다. 클램프의 범위를 구하고 Clamp 함수에 사용하자.

# Pan - Clamp와 Pan 닫기

 

 

댓글