Если вы когда-нибудь разрабатывали мессенджер, то наверняка сталкивались с задачей выбора фотографий и видео для отправки. На первый взгляд кажется, что Apple предоставляет всё необходимое: UIImagePickerController, PHPickerViewController. Но стоит только копнуть чуть глубже, и оказывается, что стандартные компоненты не покрывают и половины того, что нужно в реальном продукте.
В этой статье я расскажу, как мы шаг за шагом построили кастомный пикер галереи для iOS-мессенджера. Пройдём путь от наивной реализации, которая зависала при открытии на устройствах с 60 000+ фотографий, до production-ready решения с lazy-загрузкой, отслеживанием прогресса скачивания из iCloud через Combine и встроенным превью камеры.
Зачем нужна кастомная галерея
Системные компоненты iOS для выбора медиа хороши, но ограничены:
UIImagePickerControllerоткрывается как полноэкранный модальный контроллер. Нельзя встроить его inline в интерфейс чата, нельзя кастомизировать внешний вид, нет мультивыбора.PHPickerViewController(iOS 14+) уже лучше: есть мультивыбор, фильтрация по типу медиа. Но по-прежнему модальный, с минимальными возможностями для кастомизации UI.
В нашем случае нужно было решить несколько задач, которые не покрывал ни один стандартный компонент:
Inline-компонент — галерея открывается как часть экрана чата (bottom sheet), а не как отдельный модальный контроллер.
Превью камеры — первая ячейка в сетке показывает живое изображение с камеры.
Мультивыбор с нумерацией — пользователь видит порядок выбранных фото (1, 2, 3...) и может выбрать до n элементов.
Прогресс загрузки из iCloud — старые фото могут храниться только в iCloud, и пользователь должен видеть прогресс их скачивания.
Отмена загрузки — возможность отменить скачивание тяжёлого фото из 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():
PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .any)— возвращает все смарт-альбомы (Recents, Favorites, Screenshots, Videos, Selfies, Bursts и т.д.). Это 10-15 коллекций.Для каждой коллекции вызываем
PHAsset.fetchAssets(in: collection, options:). Это синхронная операция, которая хоть и возвращает lazyPHFetchResult, но сам вызов стоит времени: Photos framework должен выполнить SQL-запрос к базе данных Photos.db.Всё это происходит на 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:). Но это плохая идея:
Diffable data source при вызове
reloadItemsпересоздаёт snapshot, вычисляет diff. Для коллекции из 60 000 элементов это не бесплатно.Перерисовка ячейки — при
reloadItemsячейка полностью переконфигурируется: отменяется загрузка миниатюры, создаётся новый запрос кPHImageManager, изображение моргает.Частота обновлений —
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 — задача, которая выглядит простой, но скрывает в себе множество нюансов производительности. Вот ключевые принципы, к которым мы пришли:
Lazy-загрузка через замыкания — главный инструмент для работы с большими библиотеками. Не загружай то, что не показываешь.
GalleryAlbumс lazyfetchResult, ячейки сasset: () -> PHAsset— всё это сводит стоимость открытия пикера к минимуму.PHFetchResult— уже lazy. Apple спроектировала его как виртуальный массив: обращение по индексу загружает конкретный ассет, а не всю библиотеку. Не конвертируйте его в[PHAsset], используйте индексный доступ.CurrentValueSubjectвместоreloadItems— для точечных обновлений ячеек (прогресс, выбор) подписка через Combine радикально эффективнее, чем перезагрузка через diffable data source. Ячейка обновляет только свои UI-элементы, без перерисовки миниатюры.Камера через
Task— тот же принцип отложенной инициализации. ПередаёмTask<AVCaptureSession, Error>в ячейку, камера стартует в фоне, ячейка дожидается результат. Никакой блокировки main thread.Управление подписками через
prepareForReuse— ячейки коллекции переиспользуются, и очисткаcancellablesвprepareForReuseгарантирует, что каждая ячейка подписана только на свой ассет.
Каждая из этих оптимизаций в отдельности кажется незначительной. Но вместе они превращают пикер из компонента, зависающего на 3 секунды при 60k фото, в отзывчивый инструмент, работающий одинаково быстро что с 100, что с 100 000 элементов.
