핵심 포인트
Pinch-to-Zoom(핀치)은 표시된 콘텐츠의 배율을 변경하기 위해 두 손가락을 사용하는 멀티터치 제스처입니다. 손가락을 모으면(pinch close) 배율이 감소하고, 벌리면(pinch open) 증가합니다. 제스처는 연속적이며, 시스템은 실시간으로 손가락 사이의 거리를 추적하고 중간 이벤트를 생성하여 애플리케이션이 배율을 부드럽게 업데이트할 수 있도록 합니다.
제스처의 메커니즘은 두 터치 포인트 사이의 거리 계산을 기반으로 합니다. iOS와 Android는 유클리드 거리를 사용합니다: sqrt((x2−x1)² + (y2−y1)²). 현재 배율은 현재 거리와 초기 거리의 비율로 계산됩니다. 초기 거리가 100px이고 150px이 되면 배율 = 1.5(50% 증가)입니다. iOS는 이전 상태에 상대적인 배율(델타)을 전달하고, Android는 ScaleGestureDetector를 통해 누적 배율을 전달합니다.
Google Material Design Guidelines에 따르면, Pinch-to-Zoom은 이미지, 지도 또는 문서를 표시하는 애플리케이션에 필수입니다. IT Sectr에서는 시각적 콘텐츠를 다루는 모든 프로젝트에서 UIPinchGestureRecognizer와 ScaleGestureDetector를 사용하여 이 제스처를 구현합니다 — 갤러리에서 이미지 편집기까지.
UIPinchGestureRecognizer는 핀치 제스처를 인식하기 위한 UIGestureRecognizer의 연속 하위 클래스입니다. 개별 인식기(UITapGestureRecognizer)와 달리, .began(손가락이 화면에 닿음), .changed(각 손가락 움직임), .ended(손가락을 뗌)의 이벤트 시퀀스를 생성합니다.
주요 속성: scale(CGFloat) — 제스처 시작 이후 손가락 사이 거리의 상대적 변화; velocity(CGFloat) — 초당 배율 변화율. 초기 배율 = 1.0. 손가락을 벌리면 배율이 증가하고(1.0 → 1.5 → 2.0), 모으면 감소합니다(1.0 → 0.8 → 0.5). velocity 속성은 관성에 사용되며, .ended 후에 감속과 함께 연속적인 스케일링을 애니메이션화할 수 있습니다.
UIPinchGestureRecognizer는 다른 인식기(예: 지도에서 pan + pinch)와 동시 인식을 위한 delegate 속성을 지원합니다. gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) 메서드는 true를 반환하며, 두 제스처가 병렬로 작동합니다. Apple은 개별 요소 대신 컨테이너 뷰에 UIPinchGestureRecognizer를 추가할 것을 권장합니다 — 이렇게 하면 스케일링 피벗 포인트 계산이 간소화됩니다.
ScaleGestureDetector는 멀티터치 스케일링을 인식하도록 설계된 Android Support Library(android.view)의 클래스입니다. GestureDetector.onFling과 달리, ScaleGestureDetector는 특히 두 손가락 줌을 위해 구축되었으며 focusX, focusY(손가락 사이의 중심점) 및 scaleFactor(이전 이벤트의 스케일 델타)를 자동으로 계산합니다.
ScaleGestureDetector는 세 가지 메서드가 있는 OnScaleGestureListener 인터페이스를 통해 작동합니다: onScaleBegin(detector)(.began에 해당), onScale(detector)(.changed에 해당, boolean 반환 — 스케일이 수락되면 true), onScaleEnd(detector)(.ended에 해당). 속성: scaleFactor — 이전 이벤트에 상대적인 스케일 승수(천천히 벌리면 약 1.01), focusX/focusY — 손가락 사이 중심점의 좌표.
View에 스케일링을 적용하려면 일반적으로 Matrix가 사용됩니다: view.imageMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY). Matrix는 변환 누적을 지원하며, 각 postScale 호출은 기존 변환에 새 변환을 추가합니다. 중요한 점: ScaleGestureDetector는 최소/최대 배율을 제한하지 않습니다 — 이는 개발자의 책임입니다. IT Sectr에서는 사용자 지정 배율 경계(0.5–3.0)와 초과 시 반환 애니메이션이 있는 ScaleGestureDetector를 사용합니다.
Pinch-to-Zoom을 구현할 때 최소 및 최대 배율을 제한하는 것이 매우 중요합니다 — 이렇게 하지 않으면 사용자가 무한대로 확대하거나 보이지 않을 때까지 축소할 수 있습니다. 표준 값: 최소 = 0.5(원본 크기의 50%), 최대 = 3.0(300%). iOS에서는 클램프를 통해 제한합니다: newScale = min(max(currentScale * gesture.scale, minScale), maxScale). Android에서는 postScale 후 검사를 통해 제한합니다.
피벗 포인트는 손가락 사이의 중심점이며, 이를 기준으로 스케일링이 수행됩니다. iOS는 두 터치의 중심을 자동으로 계산하여 location(in:)을 통해 전달합니다 — 개발자가 수동으로 피벗을 계산할 필요가 없습니다. Android는 ScaleGestureDetector에서 focusX/focusY를 제공하지만, Matrix를 통해 적용할 때 피벗은 postScale(scaleFactor, scaleFactor, focusX, focusY)의 세 번째 및 네 번째 인수로 전달됩니다. 피벗 포인트가 없으면 스케일링이 왼쪽 상단 모서리(0,0)를 기준으로 수행되어 줌 중에 콘텐츠가 이동합니다.
IT Sectr의 경험에 따르면, 올바른 피벗 포인트 계산은 Pinch-to-Zoom 구현 시 가장 흔한 버그의 원인입니다. 90%의 경우, GestureRecognizer 및 ScaleGestureDetector의 내장 좌표를 사용하는 것으로 충분하며 피벗을 수동으로 계산할 필요가 없습니다. 스케일링 중 콘텐츠가 이동하면 focusX/focusY를 matrix.postScale()에 전달하고 있는지 확인하세요.
제스처 종료 후 부드러운 UX를 위해 관성을 추가할 수 있습니다: iOS에서는 UIView.animate 및 스프링 매개변수와 함께 UIPinchGestureRecognizer의 velocity를 사용하고, Android에서는 OverScroller 또는 ValueAnimator를 사용합니다. 관성은 최소/최대 배율 경계를 초과하지 않아야 하며, 제한을 초과하면 스프링 반환 애니메이션으로 최종 값이 제한됩니다.
0.5에서 3.0까지의 배율 제한으로 UIImageView에 UIPinchGestureRecognizer를 추가합니다. 누적 배율을 유지하는 최소 구현을 보여줍니다.
import UIKit
class ZoomableImageViewController: UIViewController {
@IBOutlet private var imageView: UIImageView!
private var currentScale: CGFloat = 1.0
private let minScale: CGFloat = 0.5
private let maxScale: CGFloat = 3.0
override func viewDidLoad() {
super.viewDidLoad()
let pinch = UIPinchGestureRecognizer(
target: self,
action: #selector(handlePinch(_:))
)
imageView.addGestureRecognizer(pinch)
imageView.isUserInteractionEnabled = true
}
@objc private func handlePinch(_: UIPinchGestureRecognizer) {
if gestureRecognizer.state == .began
|| gestureRecognizer.state == .changed {
let newScale = currentScale * gestureRecognizer.scale
imageView.transform = CGAffineTransform(
scaleX: min(max(newScale, minScale), maxScale),
y: min(max(newScale, minScale), maxScale)
)
}
if gestureRecognizer.state == .ended {
currentScale = min(max(
currentScale * gestureRecognizer.scale,
minScale
), maxScale)
gestureRecognizer.scale = 1.0
}
}
}
로직: .began/.changed에서는 클램프와 함께 CGAffineTransform을 통해 배율이 적용됩니다. .ended에서는 배율이 1.0으로 재설정되고 누적 값이 currentScale에 저장됩니다. 이는 반복적인 제스처 중 배율 드리프트를 방지하며, 각 새 제스처는 현재 상태에서 시작됩니다. 이미지의 경우 CGAffineTransform 대신 minimumZoomScale/maximumZoomScale이 있는 UIScrollView가 권장됩니다.
Matrix와 함께 ScaleGestureDetector를 통해 ImageView 스케일링을 구현합니다. 올바른 피벗 포인트 처리와 배율 제한을 보여줍니다.
import android.content.Context
import android.graphics.Matrix
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.widget.ImageView
class PinchZoomImageView(context: Context) : ImageView(context) {
private val matrix = Matrix()
private var currentScale = 1f
private val minScale = 0.5f
private val maxScale = 3.0f
private val scaleDetector =
ScaleGestureDetector(context, ScaleListener())
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
currentScale = (currentScale * detector.scaleFactor)
.coerceIn(minScale, maxScale)
matrix.postScale(
currentScale,
currentScale,
detector.focusX,
detector.focusY
)
imageMatrix = matrix
return true
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
return true
}
}
사용자 지정 ImageView는 onTouchEvent를 재정의하여 ScaleGestureDetector에 이벤트를 전달합니다. OnScaleListener.onScale()은 각 손가락 움직임 시 호출됩니다. detector.scaleFactor는 델타입니다(천천히 벌리면 1.01). currentScale은 클램핑을 위해 coerceIn으로 누적됩니다. focusX/focusY — 손가락 사이의 중심점으로, 올바른 피벗 스케일링을 위해 postScale에 전달됩니다.
UIScrollView는 델리게이트를 통해 내장된 Pinch-to-Zoom 지원을 제공합니다. 이것이 이미지와 문서에 선호되는 접근 방식입니다 — ScrollView가 배율, 관성 및 스크롤을 자동으로 관리합니다.
class ScrollableImageViewController: UIViewController,
UIScrollViewDelegate {
@IBOutlet private var scrollView: UIScrollView!
@IBOutlet private var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
scrollView.minimumZoomScale = 0.5
scrollView.maximumZoomScale = 3.0
scrollView.delegate = self
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
UIScrollView는 자동으로 UIPinchGestureRecognizer를 생성하고, CALayer 변환을 통해 스케일링을 관리하며, 경계를 초과할 때 바운스 효과를 추가합니다. 코드는 min/maxZoomScale 설정 및 viewForZooming 반환으로 축소됩니다. IT Sectr에서는 모든 전체 화면 이미지에 UIScrollView를 사용합니다 — 이는 UIPinchGestureRecognizer를 통한 수동 구현에 비해 코드 양을 5배 줄여줍니다.
자주 묻는 질문
터치 스크린에서 핀치는 두 손가락 사이의 거리 추적과 함께 두 손가락을 사용합니다. MacBook 트랙패드에서는 동일한 상태(.began → .changed → .ended)의 UIPinchGestureRecognizer를 통해 줌이 구현되지만, 터치 소스는 정전식 화면이 아닌 Force Touch 트랙패드입니다. API는 동일하고 메커니즘은 동일합니다.
iOS에서: UIPinchGestureRecognizer에서 배율을 얻은 후 currentScale을 곱하고 clamp(min: 0.5, max: 3.0)을 적용합니다. Android에서: matrix.postScale() 전에 coerceIn(minScale, maxScale)을 사용합니다. UIScrollView에서는 minimumZoomScale 및 maximumZoomScale 속성을 통해 제한합니다. 제한이 없으면 배율이 무한대나 0이 될 수 있습니다.
아니요, 핀치는 최소 두 손가락이 필요합니다. 한 손가락 줌(더블 탭 + 위/아래로 드래그)의 경우 UIPanGestureRecognizer 또는 GestureDetector.onScroll을 통한 사용자 지정 구현이 필요합니다. 이것은 다른 로직을 가진 다른 제스처이며, 일반적으로 카메라나 지도에서 줌에 사용됩니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.