[Swift, UIKit] 핀치줌(Pinch to Zoom)이 가능한 ImageViewer 만들기(+ 더블탭 줌)

이경은·2024년 4월 1일
0
post-thumbnail
post-custom-banner

일기 어플리케이션을 만들전 중에 일기와 함께 사진을 업로드하는 기능을 구현했고, 디바이스에서 불러온 사진은 collecionView로 스와이프하여 볼 수 있는 형태로 구현했는데, 핀치 줌(Pinch to Zoom)이 가능한 별도의 ViewController가 필요하다는 생각이 들었습니다.

[적용사유]

1. 일기를 작성하는 화면이므로 사진은 간략히 보여주고 있어, 1:1비율로 잘려서 보인다는 점
2. 따라서 원본사진을 편리하게 볼 수 있는 인터페이스를 가진 별도의 ViewController가 필요하다는 점


[구현하고자 하는 것]

1. 화면에 사진의 원본을 보여줄 것
2. 스와이프로 다음 사진을 보여줄 것
3. 핀치 줌(Pinch-to-Zoom)으로 사진을 확대할 수 있을 것
4. 더블 탭(Double tap)으로 사진을 확대/축소 할 수 있을 것.
5. 사진을 아래로 스와이프하여 dimiss시킬 것



1단계 : 이미지를 탭하여 원본 이미지를 보여주는 뷰어를 나타내기

우선 일기 작성 뷰에서 원하는 이미지를 탭했을 때 해당 이미지를 핀치 줌이 가능한 이미지 뷰어로 보여주도록 구현했습니다.

ViewController 설정

실제 프로젝트에서는 CarouselFlowLayout이라는 커스텀 레이아웃을 사용했지만, 여기서는 1/3 정도 크기의 cell이 있다고 가정하겠습니다.

import UIKit

class WriteDiaryVC: UIViewController {
    var images: [UIImage] = []  // 이 배열에 표시할 이미지들을 저장합니다.
    var collectionView: UICollectionView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configureCollectionView()
    }
    
    func configureCollectionView() {
        let layout = UICollectionViewFlowLayout()
        layout.itemSize = CGSize(width: (view.frame.size.width/3)-4, height: (view.frame.size.width/3)-4)
        layout.minimumInteritemSpacing = 2
        layout.minimumLineSpacing = 2
        
        collectionView = UICollectionView(frame: self.view.frame, collectionViewLayout: layout)
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        view.addSubview(collectionView)
    }
}

UICollectionView DataSource 및 Delegate 구현

UICollectionViewDataSource로 WriteDiaryVC에 보여줄 cell의 갯수와 cell 내의 image를 채우도록합니다.

그리고 UICollectionViewDelegate(didSelectItemAt)을 통해서 동일한 이미지 배열과 사용자가 탭한 cell의 인덱스를 전달하고, ImageZoomCollectionViewController를 호출하도록 구현했습니다.

extension WriteDiaryVC: UICollectionViewDataSource, UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return images.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) else {
            fatalError("Unable to dequeue cell")
        }
        let imageView = UIImageView(image: images[indexPath.row])
        imageView.contentMode = .scaleAspectFill		// 이미지를 cell의 크기에 맞춰 확대 채움
        imageView.clipsToBounds = true					// cell의 크기를 넘어서는 부분을 잘라냄
        cell.contentView.addSubview(imageView)
        imageView.frame = cell.contentView.frame
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        // 여기에서 ImageZoomCollectionViewController를 호출합니다.
        let zoomViewController = ImageZoomCollectionViewController()
        zoomViewController.images = images 					// 동일한 이미지 배열을 전달합니다.
        zoomViewController.initialIndex = indexPath.row 	// 선택된 이미지의 인덱스를 설정합니다.
        self.present(zoomViewController, animated: true, completion: nil)
    }
}

2단계 : UICollectionView로 이미지 슬라이더 만들기

두 번째 단계에서는 WriteDiaryVC에서 전달받은 이미지 배열과 index를 이용해 UICollectionView로 사용자가 좌우로 스와이프 할 수 있는 이미지 슬라이더를 만들어 줍니다.(사실 그냥 화면에 꽉 찬 CollectionView와 Cell을 말합니다.)

ImageZoomCollectionViewController 설정

  • UICollectionView와 함께 UICollectionViewFlowLayout을 사용하여 아이템들이 수평으로 스크롤 되도록 설정합니다.
  • 페이지마다 하나의 이미지씩 표시하도록 페이징 기능을 활성화 합니다.

그리고 WriteDiaryVC에서 선택한 indexPath와 일치하는 Cell을 보여줄 수 있도록scrollToItem(at:)메서드를 viewDidLoad시점에 호출합니다.

import UIkit

class ImageZoomCollectionViewController: UIViewController {
	var images: [UIImage] = []	//이미지가 담긴 배열
    var initialIndex: Int = 0	// 초기에 표시할 인덱스
    
    private lazy var collectionView: UICollectionView = {
    	let layout = UICollectionViewFlowLayout()
    	layout.scrollDirection = .horizontal	// 수평스크롤
    	layout.itemSize = CGSize(width: self.view.bounds.width, height: self.view.bounds.height)	// 화면전체를 CollectionView로 채운다.
    	layout.minimumLineSpacing = 0
    	layout.minimumInteritemSpacing = 0
    
    	let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
    	collectionView.isPagingEnable = true	// 페이징 활성화 여부
    	collectionView.showHorizontalScrollIndicator = false
    	collectionView.delegate = self
    	collectionView.dataSource = self
    	collectionView.register(ImageZoomCollectionViewCell.self, forCellWithReuseIdentifier: ImageZoomCollectionViewCell.reuseIdentifier)
    	return collectionView
    }()
    
	override func viewDidLoad() {
        super.viewDidLoad()
        setupCollectionView()
    }
    
    private func setupCollectionView() {
        view.addSubview(collectionView)
        collectionView.frame = view.bounds
        collectionView.scrollToItem(at: IndexPath(item: initialIndex, section: 0), at: .centeredHorizontally, animated: false)
    }
}

위 코드는 UICollectionView를 초기화하고, 가로 스크롤 방식으로 설정하는 방법을 보여줍니다.

페이지마다 하나의 이미지씩을 표시하도록 paging 기능도 활성화 했습니다.

scrollToItem(at:at:animated:)

at: 스크롤 할 아이템의 위치를 지정. 여기서는 사용자가 선택한 이미지의 인덱스를 기반으로 IndexPath를 생성합니다.

at: 두번째 at은 아이템이 표시될 위치를 지정합니다. 예시에서는 .centeredHorizontally로 수평 중앙에 위치시키도록 하였습니다.

animated:scrollToItem을 애니메이션으로 보여줄지 여부를 결정합니다. 예시는 false로 설정하여 해당위치로 바로 이동합니다.


ImageZoomCollectionViewCell 구성

ImageZoomCollectionViewCell은 각 이미지를 표시하는 cell입니다 추후 Pinch-To-Zoom을 구현할 수 있도록 cell의 크기만큼 UIScollView를 삽입하고, 그 내부로 UIImageView를 포함하도록 하였습니다.

import UIKit

class ImageZoomCollectionViewCell: UICollectionViewCell, UIScrollViewDelegate {
    static let reuseIdentifier = "ImageZoomCollectionViewCell"
    
    private lazy var scrollView: UIScrollView = {
        let scrollView = UIScrollView(frame: bounds)
        scrollView.delegate = self
        return scrollView
    }()
    
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        addSubview(scrollView)
        scrollView.addSubview(imageView)
    }
    
    func configure(with image: UIImage) {
        imageView.image = image
        imageView.frame = scrollView.bounds
    }
}

UICollectionView DataSource 및 Delegate 설정

extension ImageZoomCollectionViewController: UICollectionViewDelegate, UICollectionViewDataSource {
		func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
	        return images.count
        }
    
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ImageZoomCollectionViewCell.reuseIdentifier, for: indexPath) as? ImageZoomCollectionViewCell else {
            fatalError("Unable to dequeue ImageZoomCollectionViewCell")
        }
        let image = images[indexPath.item]
        cell.configure(with: image)
        return cell
    }
}

3단계 : Pinch To Zoom 기능 추가하기

이제 각 이미지에 Pinch Zoom 기능을 추가합니다.

minimumZoomScale 및 maximumZoomScale 설정

UIScrollView의 minimumZoomScalemaximumZoomScale 속성을 설정하여, 사용자가 이미지를 얼마나 축소하거나 확대할 수 있는지 제한합니다. 이 두 속성은 사용자가 이미지를 너무 많이 확대하거나 축소하여 이미지가 너무 작아지거나 크게 보이는 것을 방지합니다.

private lazy var scrollView: UIScrollView = {
    let scrollView = UIScrollView(frame: bounds)
    scrollView.delegate = self
    scrollView.minimumZoomScale = 1.0 // 최소 줌 스케일
    scrollView.maximumZoomScale = 4.0 // 최대 줌 스케일
    return scrollView
}()

resetZoomScale 메소드 구현

이미지를 다른 이미지로 변경할 때나 사용자가 다른 이미지를 선택했을 때 초기 줌 상태로 돌아가야 합니다. 이를 위해 resetZoomScale 메소드를 구현하여, 스크롤 뷰의 줌 스케일을 초기값으로 재설정할 수 있습니다.

func resetZoomScale() {
    scrollView.zoomScale = 1.0
}

configure(with:) 메소드 내에서 resetZoomScale을 호출하여 이미지가 새로 설정될 때마다 줌 스케일을 초기화합니다.

func configure(with image: UIImage) {
    imageView.image = image
    imageView.frame = scrollView.bounds
    resetZoomScale()
}

viewForZooming(in:) 메소드 구현

UIScrollViewDelegate 프로토콜의 viewForZooming(in:) 메소드를 구현하여, 어떤 뷰가 확대/축소될 것인지 UIScrollView에 알려줍니다. 이 경우, 우리는 이미지 뷰(imageView)가 줌 대상임을 지정합니다.

func viewForZooming(in scrollView: UIScrollView) -> UIView? {
    return imageView
}

4단계 : Gesture 추가하기

이번에는 ImageZoomCollectionViewCell에 "더블탭하여 줌" 기능과 "아래로 쓸어내려 dismiss(닫기)" 기능을 추가합니다. 이 기능들은 사용자 경험을 크게 향상시키며, 이미지 뷰어의 상호작용성을 높입니다.

4.1 더블 탭하여 줌 구현

더블탭 제스처를 추가하여 사용자가 이미지 위에서 더블탭을 할 때 이미지를 확대하거나, 이미 확대된 상태에서는 이미지를 원래 크기로 돌리는 기능입니다.

더블탭 제스처 설정: UITapGestureRecognizer를 사용하여 더블탭 제스처를 scrollView에 추가합니다. 더블탭 시 호출될 handleDoubleTap 메서드를 지정합니다.

private func setupDoubleTapGesture() {
    let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
    doubleTapGesture.numberOfTapsRequired = 2
    scrollView.addGestureRecognizer(doubleTapGesture)
}

더블탭 처리: 사용자가 더블탭을 하면, 현재 줌 상태에 따라 이미지를 확대하거나 축소합니다. 더블탭한 지점을 이미지의 중심으로 하여 확대합니다.

@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
    if scrollView.zoomScale > scrollView.minimumZoomScale {
        scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
    } else {
        let zoomRect = zoomRectForScale(scale: scrollView.maximumZoomScale, center: gesture.location(in: gesture.view))
        scrollView.zoom(to: zoomRect, animated: true)
    }
}

private func zoomRectForScale(scale: CGFloat, center: CGPoint) -> CGRect {
    var zoomRect = CGRect()
    let bounds = scrollView.bounds

    zoomRect.size.width = bounds.width / scale
    zoomRect.size.height = bounds.height / scale
    zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
    zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)

    return zoomRect
}

setupViews() 메소드 내에서 setupDoubleTapGesture()를 호출하여 셀에 더블탭 제스처를 추가합니다.

4.2 아래로 쓸어내려 dismiss 구현

사용자가 이미지를 아래로 드래그할 때 뷰 컨트롤러를 닫을 수 있도록 UIPanGestureRecognizer를 추가합니다. 이 기능은 사용자가 빠르게 이미지를 닫고 이전 화면으로 돌아가고 싶을 때 유용합니다.

ImageZoomCollectionViewCell에 다음 코드를 추가하여 팬 제스처를 설정하고, 드래그 동작을 처리합니다.

private func setupPanGesture() {
    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
    panGesture.delegate = self
    scrollView.addGestureRecognizer(panGesture)
}

    @objc func handlePanGesture(_ sender: UIPanGestureRecognizer) {
        let touchPoint = sender.location(in: self.window)
        
        switch sender.state {
        case .began:
            initialTouchPoint = touchPoint
        case .changed:
            // 스크롤 뷰의 Zoom Scale이 1.0일 때만 아래로 스크롤하여 닫는 동작을 활성화
            if scrollView.zoomScale == 1.0 {
                let deltaY = touchPoint.y - initialTouchPoint.y
                
                // 이미지를 아래로 드래그 했을 때
                if deltaY > 0 {
                    imageView.center = CGPoint(x: originalImageCenter!.x, y: originalImageCenter!.y + deltaY)
                }
                
                // 이미지를 아래로 드래그 했을 때
                if touchPoint.y - initialTouchPoint.y > 100 {   // 아래로 100포인트 이상 드래그 했을 때
                    delegate?.cellDidRequestDismiss(self)
                }
            }
        case .ended, .cancelled:
            // 드래그가 끝나면 이미지를 원래 위치로 복원
            if scrollView.zoomScale == 1.0 {
                UIView.animate(withDuration: 0.3, animations: {
                    self.imageView.center = self.originalImageCenter ?? CGPoint(x: self.bounds.size.width / 2, y: self.bounds.size.height / 2)
                })
            }
        default:
            break
        }
    }
}

동시 제스쳐 인식: UIScrollView에서의 핀치 줌과 팬 제스처를 동시에 인식할 수 있도록 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) 메서드를 구현합니다. 이를 통해 사용자가 이미지를 확대한 상태에서도 아래로 쓸어내려 Dismiss할 수 있습니다.

    // 확대 상태에서 panGesture가 가능하도록하는 메서드
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        true
    }
post-custom-banner

0개의 댓글