문제 상황

문서 스캔 앱에서 스캔 이미지를 전체화면으로 보여주는 뷰어가 필요했는데요. 핀치 줌으로 확대하고, 확대 상태에서 드래그로 다른 부분을 볼 수 있어야 했어요. 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)

얼핏 동작하는 것 같았는데, 실기기에서 써보니 완전히 다른 이야기였어요.

두 손가락으로 확대 → 한 손가락으로 이동 하면:

  1. 두 손가락 확대는 잘 됨
  2. 한 손가락으로 전환하는 순간 이미지가 정지
  3. 손가락을 떼면 그제야 위치가 갑자기 점프

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 최적화를 가져올 수 있다면 안 쓸 이유가 없죠.