PhotosPicker 사용하기
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 SwiftUI의 PhotosPicker를 사용하여 사진 라이브러리에서 사진을 가져오는것을 학습해보겠습니다 🙋🏻
iOS 16.0 이전 기존에는 SwiftUI에서 PhotosPicker 같은 편리한 뷰 컴포넌트가 존재하지 않았기에 PHPhotoLibrary를 이용해 사용하곤 했습니다.
그런데 이제 PhotosPicker을 통해 쉽게 SwiftUI스럽게 만들어볼 수 가 있게 되었어요.
그럼 바로 알아볼까요?
PhotosPicker
해당 컴포넌트는 사진 라이브러리 즉, 디바이스의 앨범에서 에셋을 선택하기 위해 Photo Picker를 띄우는 뷰 컴포넌트입니다.
쉽게 말해, 앨범을 띄우는 기능을 가진 뷰라고 보시면 될것 같아요 🙋🏻
아쉽지만, 위에서도 한번 말했듯 해당 컴포넌트는 iOS 16.0 이상에서만 사용할 수 있어요 🥲
@MainActor @preconcurrency
struct PhotosPicker<Label> where Label : View
선언을 보시면 이렇게 UI 관련 뷰 작업 컴포넌트이기에 메인 스레드에서 실행을 보장하도록 되어 있고, preconcurrency가 붙어 있는건 동시성과 관련있어 동시성 호환을 보장하기 위한 용도입니다.
자세하게는 조만간 관련 포스팅으로 정리해볼까해요!
그리고 해당 뷰 컴포넌트는 View를 제네릭하게 받고 있습니다.
즉, 해당 PhotoPicker를 띄울 뷰를 담아주겠죠?
기본적으로는 이렇게 사용할 수 있어요.
import SwiftUI
import PhotosUI
struct ContentView: View {
@state var selectedItems: [PhotosPickerItem] = []
var body: some View {
PhotosPicker(
selection: $selectedItems,
matching: .images
) {
Text("Select Multiple Photos")
}
}
}
사용을 위해선 PhotosUI 프레임워크를 채택해줍니다.
그리고, 선택된 사진 에셋들을 담을 PhotosPickerItem 배열 타입의 상태 프로퍼티를 만들어요.
여기서 PhotoPickerItem은 PhotoPicker와 함께 사용하는 항목을 나타내는 타입입니다.
이제 기본적으로 준비가 되었다면, PhotosPicker 뷰 컴포넌트를 호출하여 사용하면 됩니다.
여기 선택된 에셋 인자라는 selection에다가 위에서 만든 selectedItems를 바인딩 시켜주고 매칭 타입을 적절히 선택해줍니다.
그리고 마지막으로, 제네릭하게 뷰를 받을 클로저에 원하는 뷰를 구성해줍니다.
즉, 해당 Select Multiple Photos 텍스트 뷰가 이제는 사진 앨범을 열어줄 트리거가 되는거라고 보면 됩니다.
해당 PhotosPicker를 이용할때는 단일 에셋을 선택할거냐 다중 에셋을 선택할거냐의 차이부터 이미지만 띄울건지 비디오 등의 에셋들도 제공해서 선택할 수 있게하는지 그리고 더 나아가 최대 몇개까지만 선택할 수 있게 하는지등의 구현도 커스텀하게 할 수 있죠.
먼저 예를들어, 사진을 가져오는데 스크린샷을 제외한 이미지만 가져오도록 matching의 값을 변경할 수 있어요.
PhotosPicker(
selection: $selectedItems,
matching: .any(of: [.images, .not(.screenshots)])
) {
Text("Select Photos")
}
matching은 PHPickerFilter 타입으로 해당 케이스들이 정말 다양하게 존재합니다.
원하는 필터 조건을 자유롭게 걸 수 있죠!
이제 중요한 하나의 부분으로 선택한 에셋을 가지고 SwiftUI 이미지와 진행 상황을 추적할 수 있어요.
바로 loadTransferable(type:completionHandler:) 메서드를 사용하면 됩니다.
즉, 아래와 같이 사용할 수 있어요.
func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress {
return imageSelection.loadTransferable(type: Image.self) { result in
DispatchQueue.main.async {
guard imageSelection == self.imageSelection else { return }
switch result {
case .success(let image?):
// Handle the success case with the image.
case .success(nil):
// Handle the success case with an empty value.
case .failure(let error):
// Handle the failure case with the provided error.
}
}
}
}
PhotoPickerItem은 Transferable을 준수하고 있으며, 요청에 대해 로드할 수 있게 됩니다.
즉 쉽게 말해 아래 단계로 생각해볼 수 있어요.
1️⃣ PhotoPickerItem을 사용해서 이미지 선택
2️⃣ loadTranferable을 사용해서 이미지 로드
3️⃣ 로딩 현황이나 성공 실패에 대한 처리 등을 후속으로 원하면 구현
여기서, 에러가 나는 케이스는 시스템이 데이터 검색을 시도할 때 오류가 날 수 있습니다.
Picker가 네트워크 연결이 되어 있지 않은 상태에서 iCloud 사진에 접근하여 다운하려고하면 당연히 에러가 나겠죠?
또 하나 중요한 사실 🙋🏻
SwiftUI의 Image는 기본적으로 PNG 파일 형식만을 지원합니다.
그래서 다른 이미지 형식을 지원하기 위해서는 사용자 정의 Transferable 모델을 만들어야 합니다.
자세하게 알고 싶다면 아래 레퍼도 참고해보면 좋겠습니다 😃
자 이제 그럼 간단히 PhotosPicker가 무엇인지 알았으니, 실제 여러 인자들을 커스텀하게 사용해보면서 알아볼까요?
실제 사용해보기
저는 우선, 컨셉을 여러장을 선택하되 최대 선택할 수 있는 갯수의 제한을 주고 싶어요.
그리고, 이미지 타입만 필터를 걸어 나타내고 가져올 수 있도록 하며 실제 최대 갯수만큼 선택이 되었을때는 해당 뷰와 기능을 비활성화를 하고 싶습니다.
그리고 제일 중요한, PhotosPicker를 여러번 띄워 동일한 이미지를 선택하면 중복은 제거하도록 체킹도 하고 싶습니다.
이 조건들을 부합하도록 한번 커스텀하게 PhotosPicker를 이용해 뷰 컴포넌트를 만들어보겠습니다!
코드부터 보시죠 🙏🏻
import PhotosUI
import SwiftUI
public struct GreenPhotoPicker<Content: View>: View {
@State private var selectedPhotos: [PhotosPickerItem]
@Binding private var selectedImages: [UIImage]
@Binding private var isPresentedError: Bool
private let maxSelectedCount: Int
private var disabled: Bool {
selectedImages.count >= maxSelectedCount
}
private var availableSelectedCount: Int {
maxSelectedCount - selectedImages.count
}
private let matching: PHPickerFilter
private let photoLibrary: PHPhotoLibrary
private let content: () -> Content
public init(
selectedPhotos: [PhotosPickerItem] = [],
selectedImages: Binding<[UIImage]>,
isPresentedError: Binding<Bool> = .constant(false),
maxSelectedCount: Int = 5,
matching: PHPickerFilter = .images,
photoLibrary: PHPhotoLibrary = .shared(),
content: @escaping () -> Content
) {
self.selectedPhotos = selectedPhotos
self._selectedImages = selectedImages
self._isPresentedError = isPresentedError
self.maxSelectedCount = maxSelectedCount
self.matching = matching
self.photoLibrary = photoLibrary
self.content = content
}
public var body: some View {
PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: availableSelectedCount,
matching: matching,
photoLibrary: photoLibrary
) {
content()
.disabled(disabled)
}
.disabled(disabled)
.onChange(of: selectedPhotos) { _, newValue in
handleSelectedPhotos(newValue)
}
}
private func handleSelectedPhotos(_ newPhotos: [PhotosPickerItem]) {
for newPhoto in newPhotos {
newPhoto.loadTransferable(type: Data.self) { result in
switch result {
case .success(let data):
if let data = data, let newImage = UIImage(data: data) {
if !selectedImages.contains(where: { $0.pngData() == newImage.pngData() }) {
DispatchQueue.main.async {
selectedImages.append(newImage)
}
}
}
case .failure:
isPresentedError = true
}
}
}
selectedPhotos.removeAll()
}
}
코드가 길어보이지만 어렵지 않습니다!
하나씩 볼까요?
먼저 타입 선언을 보면 View를 제네릭하게 받습니다.
즉, 해당 뷰 컴포넌트를 호출 할 때 동작시킬 뷰를 전달해줄 수 있다는 소리죠.
그리고 프로퍼티로는 아래와 같이 존재합니다.
1️⃣ selectedPhotos - PhotoPicker와 상호 연동할 수 있는 선택된 Item 배열 상태 프로퍼티
2️⃣ selectedImages - 실제 해당 컴포넌트를 호출한 상위 뷰와 바인딩 시킬 이미지 배열 프로퍼티
3️⃣ isPresentedError - 로드 중 실패 시 상위 뷰에 에러를 띄워주기 위해 감지하는 바인딩 프로퍼티
4️⃣ maxSelectedCount - Item을 선택할 수 있는 최대 갯수
5️⃣ disabled - 최대 갯수가 선택된 여부에 따라 뷰와 기능을 비활성화 시킬 연산 프로퍼티
6️⃣ availableSelectedCount - 현재 선택된 아이템의 갯수를 가지고 최대 갯수와의 차이를 구하여 남은 선택 가능한 갯수 연산 프로퍼티
7️⃣ matching - 필터 타입 변수
8️⃣ photoLibrary - PHPhotoLibrary 타입의 인스턴스 변수
9️⃣ content - 제네릭하게 받아온 View
초반 프로퍼티가 조금 많지만 구현은 별거 없어요.
body를 보시면 PhotosPicker를 이용하여 selection부터 photoLibrary 인자까지 적절히 넣어줍니다.
그리고, 이스케이핑 후행 클로저로 content 즉, 넘겨받아온 뷰를 담아주기만 하면 되죠!
해당 Picker에 disabled와 onChange 뷰 모디파이어가 붙어 있는것을 볼 수 있습니다.
disabled는 앞서 설명했듯이 현재 선택한 이미지 갯수가 최대 상한 갯수에 도달했을때 뷰와 기능을 비활성화 시켜줍니다.
그리고, onChange 뷰 모디파이어에 selectedPhotos로 감지하고 있는데요.
즉, 에셋에서 선택한 아이템의 변화가 있으면 내부 로직인 handleSelectedPhotos 메서드를 실행시켜줍니다.
newValue인 새로 업데이트된 값을 넘겨줌으로써요.
마지막으로, handleSelectedPhotos 메서드 로직을 봐볼께요 🙋🏻
PhotosPickerItem 배열 타입의 값인 newValue를 받아왔겠죠?
여기서 반복을 돌아주면서 앞서 설명한 선택된 에셋에 대해 로드하고 진행상황을 추적하도록 해주는 loadTranferable 메서드를 이용합니다.
성공 시 해당 이미지가 아직 selectedImages에 없는, 즉 기존에 없던 이미지라면 추가해주고 아니라면 추가해주지 않습니다.
여기서 pngData로 체크하는 이유는 그냥 Data를 가지고 체크하면 다시 Picker에서 같은 데이터를 선택하더라도 주소값을 다르게 가져가기에 동일한 이미지였는지 체크할 수 없기에 사용합니다.
그리고 로드에 실패한다면 isPresentedError의 값을 변경하여 호출한 상위 뷰에서 얼럿을 띄워주거나 후속 작업을 해줄 수 있도록 해줍니다.
그리고 selectedPhotos를 마지막에 전부 삭제해줌으로 다시 PhotosPicker를 노출했을때 선택했던 이미지의 선택 상태가 풀려있도록 해줍니다.
(이건 구현하시는 용도에 맞게 자유롭게 해줘도 될것 같네요 😃)
자 그럼 이제 한번 실제 뷰에서 얹어서 사용해볼까요?
@State selectedImages: [UIImage]
...
GreenPhotoPicker(selectedImages: $selectedImages) {
Text("사진 골라")
.padding()
.background(Color.green)
.foregroundColor(.white)
.cornerRadius(10)
}
...
이렇게 핵심 부분만 가져와봤어요.
호출부에서 자유롭게 해당 방식처럼 뷰 컴포넌트를 호출하면서 selectedImages 상태 변수만 바인딩 해주면됩니다.
그럼 실행 시켜볼까요?
이렇게 사진을 선택하면 연동되어 나타나는걸 볼 수 있습니다 👍
마무리
나름 간단하게 PhotosPicker를 톺아보면서 입맛대로 커스텀하게도 구현해봤습니다.
생각보다 대응이 잘되긴해 보입니다.
물론 제 입맛이기에 사용하시는분들은 더 여러분의 입맛에 맞게 사용하시면 좋을것 같아요!