포스트

[SwiftUI] PhotosPicker로 사진 추가 기능을 구현하자 - Transferable 프로토콜 이해하기

[SwiftUI] PhotosPicker로 사진 추가 기능을 구현하자 - Transferable 프로토콜 이해하기

후배와 함께 진행하는 프로젝트 중 사용자 프로필 설정 화면을 만들면서 사진을 추가하거나 제거하는 기능을 구현해야 하는 일이 있었습니다.

SwiftUI를 최대한 활용하는 것이 목적이었기에, 대다수의 레퍼런스에서 사용되는 ImagePicker를 래핑하는 방식을 지양하고자 했습니다.

그래서 이를 대체하기 위해 iOS 16부터 지원되는 PhotosPicker를 활용하여 프로필 사진 추가 및 제거 과정을 MVVM 패턴에 따라 정리해보았습니다.

[GitHub PR] 프로필 사진 추가 및 제거 UI와 기능 구현 PR

[FEAT] 프로필 설정 UI 그리기 및 기능 구현

🖼️ PhotosPicker 띄우기

PhotosPicker를 띄우는 방법은 두 가지가 있습니다.

1. PhotosPicker Initializer

첫 번째는 PhotosPicker의 생성자를 이용해 Label에 PhotosPicker가 Present되도록 동작을 연결하는 방식입니다.

코드

1
2
3
4
5
PhotosPicker(
    "사진 선택하기",
    selection: $viewModel.selectedPhoto,
    matching: .images
)

동작

2. PhotosPicker ViewModifier

두 번째는 PhotosPicker를 ViewModifier에 두어 @State 값이 변경될 때 Present되도록 하는 방식입니다.

코드

1
2
3
4
5
.photosPicker(
	isPresented: $isPhotosPickerPresented,
	selection: $viewModel.selectedPhoto,
	matching: .images
)

동작

ViewModifier를 선택한 이유

개인적으로 confirmationDialog(ActionSheet)를 이용해 프로필 변경 동작을 여러 개 선택하기에, 바로 Present되는 방식이 아닌 동작 선택의 콜백으로 PhotosPicker를 띄우고 싶어 두 번째 방식을 선택했습니다.

Selection 파라미터

두 방식 모두 공통으로 사용하는 파라미터가 있습니다. 바로 selection입니다.

selectionPhotosPickerItem 타입을 요구하는 파라미터로, PhotosPicker에서 선택한 사진이 담길 변수를 의미합니다.

따라서 사진을 PhotosPicker로 받아내기 위해서는 해당 타입의 변수를 선언하고 바인딩해줘야 합니다.

📥 전달받은 사진 처리하기

선택된 사진을 PhotosPickerItem으로 전달받으면 우리가 일반적으로 사용하는 Image 타입이 아니기 때문에, 이를 Image에 사용할 수 있도록 변환해줘야 합니다.

1. loadTransferable(type:)를 통해 Data로 변환

PhotosPickerItem에서 비동기적으로 Transferable 타입의 데이터를 로드하는 메서드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI
import PhotosUI
import OSLog

class SetupProfileViewModel: ObservableObject {
    @Published private var profile: Profile
    @Published var selectedPhoto: PhotosPickerItem? {
        didSet { loadImageData(from: self.selectedPhoto) }
    }

	...

    private func loadImageData(from selectedPhoto: PhotosPickerItem?) {
        Task {
            do {
                let data = try await selectedPhoto?.loadTransferable(type: Data.self)
                await MainActor.run { self.profile.updateAvatarImageData(to: data) }
            } catch {
                os_log(.error, "\(#file) \(#function) \(error.localizedDescription)")
            }
        }
    }
}

PhotosPicker로부터 받아낸 PhotosPickerItem을 지정한 타입(여기서는 Data)으로 변환해줍니다.

이로써 사진 앱에서 사진을 선택한 후 Data 형태로 변환까지 성공했습니다.

2. 모델의 데이터를 View에 바인딩

이제 남은 일은 이렇게 처리된 Data를 View에 바인딩하는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 상위 View에서 호출
AvatarView(imageData: $viewModel.avatarImageBinding)

// 프로필 View 구현
struct AvatarView: View {
    @Binding var imageData: Data?

    var body: some View {
        Circle()
            .fill(Color.neutralOffWhite)
            .overlay {
                if let image = Image(data: imageData) {
                    image
                        .resizable()
                        .scaledToFill()
                        .clipShape(Circle())
                } else { Image(.avatar) }
            }
    }
}

이렇게 하면 ViewModel에 선언된 @Published 모델이 변경될 때 @StateObject로 선언된 ViewModel이 갱신되면서 최종적으로 사진이 추가됩니다.

🥳 최종 완성

❓ 궁금증 해결하기 - loadTransferable(type:)

이번 구현의 핵심은 PhotosPickerItemloadTransferable(type:) 메서드를 적절히 활용하는 것이었습니다.

이 메서드가 어떻게 PhotosPickerItem의 값을 Data 타입으로 변환하는지 궁금해져 알아보았습니다.

우선 메서드 문서와 메서드의 원형을 봤을 때 다음과 같이 특성을 확인할 수 있습니다.

[loadTransferable(type: ) 메서드의 조건]

  • 파라미터로 넘긴 타입(T)은 Transferable 프로토콜을 채택하고 있어야 합니다.
  • 아이템이 지원하는 content type 중 T와 호환되는 첫 번째 representation을 찾습니다.
  • 해당 representation을 비동기적으로 로드하여 T 인스턴스로 반환합니다.

(Transferable…? representation…?)

이게 대체 뭔지 싶은것들 투성입니다 🤣

하나씩 알아가보죠 ㅎㅎ

Transferable 프로토콜

Transferable은 Apple 플랫폼에서 데이터를 안전하고 효율적으로 공유하거나 전송할 수 있도록 설계된 프로토콜입니다.

주로 드래그 앤 드롭, 복사/붙여넣기 등 전송 API와 타입이 상호작용할 수 있도록 만들어졌습니다.

Transferable을 채택한 타입은 이러한 전송 API에 사용될 수 있습니다.

이 때 저희가 사용한 PhotosPicker는 OS의 사진 앱과 내 앱 사이를 연결하는 전송 API라 볼 수 있습니다.

그래서 여기에 사용될 타입을 정의하기 위해 Transferable 프로토콜 채택이 필요합니다.

이번에 사용한 Data 타입은 공식 문서 하단에 Transferable을 준수한다고 명시되어 있어, 해당 메서드의 파라미터로 사용할 수 있었습니다.

transferRepresentation

Transferable 프로토콜을 채택할 때 구현해야 하는 정적 프로퍼티입니다.

Transferable 타입을 어떻게 포함시키거나 전송할지 정의하는 역할을 합니다.

[애플에서 제공되는 대표적인 Representation 종류]

  • Codable: 모델이 Codable을 채택해 JSON 형태로 직렬화가 가능할 때
  • Data: 모델이 Data로 변환 가능할 때
  • File: 파일 URL을 통해 대용량 전송이 필요할 때
  • Proxy: Transferable을 채택한 일반 타입들(String, Data, …)을 전송할 때

다양한 Representation을 통해 최종적으로 모델을 어떻게 전송할지 정의할 수 있습니다.

loadTransferable(type:) 동작 정리하기

이제 이해가 좀 더 명확해졌습니다.

PhotosPickerItem은 시스템 앱으로부터 전송받은 이미지입니다.

외부에서 전달된 것이기에, 이를 앱 내에서 사용하려면 해당 이미지를 받을 타입이 필요합니다.

애플은 PhotosPickerItem의 메서드로 Transferable 타입으로 변환하는 기능을 제공합니다.

따라서 우리는 Transferable 프로토콜을 채택한 타입을 정의하고, 해당 타입의 전송 방식을 transferRepresentation에 명시하여 외부에서 전달된 이미지를 앱에서 사용할 수 있게 되는 것입니다.

(아 이해했다~!~!~!!!)

🏁 마무리

이번 포스팅을 통해 SwiftUI에서 PhotosPicker를 이용해 사진을 가져오는 방법을 정리했고,

나아가 어떻게 이를 전송받는지 Transferable 프로토콜을 이해하는 데 도움이 되었습니다.

앞으로 애플의 기본 시스템을 활용해 데이터를 전달하거나 전송할 때 Transferable 프로토콜이 자주 등장할 것으로 예상됩니다.

🔗 레퍼런스

해당 자료를 찾아보며 도움이 됐던 링크

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.