Danny의 iOS 컨닝페이퍼
article thumbnail

[TIL #9] 2023 / 04 / 03 ~ 2023 / 04 / 06 

사진을 가져오려 하는데 iOS 14 이상부터는 UIImagePickerController 대신

PHPickerViewController를 사용하라고 하더라고요.

 

그래서 오늘은 PHPickerViewController에 대해서 알아보겠습니다.

 

 

PHPickerViewController

공식 문서 링크

PhotoKit   WWDC2021

 

정보가 많이 없어 공식문서와 WWDC를 참고해서 만들어 봤습니다.

틀린 내용 있으면 댓글 부탁드려요!

 

한번 간략하게 설명해 보겠습니다.

 

PHPickerViewController는 iOS 14에서 새로 추가됐고

기존에 사용하던 UIImagePickerController와 같이

이미지나 비디오 같은 미디어들을 선택하는 기능을 제공하지만, 조금 더 유연하게 사용이 가능합니다.

 

간단히 추가된 기능을 설명하면

 

다중 선택

사용자가 동시에 여러 미디어 항목을 선택이 가능.

 

PHPickerResult

미디어의 정보를 나타내는 객체입니다.

assetIdentifier와 itemProvider를 통해 미디어에 접근할 수 있습니다.

자세한 동작 방식은 WWDC를 참고해 주세요.

(참고 "사용방법 - 2"에 간단히 설명해 놨습니다.)

 

또한

사용자가 허락한 미디어만 사용가능(보안 개선) 및 이미지를 안정적으로 처리한다고 합니다.

 

그럼 한번 사용방법을 알아봅시다.

 

 

사용 방법 - 1

이 방법은 이미지를 한 개만 불러올 때 사용하면 좋은 방법입니다.

 

일단 itemProvider는 미디어 정보가 들어있습니다.

타입이 NSItemProvider이라서 loadObject를 통해 원하는 미디어로 변환하여 사용이 가능하죠.

설명은 주석 및 다음 예제를 참고해 주세요.

 

일단 전역변수로 itemProvider들을 담을 수 있게 만들어 줍니다.

private var itemProviders: [NSItemProvider] = []

 

Picker를 설정하고 present 하는 메서드를 만들어 줬습니다.

여기선 사진 선택을 한 개만 가능하게 설정해 줬습니다.

private func presentPicker() {
    // PHPickerConfiguration 생성 및 정의
    var config = PHPickerConfiguration()
    // 라이브러리에서 보여줄 Assets을 필터를 한다. (기본값: 이미지, 비디오, 라이브포토)
    config.filter = .images
    // 다중 선택 갯수 설정 (0 = 무제한)
    config.selectionLimit = 1
    
    let imagePicker = PHPickerViewController(configuration: config)
    imagePicker.delegate = self
    
    self.present(imagePicker, animated: true)
}

 

PHPickerViewControllerDelegate에서

키패스를 통해 itemProvider들을 생성한 전역변수에 담아 줍시다.

extension ViewController : PHPickerViewControllerDelegate {

    // picker가 종료되면 동작 함
    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        
        // picker가 선택이 완료되면 화면 내리기
        picker.dismiss(animated: true)
        
        // 만들어준 itemProviders에 Picker로 선택한 이미지정보를 전달
        itemProviders = results.map(\.itemProvider)
    }
}

 

사용자가 선택한 이미지 정보인 itemProvider를 갖고 

이미지를 화면에 올리는 작업을 해봅시다.

private func displayImage() {
    // 사진이 한 개이므로 first로 접근하여 itemProvider를 생성
    guard let itemProvider = itemProviders.first else { return }
    
    // 만약 itemProvider에서 UIImage로 로드가 가능하다면?
    if itemProvider.canLoadObject(ofClass: UIImage.self) {
        // 로드 핸들러를 통해 UIImage를 처리해 줍시다.
        itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
            
            guard let self = self,
                  let image = image as? UIImage else { return }
                  
            // loadObject가 비동기적으로 처리되기 때문에 UI 업데이트를 위해 메인쓰레드로 변경
            DispatchQueue.main.async {
                self.imageView.image = image
            }
        }
    }
}

 

다시 델리게이트로 돌아와서 분기처리를 해줍시다.

extension ViewController : PHPickerViewControllerDelegate {
    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        
        picker.dismiss(animated: true)
        
        itemProviders = results.map(\.itemProvider)
        
        // 👉 만약 itemProviders가 있다면 (즉, 이미지를 선택했다면), 위에 만든어준 displayImage 실행
        if !itemProviders.isEmpty {
            displayImage()
        }
    }
}

 

완성

 

 

사용 방법 - 2

이번에는 assetIdentifier를 이용해서 여러 개의 이미지를 순서에 맞게 불러와 보겠습니다.

(또한, 이미지 선택할 때 틱 모양이 아닌 숫자로 나오게 만들 예정입니다.)

 

라이브러리 - BSImagePicker 의 이미지 다중 선택 기능과 비슷하도록 만들어봤습니다.

 

관련 정보가 별로 없어서, 기능 하나 구현하는데 시간이 너무 오래 걸렸네요 ㅠ.ㅠ

 

예제 코드가 많이 어지러운데...

혹시나 애플 내장 라이브러리만 갖고 만들어보고 싶으신 분은 참고해 보세요.

 

생각한 기능을 넣으면 이런 식으로 만들어지네요.

(다중 이미지 선택, 숫자로 이미지 선택 표현, 선택한 순서대로 이미지 정렬해서 생성)

 

 

여기서는 전역변수로 두 가지를 저장해 줍시다.

// Identifier와 PHPickerResult로 만든 Dictionary (이미지 데이터를 저장하기 위해 만들어 줌)
private var selections = [String : PHPickerResult]()
// 선택한 사진의 순서에 맞게 Identifier들을 배열로 저장해줄 겁니다. 
// selections은 딕셔너리이기 때문에 순서가 없습니다. 그래서 따로 식별자를 담을 배열 생성
private var selectedAssetIdentifiers = [String]()

 

마찬가지로 PHPicker를 설정을 해주고 present를 시키는 메서드를 만들어줍니다.

(설정할 코드가 조금 더 많습니다. 주석을 참고해 주세요.)

private func presentPicker() {
    // 이미지의 Identifier를 사용하기 위해서는 초기화를 shared로 해줘야 합니다.
    var config = PHPickerConfiguration(photoLibrary: .shared())
    // 라이브러리에서 보여줄 Assets을 필터를 한다. (기본값: 이미지, 비디오, 라이브포토)
    config.filter = PHPickerFilter.any(of: [.images])
    // 다중 선택 갯수 설정 (0 = 무제한)
    config.selectionLimit = 3
    // 선택 동작을 나타냄 (default: 기본 틱 모양, ordered: 선택한 순서대로 숫자로 표현, people: 뭔지 모르겠게요)
    config.selection = .ordered
    // 잘은 모르겠지만, current로 설정하면 트랜스 코딩을 방지한다고 하네요!?
    config.preferredAssetRepresentationMode = .current
    // 이 동작이 있어야 PHPicker를 실행 시, 선택했던 이미지를 기억해 표시할 수 있다. (델리게이트 코드 참고)
    config.preselectedAssetIdentifiers = selectedAssetIdentifiers
    
    // 만들어준 Configuration를 사용해 PHPicker 컨트롤러 객체 생성
    let imagePicker = PHPickerViewController(configuration: config)
    imagePicker.delegate = self
    
    self.present(imagePicker, animated: true)
}

여기서는 preselectedAssetIdentifiers 설정이 중요합니다. (선택한 사진을 기억하게 하는 기능)

 

간단히 예를 들어 설명하겠습니다.

 

사진을 몇 개 선택하Add버튼을 누르면 사진들이 저장이 되겠죠?

이 설정을 해주지 않고 다시 Picker로 들어가 보면, 이전에 선택했던 이미지들이 체크가 안된 상태로 나타납니다.

(그럼 사용자가 무슨 사진을 선택했었는지 알 수 가 없겠죠?!)

 

하지만 델리게이트를 통해 선택한 이미지들preselectedAssetIdentifiers 담아준 뒤,

Picker를 다시 실행한다면, 기존에 선택했던 이미지들이 체크되어 나타나게 됩니다!

(아래 이미지 참고)

 

 

이제 델리게이트를 정의해 봅시다.

extension ViewController : PHPickerViewControllerDelegate {
    // picker가 종료되면 동작 합니다.
    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        
        // picker가 선택이 완료되면 화면 내리기
        picker.dismiss(animated: true)
        
        // Picker의 작업이 끝난 후, 새로 만들어질 selections을 담을 변수를 생성
        var newSelections = [String: PHPickerResult]()
        
        for result in results {
            let identifier = result.assetIdentifier!
            // ⭐️ 여기는 WWDC에서 3분 부분을 참고하세요. (Picker의 사진의 저장 방식)
            newSelections[identifier] = selections[identifier] ?? result
        }
        
        // selections에 새로 만들어진 newSelection을 넣어줍시다.
        selections = newSelections
        // Picker에서 선택한 이미지의 Identifier들을 저장 (assetIdentifier은 옵셔널 값이라서 compactMap 받음)
        // 위의 PHPickerConfiguration에서 사용하기 위해서 입니다.
        selectedAssetIdentifiers = results.compactMap { $0.assetIdentifier }
    }
}

 

제 생각에 여기서 가장 중요한 부분은 별표 부분입니다.

이 부분만 간단히 짚고 넘어가겠습니다.

 

여기선 Picker의 동작방식을 알야 하는데요.

일단 PHPickerResult는 itemProvider와 assetIdentifier 두 가지 값으로 저장이 됩니다.

 

Picker에서 사진을 선택하고 추가 버튼을 누르면

PHPickerViewController에서 App으로 PHPickerResult(사진과 식별자)를 전달하게 되죠.

 

이후 만약 다시 Picker로 들어가 이미지를 선택할 경우

이전에 선택한 이미지의 식별자현재 선택한 이미지의 식별자같다면,

itemProvider의 데이터, 즉 이미지는 전달하지 않습니다.

 

왜냐하면 이미 App에서 이미지를 사용 중이기 때문에,

이미지를 다시 만들어 전달하게 되면 불필요한 데이터 전송이 발생하고 앱의 성능도 떨어지겠죠?

아마도 그럴 것 같아요...

 

아무튼 그래서

App으로 전달된 이미지를 저장하기 위해서 처음에 전역변수 selections를 만들어 준겁니다.

 

그렇다면 별표 부분의 동작 방식은

만약 같은 식별자를 갖고 있으면 selections을 통해 미리 저장된 이미지를 사용하고

식별자가 없다면 PHPickerResult를 통해 새로운 이미지를 저장해 주게 되겠죠!!!

 

 

제가 설명을 잘 못해서... 일단 사진도 첨부합니다...

 

처음에 사진을 선택하면 이와 같이 PHPickerResult를 전달을 할겁니다.

 

그리고 다시 Picker로 들어가 사진을 선택한다고 해봅시다.

 

여기 상황은 기존의 "식별자 1"은 그대로 유지하고

"식별자 2"는 선택을 해제하고 "식별자 3"을 추가한 뒤 완료버튼을 누른 상황입니다.

 

아래와 같이 식별자가 같다면 "빈 itemProvider"를 전달하게 됩니다. (빈 사진을 전달)

그리고 선택이 해제되면 사라지고, 추가하면 새로운 PHPickerResult를 받겠죠?

요런 느낌입니다.

자세한 내용은 WWDC 를 참고해 주세요.

 

 

다음으로 일단 이미지를 받아서 스택뷰에 추가시키는 작업을 만들어 줬습니다.

 

이미지를 스크롤 뷰나 콜렉션 뷰로 받는 게 깔끔할테지만,

여기서는 간단히 만들 예정이므로 StackView와 ImageView만을 사용했습니다.

private func addImage(_ image: UIImage) {
    let imageView = UIImageView()
    imageView.image = image
            
    imageView.snp.makeConstraints {
        $0.width.height.equalTo(200)
    }
    
    stackView.addArrangedSubview(imageView)
}

 

이제 화면에 올려 봅시다. 처음 방법이랑 비슷합니다.

itemProvider로 접근해서 loadObject의 핸들러를 통해 UIImage로 변환하여 사용합니다.

일단 아래 코드는 실패 예제입니다

private func displayImage() {
    // 처음 스택뷰의 서브뷰들을 모두 제거함
    self.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
        
    for (_, result) in selections {
        
        let itemProvider = result.itemProvider
        // 만약 itemProvider에서 UIImage로 로드가 가능하다면?
        if itemProvider.canLoadObject(ofClass: UIImage.self) {
            // 로드 핸들러를 통해 UIImage를 처리해 줍시다. (비동기적으로 동작)
            itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
                
                guard let self = self,
                      let image = image as? UIImage else { return }
                
                DispatchQueue.main.async {
                    self.addImage(image)
                }
            }
        }
    }
}

이 부분에서 멘탈이 터졌는데요...

loadObject는 비동기적으로 itemProvider를 대기열로 올려 동작한다고 하네요.

그래서 그런지 이미지의 순서가 뒤죽 박죽 되더라고요.

(크기가 작은 이미지부터 나와요... ㅠ.ㅠ)

 

DispatchSemaphore를 사용해서 순서대로 받을 수도 있겠지만,

쓰레드 수를 줄이는 것 보다 이 방식이 더 빠를 것 같고 어짜피 result 값이 딕셔너리라서 더 복잡할 것 같아서...

 

그래서 그냥 DispatchGroup으로 이미지를 모두 받아 저장한 뒤

로직을 추가해 순서대로 이미지를 받아 봤습니다.

private func displayImage() {
    
    let dispatchGroup = DispatchGroup()
    // identifier와 이미지로 dictionary를 만듬 (selectedAssetIdentifiers의 순서에 따라 이미지를 받을 예정입니다.)
    var imagesDict = [String: UIImage]()

    for (identifier, result) in selections {
        
        dispatchGroup.enter()
                    
        let itemProvider = result.itemProvider
        // 만약 itemProvider에서 UIImage로 로드가 가능하다면?
        if itemProvider.canLoadObject(ofClass: UIImage.self) {
            // 로드 핸들러를 통해 UIImage를 처리해 줍시다. (비동기적으로 동작)
            itemProvider.loadObject(ofClass: UIImage.self) { image, error in
                
                guard let image = image as? UIImage else { return }
                
                imagesDict[identifier] = image
                dispatchGroup.leave()
            }
        }
    }
    
    dispatchGroup.notify(queue: DispatchQueue.main) { [weak self] in
        
        guard let self = self else { return }
        
        // 먼저 스택뷰의 서브뷰들을 모두 제거함
        self.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
    
        // 선택한 이미지의 순서대로 정렬하여 스택뷰에 올리기
        for identifier in self.selectedAssetIdentifiers {
            guard let image = imagesDict[identifier] else { return }
            self.addImage(image)
        }
    }
}

 

마지막으로 PHPickerViewControllerDelegate로 다시 돌아와

로직을 추가해 줍시다.

extension ViewController : PHPickerViewControllerDelegate {
    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        
        picker.dismiss(animated: true)
        
        var newSelections = [String: PHPickerResult]()
        
        for result in results {
            let identifier = result.assetIdentifier!
            newSelections[identifier] = selections[identifier] ?? result
        }
        
        selections = newSelections
        selectedAssetIdentifiers = results.compactMap { $0.assetIdentifier }
        
        // 👉 만약 비어있다면 스택뷰 초기화, selection이 하나라도 있다면 displayImage 실행
        if selections.isEmpty {
            stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
        } else {
            displayImage()
        }
    }
}

 

완성

 

풀 코드는 GitHub 를 참고하세요

 

 

마무리

PHPickerViewController를 정리하다 보니 아직도 남은 API가 있더라고요.

PHPhotoLibrary(사진 라이브러리에서 사용자가 허락한 미디어 사용) 이런 게 남아있는데...

이건 다음번에 사용할 때 한번 정리해 보도록 하겠습니다.

 

이번에도 WWDC 도움을 많이 받았던 것 같네요.

자주 챙겨봐야겠습니다.

 

아 참고로 PHPickerViewController의 뷰 계층은 비공개이므로 상속을 못한다고 합니다.

 

 

반응형
profile

Danny의 iOS 컨닝페이퍼

@Danny's iOS

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!