Если вы когда-нибудь разрабатывали мессенджер, то наверняка сталкивались с задачей выбора фотографий и видео для отправки. На первый взгляд кажется, что Apple предоставляет всё необходимое: UIImagePickerController, PHPickerViewController. Но стоит только копнуть чуть глубже, и оказывается, что стандартные компоненты не покрывают и половины того, что нужно в реальном продукте.

В этой статье я расскажу, как мы шаг за шагом построили кастомный пикер галереи для iOS-мессенджера. Пройдём путь от наивной реализации, которая зависала при открытии на устройствах с 60 000+ фотографий, до production-ready решения с lazy-загрузкой, отслеживанием прогресса скачивания из iCloud через Combine и встроенным превью камеры.

Зачем нужна кастомная галерея

Системные компоненты iOS для выбора медиа хороши, но ограничены:

  • UIImagePickerController открывается как полноэкранный модальный контроллер. Нельзя встроить его inline в интерфейс чата, нельзя кастомизировать внешний вид, нет мультивыбора.

  • PHPickerViewController (iOS 14+) уже лучше: есть мультивыбор, фильтрация по типу медиа. Но по-прежнему модальный, с минимальными возможностями для кастомизации UI.

В нашем случае нужно было решить несколько задач, которые не покрывал ни один стандартный компонент:

  1. Inline-компонент — галерея открывается как часть экрана чата (bottom sheet), а не как отдельный модальный контроллер.

  2. Превью камеры — первая ячейка в сетке показывает живое изображение с камеры.

  3. Мультивыбор с нумерацией — пользователь видит порядок выбранных фото (1, 2, 3...) и может выбрать до n элементов.

  4. Прогресс загрузки из iCloud — старые фото могут храниться только в iCloud, и пользователь должен видеть прогресс их скачивания.

  5. Отмена загрузки — возможность отменить скачивание тяжёлого фото из iCloud прямо из ячейки.

Итого, нам нужен полностью кастомный UICollectionView-компонент с Photos framework под капотом.

Базовая реализация

Начнём с простого. Построим минимальный пикер галереи, который отображает все фото из библиотеки пользователя в сетке 3 на 3.

Модель альбома

Первая идея — обернуть данные из Photos framework в простую структуру:

struct GalleryAlbum {
    let collection: PHAssetCollection
    var fetchResult: PHFetchResult<PHAsset>

    var firstItem: PHAsset? { fetchResult.firstObject }
    var count: Int { fetchResult.count }
}

Провайдер данных

Провайдер загружает все альбомы с устройства. Для каждой коллекции делает fetch ассетов:

final class GalleryProvider {

    func fetchAlbums() -> [GalleryAlbum] {
        var collections: [PHAssetCollection] = []

        let options = PHFetchOptions()
        options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        options.wantsIncrementalChangeDetails = true

        let smartAlbums = PHAssetCollection.fetchAssetCollections(
            with: .smartAlbum, subtype: .any, options: nil
        )
        smartAlbums.enumerateObjects { collection, _, _ in
            collections.append(collection)
        }

        let userAlbums = PHAssetCollection.fetchAssetCollections(
            with: .album, subtype: .any, options: nil
        )
        userAlbums.enumerateObjects { collection, _, _ in
            collections.append(collection)
        }

        var items: [GalleryAlbum] = []
        for collection in collections {
            let fetch = PHAsset.fetchAssets(in: collection, options: options)
            guard !fetch.isEmpty else { continue }
            items.append(GalleryAlbum(collection: collection, fetchResult: fetch))
        }

        return items
    }
}

Ячейка с миниатюрой

Ячейка отображает миниатюру ассета. Загрузка изображения происходит через PHImageManager:

final class GalleryCell: UICollectionViewCell {

    private let imageView: UIImageView = {
        let view = UIImageView()
        view.contentMode = .scaleAspectFill
        view.clipsToBounds = true
        return view
    }()

    private let imageManager = PHImageManager.default()
    private var requestID: PHImageRequestID?

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(imageView)
        contentView.layer.cornerRadius = 8
        contentView.clipsToBounds = true

        imageView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }

    required init?(coder: NSCoder) { fatalError() }

    override func prepareForReuse() {
        super.prepareForReuse()
        if let requestID {
            imageManager.cancelImageRequest(requestID)
        }
        imageView.image = nil
    }

    func configure(with asset: PHAsset) {
        let options = PHImageRequestOptions()
        options.isSynchronous = false

        requestID = imageManager.requestImage(
            for: asset,
            targetSize: CGSize(width: 300, height: 300),
            contentMode: .aspectFill,
            options: options
        ) { [weak self] image, _ in
            self?.imageView.image = image
        }
    }
}

View с коллекцией

View содержит UICollectionView с flow layout для сетки 3 на 3:

final class GalleryPickerView: UIView {

    private let layout: UICollectionViewFlowLayout = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .vertical
        layout.minimumInteritemSpacing = 6
        layout.minimumLineSpacing = 6
        return layout
    }()

    lazy var collectionView: UICollectionView = {
        let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
        cv.contentInset = UIEdgeInsets(top: 8, left: 16, bottom: 100, right: 16)
        cv.showsHorizontalScrollIndicator = false
        cv.backgroundColor = .black
        return cv
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(collectionView)

        collectionView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }

    required init?(coder: NSCoder) { fatalError() }
}

ViewController и data source

Для простоты используем обычный UICollectionViewDataSource. Контроллер загружает альбомы, берёт первый (Camera Roll) и отображает его содержимое:

final class GalleryPickerViewController: UIViewController {

    private let mainView = GalleryPickerView()
    private let provider = GalleryProvider()
    private var currentAlbum: GalleryAlbum?

    override func loadView() { view = mainView }

    override func viewDidLoad() {
        super.viewDidLoad()

        mainView.collectionView.register(GalleryCell.self, forCellWithReuseIdentifier: "cell")
        mainView.collectionView.dataSource = self
        mainView.collectionView.delegate = self

        let albums = provider.fetchAlbums()
        currentAlbum = albums.first

        mainView.collectionView.reloadData()
    }
}

extension GalleryPickerViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        currentAlbum?.fetchResult.count ?? 0
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! GalleryCell
        if let asset = currentAlbum?.fetchResult.object(at: indexPath.item) {
            cell.configure(with: asset)
        }
        return cell
    }
}

extension GalleryPickerViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath
    ) -> CGSize {
        let spacing: CGFloat = 32 + collectionView.contentInset.right
        let width = (collectionView.frame.width - spacing) / 3
        return CGSize(width: width, height: width)
    }
}

Выглядит просто и работает. На тестовом устройстве с парой сотен фото всё открывается мгновенно. Можно заливать в продакшен?

Проблема: 60 000 фото

Нет, нельзя. Стоит протестировать на реальном устройстве пользователя с большой библиотекой, и всё рушится.

Вот что происходит при вызове fetchAlbums():

  1. PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .any) — возвращает все смарт-альбомы (Recents, Favorites, Screenshots, Videos, Selfies, Bursts и т.д.). Это 10-15 коллекций.

  2. Для каждой коллекции вызываем PHAsset.fetchAssets(in: collection, options:). Это синхронная операция, которая хоть и возвращает lazy PHFetchResult, но сам вызов стоит времени: Photos framework должен выполнить SQL-запрос к базе данных Photos.db.

  3. Всё это происходит на main thread в viewDidLoad().

На устройстве с 60 000 фотографий и 15+ альбомами мы получаем заметный фриз — от 1 до 3 секунд. Пользователь нажимает кнопку прикрепления фото в чате и видит замёрзший экран.

Но это ещё полбеды. Есть и вторая проблема: переключение альбомов. Когда пользователь выбирает другой альбом (например, Screenshots), все альбомы уже загружены — но их данные могли устареть. При каждом открытии пикера мы грузим ВСЕ альбомы заново.

Решение: lazy-загрузка через замыкания

Ключевая идея: не загружать то, что не показываем. Пользователь видит один альбом (обычно Camera Roll). Зачем загружать Screenshots, Panoramas, Bursts и десяток других?

Эволюция модели альбома

Превращаем GalleryAlbum из структуры со значениями в класс с отложенной инициализацией:

final class GalleryAlbum {

    private let _fetchResult: (PHAssetCollection) -> PHFetchResult<PHAsset>
    private let _collection: () -> PHAssetCollection

    init(
        collection: @escaping () -> PHAssetCollection,
        fetchResult: @escaping (PHAssetCollection) -> PHFetchResult<PHAsset>
    ) {
        self._fetchResult = fetchResult
        self._collection = collection
    }

    private(set) lazy var fetchResult: PHFetchResult<PHAsset> = _fetchResult(collection)
    private(set) lazy var collection: PHAssetCollection = _collection()

    func update(with fetchResult: PHFetchResult<PHAsset>) {
        self.fetchResult = fetchResult
    }

    var firstItem: PHAsset? { fetchResult.firstObject }
    var count: Int { fetchResult.count }
}

Что изменилось:

  • collection — теперь lazy var, инициализируется замыканием. Сам PHAssetCollection извлекается из PHFetchResult<PHAssetCollection> только при первом обращении.

  • fetchResult — тоже lazy var. Вызов PHAsset.fetchAssets(in:options:) произойдёт только когда мы реально откроем этот альбом.

  • update(with:) — позволяет обновить fetchResult при получении уведомления от PHPhotoLibraryChangeObserver, не пересоздавая объект.

Обновлённый провайдер

final class GalleryProvider {

    func fetchAlbums() -> [GalleryAlbum] {
        let options = PHFetchOptions()
        options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        options.wantsIncrementalChangeDetails = true

        let supportedAlbums: [PHAssetCollectionSubtype] = [
            .smartAlbumUserLibrary,
            .smartAlbumFavorites,
            .smartAlbumVideos,
            .smartAlbumScreenshots,
            .smartAlbumSelfPortraits,
            .smartAlbumPanoramas,
            .smartAlbumBursts,
            .smartAlbumSlomoVideos,
            .smartAlbumTimelapses,
            .smartAlbumLivePhotos,
            .smartAlbumAnimated,
            .smartAlbumDepthEffect
        ]

        var items: [GalleryAlbum] = []

        for albumType in supportedAlbums {
            let collections = PHAssetCollection.fetchAssetCollections(
                with: .smartAlbum, subtype: albumType, options: nil
            )
            guard !collections.isEmpty else { continue }

            for index in 0 ..< collections.count {
                items.append(GalleryAlbum(
                    collection: { collections.object(at: index) },
                    fetchResult: { PHAsset.fetchAssets(in: $0, options: options) }
                ))
            }
        }

        let userAlbums = PHAssetCollection.fetchAssetCollections(
            with: .album, subtype: .any, options: nil
        )
        for index in 0 ..< userAlbums.count {
            items.append(GalleryAlbum(
                collection: { userAlbums.object(at: index) },
                fetchResult: { PHAsset.fetchAssets(in: $0, options: options) }
            ))
        }

        return items
    }
}

Обратите внимание: мы больше не вызываем PHAsset.fetchAssets(in:) внутри цикла. Вместо этого передаём замыкание { PHAsset.fetchAssets(in: $0, options: options) }, которое выполнится только при первом обращении к fetchResult.

Lazy-доступ к ассетам в ячейках

Второй уровень lazy — доступ к конкретному ассету. PHFetchResult уже работает как виртуальный массив (Apple загружает объекты по мере обращения), но мы можем пойти дальше. Вместо того чтобы извлекать PHAsset в момент создания ViewModel, сохраняем замыкание:

// В Presenter
private func fetchToItems(
    _ album: PHFetchResult<PHAsset>
) -> [GalleryItem] {
    (0 ..< album.count).map { index in
        GalleryItem(
            id: "\(index)",
            asset: { album[index] }  // замыкание, а не значение
        )
    }
}

Ячейка вызовет asset() только при конфигурации — то есть когда она реально появится на экране. Для коллекции из 60 000 элементов одновременно на экране видно 15-20 ячеек. Остальные 59 980 замыканий так и останутся невызванными.

Наблюдение за изменениями

Для отслеживания изменений в библиотеке (пользователь сделал скриншот, удалил фото) подписываемся на PHPhotoLibraryChangeObserver:

extension GalleryPickerInteractor: PHPhotoLibraryChangeObserver {
    func photoLibraryDidChange(_ changeInstance: PHChange) {
        guard
            let fetchResult = currentAlbum?.fetchResult,
            let changes = changeInstance.changeDetails(for: fetchResult)
        else { return }

        currentAlbum?.update(with: changes.fetchResultAfterChanges)

        // Передаём инкрементальные изменения в UI
        // changes.removedIndexes, insertedIndexes, changedIndexes
    }
}

Благодаря тому, что GalleryAlbum — reference type (class), мы можем обновить fetchResult на месте, и все ссылки на него останутся актуальными.

Добавляем прогресс загрузки из iCloud

Когда в iOS включена оптимизация хранилища, старые фотографии и видео хранятся в iCloud, а на устройстве остаются только миниатюры. При выборе такой фотографии для отправки нужно скачать полноразмерное изображение — а это может занять от секунды до нескольких минут в зависимости от размера файла и скорости сети.

Asset Loader

Для загрузки полноразмерных изображений и видео создаём отдельный компонент:

final class AssetLoader {

    private let imageManager = PHImageManager.default()

    @discardableResult
    func loadImage(
        asset: PHAsset,
        progressHandler: @escaping (Double) -> Void,
        completion: @escaping (UIImage?) -> Void
    ) -> PHImageRequestID {
        let options = PHImageRequestOptions()
        options.isNetworkAccessAllowed = true
        options.isSynchronous = false
        options.deliveryMode = .highQualityFormat
        options.progressHandler = { progress, _, _, _ in
            DispatchQueue.main.async {
                progressHandler(progress)
            }
        }

        return imageManager.requestImageDataAndOrientation(
            for: asset, options: options
        ) { data, _, _, _ in
            let image = data.flatMap { UIImage(data: $0) }
            completion(image)
        }
    }

    @discardableResult
    func loadVideo(
        asset: PHAsset,
        completion: @escaping (URL?) -> Void
    ) -> PHImageRequestID {
        let options = PHVideoRequestOptions()
        options.isNetworkAccessAllowed = true
        options.deliveryMode = .highQualityFormat

        return imageManager.requestAVAsset(forVideo: asset, options: options) { avAsset, _, _ in
            guard let avAsset else {
                completion(nil)
                return
            }

            let outputURL = FileManager.default.temporaryDirectory
                .appendingPathComponent("\(UUID().uuidString).mp4")

            guard let session = AVAssetExportSession(
                asset: avAsset, presetName: AVAssetExportPresetHighestQuality
            ) else {
                completion(nil)
                return
            }

            session.outputURL = outputURL
            session.outputFileType = .mp4
            session.exportAsynchronously {
                completion(session.status == .completed ? outputURL : nil)
            }
        }
    }

    func cancelLoading(for requestId: PHImageRequestID) {
        imageManager.cancelImageRequest(requestId)
    }
}

Ключевой момент — options.isNetworkAccessAllowed = true. Без этого флага Photos framework вернёт только локальный кеш (миниатюру), а для iCloud-фото — ничего. С включённым флагом progressHandler будет вызываться многократно по мере скачивания файла, передавая значения от 0.0 до 1.0.

Почему Publisher, а не перезагрузка ячеек

Первый инстинкт при получении обновления прогресса — вызвать collectionView.reloadItems(at:). Но это плохая идея:

  1. Diffable data source при вызове reloadItems пересоздаёт snapshot, вычисляет diff. Для коллекции из 60 000 элементов это не бесплатно.

  2. Перерисовка ячейки — при reloadItems ячейка полностью переконфигурируется: отменяется загрузка миниатюры, создаётся новый запрос к PHImageManager, изображение моргает.

  3. Частота обновленийprogressHandler вызывается 10-20 раз в секунду. Перезагружать ячейку с такой частотой — путь к потере кадров.

Вместо этого используем CurrentValueSubject из Combine. Каждая ячейка подписывается на общий поток прогресса и обновляет только свой индикатор загрузки:

// В Interactor
let progressSubject = CurrentValueSubject<[String: Double], Never>([:])

func updateProgress(assetId: String, progress: Double) {
    progressSubject.value[assetId] = progress
}

CurrentValueSubject<[String: Double], Never> — это словарь, где ключ — localIdentifier ассета, значение — прогресс от 0.0 до 1.0. При каждом обновлении прогресса мы просто меняем значение в словаре, и все подписчики получают уведомление.

Ячейка с подпиской на прогресс

final class GalleryCell: UICollectionViewCell {
    struct ViewModel {
        let asset: () -> PHAsset
        let onCancelTap: ((String) -> Void)?
        let progressPublisher: CurrentValueSubject<[String: Double], Never>?
    }

    private var viewModel: ViewModel?
    private var cancellables = Set<AnyCancellable>()

    private let imageView: UIImageView = {
        let view = UIImageView()
        view.contentMode = .scaleAspectFill
        view.clipsToBounds = true
        return view
    }()

    private let progressIndicator: CircleProgressView = {
        let view = CircleProgressView()
        view.alpha = 0
        return view
    }()

    private let cancelButton: UIButton = {
        let button = UIButton()
        button.setImage(UIImage(systemName: "xmark"), for: .normal)
        button.tintColor = .white
        return button
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.layer.cornerRadius = 8
        contentView.clipsToBounds = true

        contentView.addSubview(imageView)
        contentView.addSubview(progressIndicator)
        progressIndicator.addSubview(cancelButton)

        cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)

        imageView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }

        progressIndicator.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.size.equalTo(50)
        }

        cancelButton.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }

    required init?(coder: NSCoder) { fatalError() }

    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.image = nil
        cancellables.removeAll()
    }

    func configure(from viewModel: ViewModel?) {
        cancellables.removeAll()

        let asset = viewModel?.asset()
        let identifier = asset?.localIdentifier

        // Загрузка миниатюры
        if let asset {
            loadThumbnail(for: asset)
        }

        // Подписка на прогресс
        if let publisher = viewModel?.progressPublisher, let identifier {
            publisher
                .receive(on: DispatchQueue.main)
                .sink { [weak self] progressByAsset in
                    self?.updateProgress(
                        for: identifier,
                        progressByAsset: progressByAsset
                    )
                }
                .store(in: &cancellables)
        }
    }

    private func updateProgress(for identifier: String, progressByAsset: [String: Double]) {
        let progress = progressByAsset[identifier] ?? 0
        let isHidden = progress == 0 || progress == 1

        UIView.animate(withDuration: 0.3) {
            self.progressIndicator.alpha = isHidden ? 0 : 1
        }
        progressIndicator.progress = progress
    }

    @objc private func cancelTapped() {
        if let id = viewModel?.asset()?.localIdentifier {
            viewModel?.onCancelTap?(id)
        }
    }

    private func loadThumbnail(for asset: PHAsset) {
        let manager = PHImageManager.default()
        let options = PHImageRequestOptions()
        options.isSynchronous = false

        manager.requestImage(
            for: asset,
            targetSize: CGSize(width: 300, height: 300),
            contentMode: .aspectFill,
            options: options
        ) { [weak self] image, _ in
            self?.imageView.image = image
        }
    }
}

Обратите внимание на жизненный цикл подписок:

  • В configure(from:) сначала вызываем cancellables.removeAll() — отменяем предыдущие подписки (ячейка могла быть переиспользована).

  • Создаём новую подписку на progressPublisher с конкретным identifier.

  • В prepareForReuse() тоже чистим cancellables — ячейка уходит в пул переиспользования.

Это ключевое преимущество подхода с Publisher: ячейка сама управляет своей подпиской. Interactor не знает и не заботится о том, какие ячейки сейчас на экране — он просто обновляет значение в progressSubject, а подписавшиеся ячейки реагируют.

Добавляем камеру

Следующий шаг — первой ячейкой в сетке показать живое превью с камеры. Пользова��ель видит сетку: [камера] [фото1] [фото2] [фото3] ...

Ячейка камеры

final class CameraCell: UICollectionViewCell {
    struct ViewModel: Hashable {
        let sessionTask: Task<AVCaptureSession, Error>?
    }

    private var sessionTask: Task<Void, Error>?

    private let cameraView: CaptureVideoView = {
        let view = CaptureVideoView()
        view.clipsToBounds = true
        return view
    }()

    private let placeholderImageView: UIImageView = {
        let view = UIImageView()
        view.image = UIImage(systemName: "camera.fill")
        view.tintColor = .white
        view.contentMode = .scaleAspectFit
        return view
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = .darkGray
        contentView.layer.cornerRadius = 8
        contentView.clipsToBounds = true

        contentView.addSubview(cameraView)
        contentView.addSubview(placeholderImageView)

        cameraView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }

        placeholderImageView.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
    }

    required init?(coder: NSCoder) { fatalError() }

    func configure(from viewModel: ViewModel?) {
        sessionTask?.cancel()
        sessionTask = Task {
            let session = try await viewModel?.sessionTask?.value
            cameraView.session = session
        }
    }
}

Здесь снова тот же lazy-подход: вместо синхронного запуска камеры мы передаём Task<AVCaptureSession, Error>?. Камерная сессия стартует в фоне, а ячейка ожидает результат через await. Пока сессия запускается, пользователь видит placeholder-иконку.

CaptureVideoView — это простая обёртка над AVCaptureVideoPreviewLayer:

class CaptureVideoView: UIView {

    override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }

    var videoPreviewLayer: AVCaptureVideoPreviewLayer {
        let layer = layer as! AVCaptureVideoPreviewLayer
        layer.videoGravity = .resizeAspectFill
        return layer
    }

    var session: AVCaptureSession? {
        get { videoPreviewLayer.session }
        set { videoPreviewLayer.session = newValue }
    }
}

Запуск сессии камеры

В Interactor при инициализации создаём Task для запуска камеры:

sessionTask = Task.detached { [cameraService] in
    try await cameraService.startSession()
}

Task.detached — чтобы не блокировать main actor. Камерная сессия стартует в фоне, и когда ячейка попросит значение через await viewModel?.sessionTask?.value, она либо получит уже готовую сессию, либо дождётся её запуска.

Два типа ячеек в коллекции

Для поддержки двух типов ячеек используем enum:

enum CellModel: Hashable {
    case camera(CameraCell.ViewModel)
    case photo(GalleryCell.ViewModel)
}

При конфигурации diffable data source:

super.init(collectionView: collectionView) { collectionView, indexPath, item in
    switch item.cellModel {
    case let .camera(viewModel):
        let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: "camera", for: indexPath
        ) as? CameraCell
        cell?.configure(from: viewModel)
        return cell ?? UICollectionViewCell()

    case let .photo(viewModel):
        let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: "photo", for: indexPath
        ) as? GalleryCell
        cell?.configure(from: viewModel)
        return cell ?? UICollectionViewCell()
    }
}

При обработке нажатий учитываем смещение на 1 из-за камеры:

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    if indexPath.item == 0 {
        // Открыть камеру
        delegate?.didSelectCamera()
    } else {
        // Выбрать фото (индекс в альбоме = indexPath.item - 1)
        delegate?.didSelectPhoto(at: indexPath.item - 1)
    }
}

Добавляем выбор элементов

Последняя часть пазла — мультивыбор фотографий для отправки. Пользователь нажимает на фото, она выделяется рамкой, в углу появляется номер (1, 2, 3...). Повторное нажатие снимает выбор.

Почему снова Publisher

Казалось бы, при выборе фото достаточно обновить одну ячейку — ту, по которой нажали. Но всё сложнее:

Представим, что выбраны фото с номерами 1, 2, 3. Пользователь снимает выбор с фото номер 2. Теперь:

  • Бывшая фото 2 — убираем выделение

  • Бывшая фото 3 — перенумеровываем в 2

При снятии выбора одного элемента нужно обновить ВСЕ элементы, которые шли после него. А мы не знаем, какие из них сейчас на экране.

Вызов reloadItems для каждого из них — это потенциально перерисовка десятков ячеек, перестроение snapshot, мерцание изображений. Вместо этого снова используем CurrentValueSubject:

// В Interactor
let selectedAssetsSubject = CurrentValueSubject<[String], Never>([])

Массив [String] — это упорядоченный список localIdentifier выбранных ассетов. Порядок в массиве определяет номер выбора.

Логика выбора

func didSelectPhoto(at index: Int) {
    guard let album = currentAlbum, index < album.fetchResult.count else { return }
    let identifier = album.fetchResult[index].localIdentifier

    if selectedAssetsSubject.value.contains(identifier) {
        // Снимаем выбор
        selectedAssetsSubject.value.removeAll { $0 == identifier }
    } else {
        // Добавляем в выбор (максимум 20)
        if selectedAssetsSubject.value.count < 20 {
            selectedAssetsSubject.value.append(identifier)
        }
    }
}

Ячейка с подпиской на выбор

Добавляем в ячейку подписку на selectedAssetsSubject:

// Внутри GalleryCell

private let selectionBadge: UIView = {
    let view = UIView()
    view.backgroundColor = .white
    view.layer.cornerRadius = 12
    view.layer.maskedCorners = [.layerMinXMaxYCorner]
    view.isHidden = true
    return view
}()

private let selectionLabel: UILabel = {
    let label = UILabel()
    label.font = .boldSystemFont(ofSize: 13)
    label.textColor = .black
    label.textAlignment = .center
    return label
}()

// В init:
contentView.addSubview(selectionBadge)
selectionBadge.addSubview(selectionLabel)

selectionBadge.snp.makeConstraints { make in
    make.top.trailing.equalToSuperview()
    make.size.equalTo(24)
}

selectionLabel.snp.makeConstraints { make in
    make.edges.equalToSuperview()
}

Обновлённая ViewModel ячейки:

struct ViewModel {
    let asset: () -> PHAsset
    let onCancelTap: ((String) -> Void)?
    let selectedAssetsPublisher: CurrentValueSubject<[String], Never>?
    let progressPublisher: CurrentValueSubject<[String: Double], Never>?
}

И метод конфигурации:

func configure(from viewModel: ViewModel?) {
    cancellables.removeAll()

    let asset = viewModel?.asset()
    let identifier = asset?.localIdentifier

    // ... загрузка миниатюры ...

    // Подписка на выбор
    if let publisher = viewModel?.selectedAssetsPublisher, let identifier {
        publisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] selectedAssets in
                self?.updateSelection(for: identifier, in: selectedAssets)
            }
            .store(in: &cancellables)
    } else {
        updateSelection(for: nil, in: [])
    }

    // Подписка на прогресс
    if let publisher = viewModel?.progressPublisher, let identifier {
        publisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] progressByAsset in
                self?.updateProgress(for: identifier, progressByAsset: progressByAsset)
            }
            .store(in: &cancellables)
    }
}

private func updateSelection(for identifier: String?, in selectedAssets: [String]) {
    guard let identifier else {
        layer.borderWidth = 0
        selectionBadge.isHidden = true
        return
    }

    let position = selectedAssets.firstIndex(of: identifier)
    let isSelected = position != nil

    layer.borderWidth = isSelected ? 4 : 0
    layer.borderColor = UIColor.white.cgColor

    if let position {
        selectionBadge.isHidden = false
        selectionLabel.text = "\(position + 1)"
    } else {
        selectionBadge.isHidden = true
    }
}

Когда Interactor добавляет или убирает элемент из selectedAssetsSubject, все видимые ячейки получают обновлённый массив и проверяют свой identifier. Ячейки, которых нет на экране, не подписаны — никаких лишних обновлений.

Отмена загрузки

Если пользователь выбрал фото из iCloud и она начала скачиваться, он может передумать. Нажатие на кнопку отмены (крестик на индикаторе прогресса) вызывает:

func cancelAssetLoading(assetId: String) {
    // Отменяем запрос к PHImageManager
    provider.cancelLoading(assetId: assetId)

    // Убираем из выбранных
    selectedAssetsSubject.value.removeAll { $0 == assetId }

    // Убираем прогресс
    progressSubject.value.removeValue(forKey: assetId)
}

Три publisher-обновления в одном действии — и UI моментально отражает изменения без единого вызова reloadData.

Выводы

Построение кастомной галереи на iOS — задача, которая выглядит простой, но скрывает в себе множество нюансов производительности. Вот ключевые принципы, к которым мы пришли:

  1. Lazy-загрузка через замыкания — главный инструмент для работы с большими библиотеками. Не загружай то, что не показываешь. GalleryAlbum с lazy fetchResult, ячейки с asset: () -> PHAsset — всё это сводит стоимость открытия пикера к минимуму.

  2. PHFetchResult — уже lazy. Apple спроектировала его как виртуальный массив: обращение по индексу загружает конкретный ассет, а не всю библиотеку. Не конвертируйте его в [PHAsset], используйте индексный доступ.

  3. CurrentValueSubject вместо reloadItems — для точечных обновлений ячеек (прогресс, выбор) подписка через Combine радикально эффективнее, чем перезагрузка через diffable data source. Ячейка обновляет только свои UI-элементы, без перерисовки миниатюры.

  4. Камера через Task — тот же принцип отложенной инициализации. Передаём Task<AVCaptureSession, Error> в ячейку, камера стартует в фоне, ячейка дожидается результат. Никакой блокировки main thread.

  5. Управление подписками через prepareForReuse — ячейки коллекции переиспользуются, и очистка cancellables в prepareForReuse гарантирует, что каждая ячейка подписана только на свой ассет.

Каждая из этих оптимизаций в отдельности кажется незначительной. Но вместе они превращают пикер из компонента, зависающего на 3 секунды при 60k фото, в отзывчивый инструмент, работающий одинаково быстро что с 100, что с 100 000 элементов.