사진 뷰어 앱을 만들어봤다.

Tabber·2021년 10월 1일
2

개인프로젝트

목록 보기
2/2
post-thumbnail

이 글에 적혀있는 정보들 중 틀린게 있을 수 있습니다! 공부를 하면서 적는 것이니 양해 부탁드립니다 :)
틀린게 있다면 언제든 댓글 부탁드립니다!

라이브러리 없이 사진 뷰어 앱을 만들어보세요.

스터디를 진행하며 받은 과제 중, 하나의 과제였다. 사실 예전에 개인 프로젝트로 했던 '라이팅'도 이미지 관련 처리를 라이브러리를 통해 진행하였었다. 따라서 막상 이런 과제를 줬을 땐, 막막하기 그지 없었다.

더군다나, 컬렉션 뷰를 가로로 세팅해보는 법도 이번이 처음이라 많이 두렵기도 했었지만.. 결국엔 해냈다.

사진 선택 처리 부분

UIImagePicker로 하면 되는거 아냐?

라는 안일한 생각부터 시작했다.
따라서 여러 정보들을 찾아보기 시작했었다.
사실 내가 지금까지 해온 것들로는 사진을 단일 선택하여 처리하는 것이 대부분이었다.
따라서 단일 선택 부분에서는 UIImagePicker는 당연히 사용할 수 있는 것이었다.
그러나 돌아오는 답변은,,,

응~ UIImagePicker로는 죽어도 못해~ 어~ 라이브러리 써~
라는 답변밖에 없었다..

어떻게서든 찾아보려는 노력

그러다가 다행히 찾은 방법이 있었다.


애플에서 2020년부터 iOS 14 버전 이상의 기기에서 사용할 수 있는 PHPickerViewController를 제공한다라는 정보를 얻었다.

원래는 사용하지 못하게 했지만,,이 방법말고는 내가 직접 구현을 해야하는 상황이라,,과제를 내주신 분도,,헤매고 있는 나를 안타깝게 여겨 사용하라고 했다..

그래서 당장 사용하기로 했다!

PHPickerViewController 알아보기

2020 WWDC PHPickerView 소개 영상
위의 영상을 보면 대충 이 기능이 어떤 역할을 하는지 알수가 있었다.

대충 어떻게 생겼냐면,

이렇게 Multiselect / Zoom In / Zoom Out 을 지원하는 이미지 셀렉터이다.
그리고 UIImagePicker와 또 다른점은 기존에 사진과 영상같은것에 접근하려면 접근을 시도하는 권한 요청 팝업이 필요하지만, 따로 권한 요청 팝업이 뜨지 않는다는 것이다!

위의 사진에서 볼 수 있듯이, 사용자의 사진을 권한 요청 없이 볼수는 있지만, 직접적으로 사용자 기기의 사진 라이브러리에 접근하는것이 아니라 더욱 보안에 신경 쓸 수 있다고 한다.
물론 추가로 권한 요청을 할 수 있지만, 기본적으로는 요청이 필요하지 않고 바로 사용이 가능하다는 것이 특징이다.

이 사진을 보면 대충 로직이 어떻게 이루어지는지 알 수 있다.
애초에 PHPicker는 현재 돌아가는 앱과는 상관없이 단독적으로 실행되며, 선택된 사진이나 동영상만 현재 실행되는 앱에 넘겨주는 역할을 하게 된다. 이름을 다시 생각해보자면 ViewController 이니 따로 실행되는 화면이 맞는 듯 하다.


위 사진은 PHPickerViewController의 전체적인 동작 흐름이다.
UIImagePicker와 비슷한 흐름인듯 하다!
PHPickerView가 왜 대단하냐면, 기존의 UIImagePicker같은 경우, 사진을 하나씩밖에 선택할 수 있는 반면, 다른 라이브러리를 사용하지 않고 애플의 기본 라이브러리로 멀티 셀렉트를 지원한다는 점에서 대단한 기능이다.

자 그럼 사용해보자!

PHPickerViewController 사용하기

1. import PhotosUI

먼저 사용하기 위해서는 라이브러리를 import 해야한다.

import PhotosUI

원래 이 라이브러리마저 사용하지 않기로 했었지만, 이것 마저 사용하지 못했다면,,지금도 구현하고 있는 상황일 듯 싶다..

아무튼, PhotosUI 안에 위에서 구현해야 할 것들이 들어가 있으니 꼭 import 해줘야 한다!

2. PHPickerConfiguration 생성

var configuration = PHPickerConfiguration()


위 사진에서도 볼 수 있듯, PHPickerConfiguration의 기능은 사진 선택의 한계(개수)를 정할 수 있고, 표시할 아이템을 필터링 할 수 있는 기능이 존재한다.

configuration.selectionLimit = 0
configuration.filter = .any(of: [.images, .livePhotos])

이렇게 선택의 한계를 정할 수 있고(만약 선택을 안하면 기본은 1개 선택으로 되어있다), 0을 넣을 시 무한정 선택하겠다라는 뜻이다.
filter 를 통해 표시할 데이터의 타입을 선택할 수 있다.

3. PHPickerViewController 생성

자 이제 이렇게 만든 PHPickerConfiguration을 PHPickerViewController에 연결해야 한다.

let picker = PHPickerViewController(configuration: configuration)

이렇게 연결하고 PHPickerViewController로 선택된 이미지를 자신에 입맛에 맞게 사용하기 위해서는 delegate를 등록하여 입맛대로 수정해야 한다.

picker.delegate = self

따라서 delegate를 현재 view로 설정 하고,

extension MainViewController: PHPickerViewControllerDelegate {
    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    }
}

위 메서드를 정의해줘야 한다.
선택한 사진에 대해서 뭘 어떻게 하겠다라는 것을 여기에다 정의해주면 된다.

self.present(picker, animated: true, completion: nil)

그리고 아까 delegate 를 정의한 곳에 가서 화면을 보여주는 코드를 작성하면,,!

이렇게 화면이 띄워진다!
근데,,화면만 띄워지고,,Add나 Cancel 버튼이 먹지를 않는다..

당연히 그럴 수밖에 없다! 왜냐!

지금 사진을 선택하고 난 뒤의(그러니까 뷰를 닫을 때쯤의) 동작을 정의하지 않았기 때문이다.

그럼 이제 사진을 여러개 선택하고 난 뒤에 Add 버튼을 누르면 창이 닫히고 선택한 사진을 가져오는 로직을 짜보자!

아 그리고, 이 글에서는 단일 선택 방법에 대해 설명하지 않겠다.
왜냐하면, WWDC 영상에서 너무나도 잘 정리해주고 있기 때문에, 필자가 과제에 사용한 것을 토대로 설명하는 것이 맞다는 판단하에 진행하겠다!

4. NSItemProvider 설정

private var itemProviders: [NSItemProvider] = []
private var iterator: IndexingIterator<[NSItemProvider]>?

먼저 선택한 사진들을 저장하는 배열과 인덱싱에 도움을 줄 이터레이터를 생성한다.
근데, 왜 UIImage같은 형식으로 지정하지 않고 NSItemProvider로 정했을까?


그건 이 사진을 보면 바로 알 수 있는데, 아까 선택한 사진에 대한 기능을 구현할 수 있는 메서드를 소개했었는데, 거기서 선택된 사진에 대해서 따로 받아올 때 선언된 파일 형식이 NSItemProvider인 것이다.

그래서 받아오려는 배열도 NSItemProvider 인것이다!

위 코드를 살펴보자.

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
	// PHPicker 닫기
        picker.dismiss(animated: true, completion: nil)
        // 선택한 사진 배열에 저장
        itemProviders = results.map(\.itemProvider)
        for item in itemProviders {
            if item.canLoadObject(ofClass: UIImage.self) {
                item.loadObject(ofClass: UIImage.self) { image, error in
                    DispatchQueue.main.async {
                        guard let image = image as? UIImage else { return }
                        imageArray.append(image)
                        self.imageCollectionView.reloadData()
                    }
                }
            }
        }
    }

정리를 해보자면, 선택한 사진을 가져온다.
후에 처리한 사진들에 대해 UIImage로 뽑아주고,
비동기 처리를 통해 실질적으로 사용하는 배열에 UIImage를 추가해주어 사용할 수 있게끔 하였고,
위 과제의 특성상 에브리타임의 사진 뷰어처럼 가로로 스크롤 할 수 있는 컬렉션뷰를 구현하였기 때문에 사진을 추가하고 reloadData()를 한 코드이다.

+ 수정

코드를 다시 분석해보니, 내 코드에서는 이터레이터가 필요하지 않았다.
이터레이터는 해당 배열의 데이터가 보이지 않게 for문과 같이 다음 배열로 넘어가려 할 때 사용하는건데, 내 코드에서는 그냥 for문을 사용했다.

따라서 이터레이터를 사용하지 않고 코드를 돌려도 무관하다.

그러면 어떤 화면이 나오냐면,

이렇게 화면이 뜨게 된다!!!

이렇게 PHPickerViewController를 처음 사용해보았는데, 정말 유용하고 편리한 기능인 듯하다.
아쉬운점은 iOS14 버전부터 지원을 해서, 그렇게 범용적으로는 사용할 수 없다라는게 흠이다.

컬렉션뷰 처리 부분

자, 사진을 선택했으면 보여주는 부분 또한 중요하다.
또한 에브리타임의 사진뷰어 처럼 가로로 스크롤되는 기능을 구현해야 해서 처음 해보는 것이라 조금 어색하긴 했지만, 열심히 찾아보면서 공부하기 시작했다.

그런데 생각보다 어려운 것은 아니었다.

1. CollectionView 생성

private let imageCollectionView: UICollectionView = {
        let flowlayout = UICollectionViewFlowLayout()
        flowlayout.minimumLineSpacing = 20
        flowlayout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
        flowlayout.scrollDirection = .horizontal
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowlayout)
        collectionView.backgroundColor = .white
        return collectionView
    }()

컬렉션 뷰 생성은 여느 생성 방법과 다른게 딱히 없다.
조금 살펴봐야 할 것들은,
scrollDirection 방향을 .horizontal 으로 설정해주어야 한다는 점이다.
가로로 사용하고 싶으면 그렇게 하는게 맞다. 그리고 다른 부분은 입맛에 맞게,,하면된다.

그리고 delegate설정 부분은

extension MainViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return imageArray.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ImageCollectionViewCell.cellId, for: indexPath) as? ImageCollectionViewCell else { return UICollectionViewCell() }
        cell.settingImageView = imageArray[indexPath.row]
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let selectViewController = SelectImageViewController(index: indexPath.item)
        selectViewController.modalPresentationStyle = .fullScreen
        present(selectViewController, animated: true, completion: nil)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: collectionView.frame.width - 50, height: collectionView.frame.height)
    }
    
}

다른 것은 다 같은 방법이지만,
맨 아래에 셀 크기 설정 같은 부분은, 가로로 스크롤 시 적당히 보이는 크기로만 설정 해주면 된다.

그러면,,

이런 화면이 나오게 된다.

상세 화면 만들기

자,, 보통 상세 보기 화면은

이런 화면이지 않은가? 이걸 만들라고 했다..(너무하다,,)

그래서 만들었다(?)

사실 뷰 자체는 꾸미는 부분이기에 별로 어려운게 없었다. 하지만, 사진을 움직이고, 확대 축소, 다음 뷰로 넘어갈 때 원래 크기로 복구 하는 이 과정이 너무나도 힘들었다.

사진 줌 아웃 해보기

먼저 사진을 줌하는 방법은 두가지가 존재한다.
1. UIImageView 자체를 키우는 방법
2. 스크롤 뷰를 같이 껴서 확대하는 방법

필자의 경우 스크롤 뷰를 같이 껴서 확대하는 방법을 택했다.
왜 스크롤 뷰가 나오냐면

이렇게 화면에 확대를 할 경우에 확대된 부분 이외의 부분도 스크롤 하여서 봐야하기 때문이다.
근데 1번같은 경우는 이렇게 스크롤 뷰를 사용하지 않았기 때문에, 확대된 부분만 볼 수 있는 단점이 존재한다.
따라서 스크롤 뷰를 통해 구현하였다.

생각보다 간단하다.

contentView.addSubview(scrollView)
scrollView.addSubview(imageViewer)

먼저 스크롤 뷰를 내가 원하는 뷰나 셀에 먼저 등록 시키고, 그 다음에 스크롤 뷰에 이미지 뷰를 넣어주는 방식을 택하면 된다.

그리고 ViewDidLoad()나 (Cell의 경우)init

self.scrollView.minimumZoomScale = 1.0
self.scrollView.maximumZoomScale = 6.0
self.scrollView.delegate = self

이렇게 최대 최소 줌 크기를 설정해주고 제일 중요한 delegate설정을 해준다.

자, delegate를 설정해줬으면 뭐다? 커스텀의 시작이다~

extension DetaillmageCollectionViewCell: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return self.imageViewer
    }
}

먼저 viewForZooming() 메서드를 살펴보겠다.
위 메서드는 이미지 뷰어를 확대시킬 수 있게 해주는 중요한 메서드이다.
이렇게 확대를 원하는 주체를 return 시켜주면 확대 축소가 가능한 상황이 된다.

그 다음 코드는


    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        if scrollView.zoomScale > 0.1 {
            if let image = imageViewer.image {
                let ratioW = imageViewer.frame.width / image.size.width
                let ratioH = imageViewer.frame.height / image.size.height
                let ratio = ratioW < ratioH ? ratioW:ratioH
                let newWidth = image.size.width*ratio
                let newHeight = image.size.height*ratio
                let left = 0.5 * (newWidth * scrollView.zoomScale > imageViewer.frame.width ? (newWidth - imageViewer.frame.width) : (scrollView.frame.width - scrollView.contentSize.width))
                let top = 0.5 * (newHeight * scrollView.zoomScale > imageViewer.frame.height ? (newHeight - imageViewer.frame.height) : (scrollView.frame.height - scrollView.contentSize.height))
                
                scrollView.contentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left)
            }
        }
    }

이게 나에게 제일 고통을 안겨준 코드이다.
scrollViewDidZoom 메서드는 줌 배율을 감지할 수 있는 메서드이다.
그리고 이 감지를 통해서 해당 셀의 사진을 확대 했을 때 끝을 확대 비율 만큼 계산하는 코드이다.
왜 이게 중요하냐면, 이부분을 설정하지 않으면

위처럼 사진의 2배의 공허 공간또한 사진 크기로 인식해 움직일 수 있기 때문이다.

따라서 코드를 적용하고 나면

이렇게 사진 크기만큼 제한이 걸려 잉여공간을 탐색하지 않아도 된다.

셀 이동 후 다시 돌아왔을 때 사진 크기 원상복귀

하,,이거때문에 3시간을 날려버렸다.
하지만 생각보다 간단했다..
먼저 셀의 크기를 설정하려면 스크롤뷰의 줌 크기를 원상복귀하는게 제일 급선무이다.
따라서 해당 스크롤 뷰를 간단하게 설정하면 됐다.

하지만 설정하는 곳은 따로 있다.

func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        if let itemCell = cell as? DetaillmageCollectionViewCell {
            itemCell.scrollView.setZoomScale(1.0, animated: true)
        }
    }

스크롤뷰가 위치해있는 곳이 아닌, 사진을 인덱싱해 움직이는 컬렉션 뷰에서 didEndDisplaying 이 붙은 메서드를 사용하면 움직인 인덱스의 정보가 남기 때문에, 움직일 때마다 방금 봤던 스크롤 뷰의 줌 크기를 원래 상태로 바꾸면 되는 것이었다.

지금 이 코드는 사실 안전하지 않은 코드이긴 한데,, 나중에 보완을 해보도록 하겠다.

그럼 어떻게 나오냐면

이렇게 다른 인덱스를 왔다가 돌아오면 이미지 줌 비율이 원상복귀 된다!

셀 선택 시 그 셀로 이동한 뷰 표시

이것도 생각보다 시간이 많이 걸렸다. 애초에 어떻게 구현해야할지를 몰라서 막막하기까지 했는데,
다행히 방법을 찾아서 구현하였다.

private let index: Int
    
init(index: Int) {
    self.index = index
    super.init(nibName: nil, bundle: nil)
}

먼저 인덱스의 정보가 필요한 곳에 이렇게 init을 달아 정보를 초기화 해줄 수 있도록 설정하였고,

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let selectViewController = SelectImageViewController(index: indexPath.item)
        selectViewController.modalPresentationStyle = .fullScreen
        present(selectViewController, animated: true, completion: nil)
    }

컬렉션 뷰의 메서드 중에 didSelectItemAt 이라는 매개변수가 달려있는 메서드가 있다.
이 메서드는 현재 선택한 셀의 인덱스를 반환하는데, 이 반환한 인덱스 숫자를 아까 init() 으로 설정해놓은 뷰(혹은 셀) 을 불러와서 값을 변경해주면 되는 방법이었다.

그렇게 하면 구동되는 화면이

이렇게 현재 선택한 셀의 사진이 자세히 보기 뷰에 바로 뜨게 되는 것이다.

현재 보고 있는 사진의 순서 라벨 구현

이건 너무 간단해서 설명을 해야하나 싶었지만,, 나중에 나를 위해 써놔야겠다.

일단 자세히 보여주는 뷰에 라벨 하나를 생성해주고,

func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
   imageIndexLabel.text = "\(indexPath.row+1) / \(imageArray.count)"
}

컬렉션 뷰 메서드 중 willDisplay라는 매개변수를 달고있는 메서드가 존재한다.
이 메서드는 이동할 인덱스를 리턴하는 메서드이다.
따라서 이 메서드를 이용해 이동할 인덱스의 정보를 지속적으로 업데이트하는 식으로 코드를 구현하였다.

정리

09월 30일 22:00 ~ 10월 01일 08:30까지 열심히 위 앱을 만들려고 노력했다.
이렇게 글을 정리해보니, 생각보다 한게 많고, 처음 배운것들이 많아서 놀랐다.
위 기술들은 어디에서나 사용할 수 있는 것들이니 나중에라도 꼭 다시 한번 구현해보고 싶다.

이렇게 정리 끝!

profile
iOS 정복중인 Tabber 입니다.

2개의 댓글

comment-user-thumbnail
2021년 10월 1일

고생 많았네!

답글 달기
comment-user-thumbnail
2021년 10월 9일

크으 고생했으

답글 달기