일기 어플리케이션을 만들전 중에 일기와 함께 사진을 업로드하는 기능을 구현했고, 디바이스에서 불러온 사진은 collecionView
로 스와이프하여 볼 수 있는 형태로 구현했는데, 핀치 줌(Pinch to Zoom)이 가능한 별도의 ViewController
가 필요하다는 생각이 들었습니다.
1. 일기를 작성하는 화면이므로 사진은 간략히 보여주고 있어, 1:1비율로 잘려서 보인다는 점
2. 따라서 원본사진을 편리하게 볼 수 있는 인터페이스를 가진 별도의 ViewController가 필요하다는 점
1. 화면에 사진의 원본을 보여줄 것
2. 스와이프로 다음 사진을 보여줄 것
3. 핀치 줌(Pinch-to-Zoom)으로 사진을 확대할 수 있을 것
4. 더블 탭(Double tap)으로 사진을 확대/축소 할 수 있을 것.
5. 사진을 아래로 스와이프하여 dimiss시킬 것
우선 일기 작성 뷰에서 원하는 이미지를 탭했을 때 해당 이미지를 핀치 줌이 가능한 이미지 뷰어로 보여주도록 구현했습니다.
실제 프로젝트에서는 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)
}
}
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)
}
}
두 번째 단계에서는 WriteDiaryVC에서 전달받은 이미지 배열과 index를 이용해 UICollectionView로 사용자가 좌우로 스와이프 할 수 있는 이미지 슬라이더를 만들어 줍니다.(사실 그냥 화면에 꽉 찬 CollectionView와 Cell을 말합니다.)
그리고 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은 각 이미지를 표시하는 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
}
}
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
}
}
이제 각 이미지에 Pinch Zoom 기능을 추가합니다.
UIScrollView의 minimumZoomScale
과 maximumZoomScale
속성을 설정하여, 사용자가 이미지를 얼마나 축소하거나 확대할 수 있는지 제한합니다. 이 두 속성은 사용자가 이미지를 너무 많이 확대하거나 축소하여 이미지가 너무 작아지거나 크게 보이는 것을 방지합니다.
private lazy var scrollView: UIScrollView = {
let scrollView = UIScrollView(frame: bounds)
scrollView.delegate = self
scrollView.minimumZoomScale = 1.0 // 최소 줌 스케일
scrollView.maximumZoomScale = 4.0 // 최대 줌 스케일
return scrollView
}()
이미지를 다른 이미지로 변경할 때나 사용자가 다른 이미지를 선택했을 때 초기 줌 상태로 돌아가야 합니다. 이를 위해 resetZoomScale
메소드를 구현하여, 스크롤 뷰의 줌 스케일을 초기값으로 재설정할 수 있습니다.
func resetZoomScale() {
scrollView.zoomScale = 1.0
}
configure(with:)
메소드 내에서 resetZoomScale
을 호출하여 이미지가 새로 설정될 때마다 줌 스케일을 초기화합니다.
func configure(with image: UIImage) {
imageView.image = image
imageView.frame = scrollView.bounds
resetZoomScale()
}
UIScrollViewDelegate
프로토콜의 viewForZooming(in:)
메소드를 구현하여, 어떤 뷰가 확대/축소될 것인지 UIScrollView에 알려줍니다. 이 경우, 우리는 이미지 뷰(imageView)가 줌 대상임을 지정합니다.
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
이번에는 ImageZoomCollectionViewCell에 "더블탭하여 줌" 기능과 "아래로 쓸어내려 dismiss(닫기)" 기능을 추가합니다. 이 기능들은 사용자 경험을 크게 향상시키며, 이미지 뷰어의 상호작용성을 높입니다.
더블탭 제스처를 추가하여 사용자가 이미지 위에서 더블탭을 할 때 이미지를 확대하거나, 이미 확대된 상태에서는 이미지를 원래 크기로 돌리는 기능입니다.
더블탭 제스처 설정: 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()
를 호출하여 셀에 더블탭 제스처를 추가합니다.
사용자가 이미지를 아래로 드래그할 때 뷰 컨트롤러를 닫을 수 있도록 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
}