๐Ÿ“ท 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 ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค์ „ ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ ์„ฑ๋Šฅ ํŒ: maxSelectionCount๋ฅผ ์„ค์ •ํ•˜์—ฌ ๊ณผ๋„ํ•œ ์„ ํƒ์„ ๋ฐฉ์ง€ํ•˜๊ณ , ๋Œ€์šฉ๋Ÿ‰ ์ด๋ฏธ์ง€๋Š” ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ ๋กœ๋“œํ•˜์„ธ์š”. PhotosPicker๋Š” ์ž๋™์œผ๋กœ ๊ถŒํ•œ ๊ด€๋ฆฌ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ ๋ณ„๋„ ๊ถŒํ•œ ์š”์ฒญ์ด ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค.