// // 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: UICollectionViewController { weak var delegate: AssetCollectionViewControllerDelegate? private var dataSource: UICollectionViewDiffableDataSource! private var flowLayout: UICollectionViewFlowLayout { return collectionViewLayout as! UICollectionViewFlowLayout } private var availableWidth: CGFloat! 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(collectionViewLayout: UICollectionViewFlowLayout()) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() // 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.register(UINib(nibName: "AssetCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: reuseIdentifier) collectionView.register(UINib(nibName: "ShowCameraCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: cameraReuseIdentifier) let scale = UIScreen.main.scale let cellSize = flowLayout.itemSize thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in switch item { case .showCamera: return collectionView.dequeueReusableCell(withReuseIdentifier: cameraReuseIdentifier, for: indexPath) 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) } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) loadAssets() } override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() let availableWidth = view.bounds.inset(by: view.safeAreaInsets).width if self.availableWidth != availableWidth { self.availableWidth = availableWidth let size = (availableWidth - 8) / 3 flowLayout.itemSize = CGSize(width: size, height: size) flowLayout.minimumInteritemSpacing = 4 flowLayout.minimumLineSpacing = 4 } } private func loadAssets() { switch PHPhotoLibrary.authorizationStatus(for: .readWrite) { case .notDetermined: PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in self.loadAssets() } return case .restricted, .denied: // todo: better UI for this return case .authorized, .limited: // todo: show "add more" button for limited access 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]) var items: [Item] = [.showCamera] 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 override func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { return true } override 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 } override 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 .asset(_): updateItemsSelectedCount() } } override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { updateItemsSelectedCount() } override 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) } override 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 asset(PHAsset) } }