π· PhotosUI
β Difficulty: ββ
β±οΈ Est. Time: 1-2h
π Graphics & Media
μ¬μ§ μ νμ μν μ΅μ μμ€ν μΈν°νμ΄μ€
iOS 14+νλΌμ΄λ²μ μ°μ
β¨ PhotosUI?
PhotosUI is a framework that provides a modern interface for selecting photos and videos from the user's library. It protects privacy by only accessing user-selected items without requiring full library access, and can be easily implemented via SwiftUI's PhotosPicker and UIKit's PHPickerViewController.
π‘ Key Features: Photo/Video Selection Β· Multi-Selection Β· Filtering Β· Live Photos Support Β· Privacy Protection Β· No Permission Required Β· SwiftUI/UIKit Integration
πΌοΈ 1. PhotosPicker (SwiftUI)
Select photos using PhotosPicker in SwiftUI.
PhotosPickerView.swift β SwiftUI Photo Picker
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 β Video/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)
Use PHPickerViewController in UIKit.
PHPickerView.swift β UIKit Photo Picker
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 β Filtering
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. Complete Example
PhotoGalleryView.swift β Photo Gallery App
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 Guidelines
- Privacy: PhotosUI doesn't require full library access permission
- κΆν λΆνμ: μ¬μ©μκ° μ νν νλͺ©λ§ μ κ·Ό κ°λ₯
- System UI: μΌκ΄λ μμ€ν μ¬μ§ μ ν μΈν°νμ΄μ€ μ¬μ©
- νν°λ§: μ±μ νμν λ―Έλμ΄ νμ λ§ μ ννλλ‘ μ ν
- λ‘λ© μν: λμ©λ λ―Έλμ΄ λ‘λ© μ μ§ν μν νμ
π― Practical Usage
- νλ‘ν μ¬μ§: μ¬μ©μ νλ‘ν μ΄λ―Έμ§ μ λ‘λ
- μμ μ±: κ²μλ¬Όμ μ¬μ§/λΉλμ€ μ²¨λΆ
- μ¬μ§ νΈμ§: μ¬μ§ νΈμ§ μ±μ μ΄λ―Έμ§ λ‘λ
- λ¬Έμ μ€μΊ: λ¬Έμ μ΄λ―Έμ§ κ°μ Έμ€κΈ°
- λ°±μ μ±: μ νν μ¬μ§ λ°±μ
π Learn More
β‘οΈ Performance Tips:
maxSelectionCount to prevent excessive selection, and load large images on a background thread. PhotosPicker handles permission management automatically, so no separate permission request is needed.