문제 상황
문서 스캔 앱에서 스캔 이미지를 전체화면으로 보여주는 뷰어가 필요했는데요. 핀치 줌으로 확대하고, 확대 상태에서 드래그로 다른 부분을 볼 수 있어야 했어요. SwiftUI로 당연히 될 줄 알았습니다.
삽질 과정
1차: MagnifyGesture만
처음에는 단순하게 MagnifyGesture만 붙였거든요.
Image(uiImage: uiImage)
.scaleEffect(scale)
.gesture(
MagnifyGesture()
.onChanged { value in
scale = max(1.0, value.magnification)
}
.onEnded { _ in
withAnimation { scale = 1.0 }
}
)
손가락을 떼면 원래 크기로 돌아가니까 확대 상태를 유지할 수가 없었어요. 그래서 lastScale을 추가해서 확대를 유지하도록 했는데, 이번엔 확대된 이미지를 이동할 방법이 없더라고요.
2차: MagnifyGesture + DragGesture
그래서 DragGesture를 추가했어요.
Image(uiImage: uiImage)
.scaleEffect(scale)
.offset(offset)
.gesture(magnifyGesture)
.gesture(dragGesture)
얼핏 동작하는 것 같았는데, 실기기에서 써보니 완전히 다른 이야기였어요.
두 손가락으로 확대 → 한 손가락으로 이동 하면:
- 두 손가락 확대는 잘 됨
- 한 손가락으로 전환하는 순간 이미지가 정지
- 손가락을 떼면 그제야 위치가 갑자기 점프
MagnifyGesture가 끝나고 DragGesture가 시작되는 전환 구간에서 제스처 인식이 겹치면서 생기는 문제였어요. .simultaneousGesture로 바꿔봐도 상황은 비슷했고요.
핵심 원인
SwiftUI의 제스처 시스템은 단일 제스처에는 잘 동작하지만, 줌 + 팬을 동시에 자연스럽게 처리하는 건 설계 범위 밖이에요. UIKit의 UIScrollView는 이걸 수십 년 동안 최적화해왔거든요 — 관성 스크롤, 바운스, 줌과 팬의 매끄러운 전환까지.
해결: UIScrollView UIViewRepresentable
결국 UIScrollView를 SwiftUI로 래핑했어요.
struct ZoomableImageView: UIViewRepresentable {
let imageData: Data
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 5.0
scrollView.bouncesZoom = true
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.image = UIImage(data: imageData)
imageView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(imageView)
// Auto Layout으로 imageView를 scrollView에 고정
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor),
])
return scrollView
}
}
Coordinator에서 viewForZooming과 줌 후 이미지 센터링을 처리하면 끝이에요.
class Coordinator: NSObject, UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
scrollView.subviews.first
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
guard let imageView = scrollView.subviews.first else { return }
let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) / 2, 0)
let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) / 2, 0)
imageView.center = CGPoint(
x: scrollView.contentSize.width / 2 + offsetX,
y: scrollView.contentSize.height / 2 + offsetY
)
}
}
더블탭 줌도 UITapGestureRecognizer로 간단하게 추가할 수 있었고요.
주의: 초기 레이아웃
처음에 imageView.frame을 수동으로 설정했더니 흰 화면만 나왔어요. makeUIView 시점에는 scrollView.bounds가 아직 .zero이거든요. Auto Layout 제약으로 바꾸니 바로 해결됐습니다.
배운 점
- SwiftUI 제스처는 단일 인터랙션에 최적화되어 있다. 줌 + 팬처럼 복합 제스처가 필요하면 UIKit 래핑이 더 나은 선택이에요.
- “SwiftUI로 다 할 수 있다"는 환상을 버리면 오히려 빠르다. UIViewRepresentable은 SwiftUI의 약점을 UIKit으로 보완하라고 있는 도구예요. 30줄 코드로 수십 년의 UIKit 최적화를 가져올 수 있다면 안 쓸 이유가 없죠.