๐ท PhotosUI
์ฌ์ง ์ ํ์ ์ํ ์ต์ ์์คํ ์ธํฐํ์ด์ค
iOS 14+ํ๋ผ์ด๋ฒ์ ์ฐ์
โจ PhotosUI๋?
PhotosUI๋ ์ฌ์ฉ์์ ์ฌ์ง ๋ผ์ด๋ธ๋ฌ๋ฆฌ์์ ์ฌ์ง๊ณผ ๋น๋์ค๋ฅผ ์ ํํ๋ ํ๋์ ์ธ ์ธํฐํ์ด์ค๋ฅผ ์ ๊ณตํ๋ ํ๋ ์์ํฌ์ ๋๋ค. ์ ์ฒด ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ ๊ทผ ๊ถํ ์์ด๋ ์ฌ์ฉ์๊ฐ ์ ํํ ํญ๋ชฉ๋ง ์ ๊ทผํ ์ ์์ด ํ๋ผ์ด๋ฒ์๋ฅผ ๋ณดํธํ๋ฉฐ, SwiftUI์ PhotosPicker์ UIKit์ PHPickerViewController๋ฅผ ํตํด ๊ฐ๋จํ๊ฒ ๊ตฌํํ ์ ์์ต๋๋ค.
๐ก ํต์ฌ ๊ธฐ๋ฅ: ์ฌ์ง/๋น๋์ค ์ ํ ยท ๋ค์ค ์ ํ ยท ํํฐ๋ง ยท Live Photos ์ง์ ยท ํ๋ผ์ด๋ฒ์ ๋ณดํธ ยท ๊ถํ ๋ถํ์ ยท SwiftUI/UIKit ํตํฉ
๐ผ๏ธ 1. PhotosPicker (SwiftUI)
SwiftUI์์ PhotosPicker๋ฅผ ์ฌ์ฉํ์ฌ ์ฌ์ง์ ์ ํํฉ๋๋ค.
PhotosPickerView.swift โ SwiftUI ์ฌ์ง ์ ํ
import SwiftUI import PhotosUI struct PhotosPickerView: View { @State private var selectedItem: PhotosPickerItem? @State private var selectedImage: Image? var body: some View { VStack(spacing: 20) { if let selectedImage { selectedImage .resizable() .scaledToFit() .frame(width: 300, height: 300) .cornerRadius(12) } else { Image(systemName: "photo.on.rectangle.angled") .font(.system(size: 60)) .foregroundStyle(.secondary) } // ๋จ์ผ ์ฌ์ง ์ ํ PhotosPicker("์ฌ์ง ์ ํ", selection: $selectedItem, matching: .images) .buttonStyle(.borderedProminent) .onChange(of: selectedItem) { Task { if let data = try? await selectedItem?.loadTransferable(type: Data.self) { if let uiImage = UIImage(data: data) { selectedImage = Image(uiImage: uiImage) } } } } } .padding() } } // ๋ค์ค ์ ํ ์์ struct MultiplePhotosPickerView: View { @State private var selectedItems: [PhotosPickerItem] = [] @State private var selectedImages: [UIImage] = [] var body: some View { VStack { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) { ForEach(selectedImages, id: \.self) { image in Image(uiImage: image) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipped() .cornerRadius(8) } } .padding() } PhotosPicker( selection: $selectedItems, maxSelectionCount: 10, matching: .images ) { Label("์ฌ์ง ์ ํ (์ต๋ 10๊ฐ)", systemImage: "photo.on.rectangle") } .buttonStyle(.borderedProminent) .padding() .onChange(of: selectedItems) { Task { selectedImages = [] for item in selectedItems { if let data = try? await item.loadTransferable(type: Data.self), let image = UIImage(data: data) { selectedImages.append(image) } } } } } } }
๐ฌ 2. ๋น๋์ค ๋ฐ Live Photos
๋น๋์ค์ Live Photos๋ฅผ ์ ํํ๊ณ ์ฒ๋ฆฌํฉ๋๋ค.
VideoPickerView.swift โ ๋น๋์ค/Live Photos
import SwiftUI import PhotosUI import AVKit struct VideoPickerView: View { @State private var selectedItem: PhotosPickerItem? @State private var videoURL: URL? @State private var player: AVPlayer? var body: some View { VStack { if let player { VideoPlayer(player: player) .frame(height: 300) .cornerRadius(12) } else { Image(systemName: "video.circle") .font(.system(size: 60)) .foregroundStyle(.secondary) } // ๋น๋์ค๋ง ์ ํ PhotosPicker("๋น๋์ค ์ ํ", selection: $selectedItem, matching: .videos) .buttonStyle(.borderedProminent) .onChange(of: selectedItem) { Task { if let movie = try? await selectedItem?.loadTransferable(type: Movie.self) { player = AVPlayer(url: movie.url) } } } } .padding() } } // Movie ํ์ ์ ์ struct Movie: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation(contentType: .movie) { movie in SentTransferredFile(movie.url) } importing: { received in let copy = URL.documentsDirectory.appending(path: "movie.mov") if FileManager.default.fileExists(atPath: copy.path()) { try FileManager.default.removeItem(at: copy) } try FileManager.default.copyItem(at: received.file, to: copy) return Self.init(url: copy) } } } // Live Photo ์ ํ struct LivePhotoPickerView: View { @State private var selectedItem: PhotosPickerItem? var body: some View { VStack { // Live Photos ํฌํจ ์ ํ PhotosPicker( "์ฌ์ง ์ ํ (Live Photos ํฌํจ)", selection: $selectedItem, matching: .any(of: [.images, .livePhotos]) ) .buttonStyle(.borderedProminent) } .padding() } }
๐ ๏ธ 3. PHPickerViewController (UIKit)
UIKit์์ PHPickerViewController๋ฅผ ์ฌ์ฉํฉ๋๋ค.
PHPickerView.swift โ UIKit ์ฌ์ง ํผ์ปค
import SwiftUI import PhotosUI struct PHPickerView: UIViewControllerRepresentable { @Binding var selectedImages: [UIImage] let selectionLimit: Int let filter: PHPickerFilter func makeUIViewController(context: Context) -> PHPickerViewController { var configuration = PHPickerConfiguration() configuration.selectionLimit = selectionLimit configuration.filter = filter let picker = PHPickerViewController(configuration: configuration) picker.delegate = context.coordinator return picker } func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, PHPickerViewControllerDelegate { let parent: PHPickerView init(_ parent: PHPickerView) { self.parent = parent } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) var images: [UIImage] = [] let group = DispatchGroup() for result in results { group.enter() result.itemProvider.loadObject(ofClass: UIImage.self) { object, error in defer { group.leave() } if let image = object as? UIImage { images.append(image) } } } group.notify(queue: .main) { self.parent.selectedImages = images } } } } // ์ฌ์ฉ ์์ struct PHPickerExampleView: View { @State private var selectedImages: [UIImage] = [] @State private var showPicker = false var body: some View { VStack { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) { ForEach(selectedImages, id: \.self) { image in Image(uiImage: image) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipped() } } } Button("์ฌ์ง ์ ํ") { showPicker = true } .buttonStyle(.borderedProminent) } .sheet(isPresented: $showPicker) { PHPickerView( selectedImages: $selectedImages, selectionLimit: 5, filter: .images ) } } }
๐ 4. ํํฐ๋ง ๋ฐ ์ค์
ํน์ ๋ฏธ๋์ด ํ์ ๋ง ์ ํํ๋๋ก ํํฐ๋งํฉ๋๋ค.
FilteredPickerView.swift โ ํํฐ๋ง
import SwiftUI import PhotosUI struct FilteredPickerView: View { @State private var selectedItems: [PhotosPickerItem] = [] @State private var filterType: PHPickerFilter = .images var body: some View { VStack(spacing: 20) { Text("ํํฐ ์ ํ") .font(.headline) // ์ด๋ฏธ์ง๋ง PhotosPicker( "์ด๋ฏธ์ง๋ง ์ ํ", selection: $selectedItems, matching: .images ) .buttonStyle(.bordered) // ๋น๋์ค๋ง PhotosPicker( "๋น๋์ค๋ง ์ ํ", selection: $selectedItems, matching: .videos ) .buttonStyle(.bordered) // Live Photos๋ง PhotosPicker( "Live Photos๋ง ์ ํ", selection: $selectedItems, matching: .livePhotos ) .buttonStyle(.bordered) // ํ๋ ธ๋ผ๋ง ์ฌ์ง๋ง PhotosPicker( "ํ๋ ธ๋ผ๋ง ์ฌ์ง๋ง", selection: $selectedItems, matching: .panoramas ) .buttonStyle(.bordered) // ์คํฌ๋ฆฐ์ท๋ง PhotosPicker( "์คํฌ๋ฆฐ์ท๋ง", selection: $selectedItems, matching: .screenshots ) .buttonStyle(.bordered) // ์ฌ๋ก๋ชจ์ ๋น๋์ค๋ง PhotosPicker( "์ฌ๋ก๋ชจ์ ๋น๋์ค๋ง", selection: $selectedItems, matching: .slomoVideos ) .buttonStyle(.bordered) // ์ฌ๋ฌ ํํฐ ์กฐํฉ PhotosPicker( "์ด๋ฏธ์ง ๋๋ ๋น๋์ค", selection: $selectedItems, matching: .any(of: [.images, .videos]) ) .buttonStyle(.borderedProminent) } .padding() } } // ๊ณ ๊ธ ํํฐ๋ง struct AdvancedFilterView: View { @State private var selectedImages: [UIImage] = [] @State private var showPicker = false var body: some View { VStack { Button("PNG ์ด๋ฏธ์ง๋ง ์ ํ") { showPicker = true } .buttonStyle(.borderedProminent) } .sheet(isPresented: $showPicker) { PHPickerView( selectedImages: $selectedImages, selectionLimit: 10, filter: .any(of: [.images]) ) } } }
๐ธ 5. ์ข ํฉ ์์
PhotoGalleryView.swift โ ์ฌ์ง ๊ฐค๋ฌ๋ฆฌ ์ฑ
import SwiftUI import PhotosUI struct PhotoGalleryView: View { @State private var selectedItems: [PhotosPickerItem] = [] @State private var photos: [PhotoItem] = [] @State private var isLoading = false var body: some View { NavigationStack { ScrollView { if photos.isEmpty { ContentUnavailableView( "์ฌ์ง์ด ์์ต๋๋ค", systemImage: "photo.on.rectangle.angled", description: Text("์๋ ๋ฒํผ์ ๋๋ฌ ์ฌ์ง์ ์ถ๊ฐํ์ธ์") ) .frame(height: 400) } else { LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) { ForEach(photos) { photo in ZStack(alignment: .topTrailing) { Image(uiImage: photo.image) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipped() .cornerRadius(8) Button { photos.removeAll { $0.id == photo.id } } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.white) .background(Circle().fill(Color.black.opacity(0.5))) } .padding(4) } } } .padding() } if isLoading { ProgressView("์ฌ์ง ๋ก๋ฉ ์ค...") .padding() } } .navigationTitle("์ฌ์ง ๊ฐค๋ฌ๋ฆฌ") .toolbar { ToolbarItem(placement: .topBarTrailing) { PhotosPicker( selection: $selectedItems, maxSelectionCount: 20, matching: .images ) { Label("์ถ๊ฐ", systemImage: "plus") } } ToolbarItem(placement: .bottomBar) { Text("\(photos.count)๊ฐ ์ฌ์ง") .font(.caption) .foregroundStyle(.secondary) } } .onChange(of: selectedItems) { Task { isLoading = true defer { isLoading = false } for item in selectedItems { if let data = try? await item.loadTransferable(type: Data.self), let image = UIImage(data: data) { photos.append(PhotoItem(image: image)) } } selectedItems = [] } } } } } struct PhotoItem: Identifiable { let id = UUID() let image: UIImage }
๐ก HIG ๊ฐ์ด๋๋ผ์ธ
- ํ๋ผ์ด๋ฒ์: PhotosUI๋ ์ ์ฒด ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ ๊ทผ ๊ถํ์ด ํ์ ์์
- ๊ถํ ๋ถํ์: ์ฌ์ฉ์๊ฐ ์ ํํ ํญ๋ชฉ๋ง ์ ๊ทผ ๊ฐ๋ฅ
- ์์คํ UI: ์ผ๊ด๋ ์์คํ ์ฌ์ง ์ ํ ์ธํฐํ์ด์ค ์ฌ์ฉ
- ํํฐ๋ง: ์ฑ์ ํ์ํ ๋ฏธ๋์ด ํ์ ๋ง ์ ํํ๋๋ก ์ ํ
- ๋ก๋ฉ ์ํ: ๋์ฉ๋ ๋ฏธ๋์ด ๋ก๋ฉ ์ ์งํ ์ํ ํ์
๐ฏ ์ค์ ํ์ฉ
- ํ๋กํ ์ฌ์ง: ์ฌ์ฉ์ ํ๋กํ ์ด๋ฏธ์ง ์ ๋ก๋
- ์์ ์ฑ: ๊ฒ์๋ฌผ์ ์ฌ์ง/๋น๋์ค ์ฒจ๋ถ
- ์ฌ์ง ํธ์ง: ์ฌ์ง ํธ์ง ์ฑ์ ์ด๋ฏธ์ง ๋ก๋
- ๋ฌธ์ ์ค์บ: ๋ฌธ์ ์ด๋ฏธ์ง ๊ฐ์ ธ์ค๊ธฐ
- ๋ฐฑ์ ์ฑ: ์ ํํ ์ฌ์ง ๋ฐฑ์
๐ ๋ ์์๋ณด๊ธฐ
โก๏ธ ์ฑ๋ฅ ํ:
maxSelectionCount๋ฅผ ์ค์ ํ์ฌ ๊ณผ๋ํ ์ ํ์ ๋ฐฉ์งํ๊ณ , ๋์ฉ๋ ์ด๋ฏธ์ง๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋์์ ๋ก๋ํ์ธ์. PhotosPicker๋ ์๋์ผ๋ก ๊ถํ ๊ด๋ฆฌ๋ฅผ ์ฒ๋ฆฌํ๋ฏ๋ก ๋ณ๋ ๊ถํ ์์ฒญ์ด ํ์ ์์ต๋๋ค.