// // AssetCollectionViewController.swift // Tusker // // Created by Shadowfacts on 1/1/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Photos private let reuseIdentifier = "assetCell" private let cameraReuseIdentifier = "showCameraCell" protocol AssetCollectionViewControllerDelegate: AnyObject { func shouldSelectAsset(_ asset: PHAsset) -> Bool func didSelectAssets(_ assets: [PHAsset]) func captureFromCamera() } class AssetCollectionViewController: UIViewController, UICollectionViewDelegate { weak var delegate: AssetCollectionViewControllerDelegate? private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var thumbnailSize: CGSize! private let imageManager = PHCachingImageManager() private var fetchResult: PHFetchResult! var selectedAssets: [PHAsset] { return collectionView.indexPathsForSelectedItems?.compactMap { (indexPath) in guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil } return asset } ?? [] } init() { super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalWidth(1/3)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1/3)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3) group.interItemSpacing = .fixed(4) let section = NSCollectionLayoutSection(group: group) let layout = UICollectionViewCompositionalLayout(section: section) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.delegate = self view.addSubview(collectionView) // use the safe area layout guide instead of letting it automatically use the safe area insets // because otherwise, when presented in a popover with the arrow on the left or right side, // the collection view content will be cut off by the width of the arrow because the popover // doesn't respect safe area insets collectionView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor), view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor), // top ignores safe area because when presented in the sheet container, it simplifies the top content offset view.topAnchor.constraint(equalTo: collectionView.topAnchor), // bottom ignores safe area because we want cells to underflow bottom of the screen on notched iPhones view.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor), ]) view.backgroundColor = .systemBackground navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed)) collectionView.alwaysBounceVertical = true collectionView.allowsMultipleSelection = true collectionView.allowsSelection = true collectionView.allowsFocus = true collectionView.register(UINib(nibName: "AssetCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: reuseIdentifier) let controlCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in switch itemIdentifier { case .showCamera: cell.imageView.image = UIImage(systemName: "camera") cell.label.text = "Take a Photo" case .changeLimitedSelection: cell.imageView.image = UIImage(systemName: "photo.on.rectangle.angled") cell.label.text = "Select More Photos" case .asset(_): break } } dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in switch item { case .showCamera, .changeLimitedSelection: return collectionView.dequeueConfiguredReusableCell(using: controlCell, for: indexPath, item: item) case let .asset(asset): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! AssetCollectionViewCell cell.updateUI(asset: asset) self.imageManager.requestImage(for: asset, targetSize: self.thumbnailSize, contentMode: .aspectFill, options: nil) { (image, _) in guard let image = image else { return } DispatchQueue.main.async { guard cell.assetIdentifier == asset.localIdentifier else { return } cell.thumbnailImage = image } } return cell } }) updateItemsSelectedCount() if let singleFingerPanGesture = collectionView.gestureRecognizers?.first(where: { $0.name == "multi-select.singleFingerPanGesture" }), let interactivePopGesture = navigationController?.interactivePopGestureRecognizer { singleFingerPanGesture.require(toFail: interactivePopGesture) } PHPhotoLibrary.shared().register(self) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) let scale = UIScreen.main.scale let cellWidth = view.bounds.width / 3 thumbnailSize = CGSize(width: cellWidth * scale, height: cellWidth * scale) loadAssets() } private func loadAssets() { var items = [Item.showCamera] switch PHPhotoLibrary.authorizationStatus(for: .readWrite) { case .notDetermined: PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in DispatchQueue.main.async { self.loadAssets() } } return case .restricted, .denied: // todo: better UI for this return case .authorized: break case .limited: items.append(.changeLimitedSelection) break @unknown default: // who knows, just try anyways break } let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] fetchResult = fetchAssets(with: options) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.assets]) fetchResult.enumerateObjects { (asset, _, _) in items.append(.asset(asset)) } snapshot.appendItems(items) dataSource.apply(snapshot, animatingDifferences: false) } open func fetchAssets(with options: PHFetchOptions) -> PHFetchResult { return PHAsset.fetchAssets(with: options) } func updateItemsSelectedCount() { let selected = collectionView.indexPathsForSelectedItems?.count ?? 0 navigationItem.title = "\(selected) selected" } // MARK: UICollectionViewDelegate func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { return true } func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } if let delegate = delegate, case let .asset(asset) = item { return delegate.shouldSelectAsset(asset) } return true } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case .showCamera: collectionView.deselectItem(at: indexPath, animated: false) delegate?.captureFromCamera() case .changeLimitedSelection: // todo: change observer PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self) case .asset(_): updateItemsSelectedCount() } } func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { updateItemsSelectedCount() } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil } return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in return AssetPreviewViewController(asset: asset) }, actionProvider: nil) } func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?, let cell = collectionView.cellForItem(at: indexPath) as? AssetCollectionViewCell { let parameters = UIPreviewParameters() parameters.backgroundColor = .black return UITargetedPreview(view: cell.imageView, parameters: parameters) } else { return nil } } // MARK: - Interaction @objc func donePressed() { delegate?.didSelectAssets(selectedAssets) dismiss(animated: true) } } extension AssetCollectionViewController { enum Section: Hashable { case assets } enum Item: Hashable { case showCamera case changeLimitedSelection case asset(PHAsset) } } extension AssetCollectionViewController: PHPhotoLibraryChangeObserver { func photoLibraryDidChange(_ changeInstance: PHChange) { DispatchQueue.main.async { self.loadAssets() } } }