【纯代码】Swift相册照片选择-支持单选或多选
// // NAPublishAlbumTableViewController.swift //// // Created by on 2019/3/23. // Copyright © 2019年 . All rights reserved. // import UIKit import Photos typealias HandlePhotos = ([PHAsset], [UIImage]) -> Void class HandleSelectionPhotosManager: NSObject { static let share = HandleSelectionPhotosManager() var maxCount: Int = 0 var callbackPhotos: HandlePhotos? private override init() { super.init() } func getSelectedPhotos(with count: Int, callback completeHandle: HandlePhotos? ) { // 限制长度 maxCount = count < 1 ? 1 : (count > 9 ? 9 : count) self.callbackPhotos = completeHandle } } /// 后期可用来对相应的英文文件夹修改为汉语名,暂时未使用 enum AlbumTransformChina: String { case Favorites case RecentlyDeleted = "Recently Deleted" case Screenshots func chinaName() -> String { switch self { case .Favorites: return "最爱" case .RecentlyDeleted: return "最近删除" case .Screenshots: return "手机截屏" } } } /// - albumAllPhotos: 所有 /// - albumSmartAlbums: 智能 /// - albumUserCollection: 收藏 enum AlbumSession: Int { case albumAllPhotos = 0 case albumSmartAlbums case albumUserCollection static let count = 2 } class NAPublishAlbumTableViewController : UITableViewController{ // MARK: - 👉Properties fileprivate var allPhotos: PHFetchResult<PHAsset>! fileprivate var smartAlbums: PHFetchResult<PHAssetCollection>! fileprivate var userCollections: PHFetchResult<PHCollection>! private let sectionTitles = ["", "智能相册", "相册"] fileprivate var MaxCount: Int = 0 fileprivate var handleSelectionAction: (([String], [String]) -> Void)? // MARK: - 👉Lifecycle override func viewDidLoad() { super.viewDidLoad() addCancleItem() fetchAlbumsFromSystemAlbum() } deinit { print(" ------1 deinit") PHPhotoLibrary.shared().unregisterChangeObserver(self) } // MARK: - 👉Private /// 获取所有系统相册概览信息 private func fetchAlbumsFromSystemAlbum() { let allPhotoOptions = PHFetchOptions() // 时间排序 allPhotoOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] allPhotos = PHAsset.fetchAssets(with: allPhotoOptions) smartAlbums = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .albumRegular, options: nil) userCollections = PHCollectionList.fetchTopLevelUserCollections(with: nil) // 监测系统相册增加,即使用期间是否拍照 PHPhotoLibrary.shared().register(self) // 注册cell tableView.register(MasterTableViewCell.self, forCellReuseIdentifier: MasterTableViewCell.cellIdentifier) } /// 添加取消按钮 private func addCancleItem() { let barItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(dismissAction)) navigationItem.rightBarButtonItem = barItem } @objc func dismissAction() { dismiss(animated: true, completion: nil) } // MARK: - 👉UITableViewDelegate & UITableViewDataSource override func numberOfSections(in tableView: UITableView) -> Int { return AlbumSession.count } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch AlbumSession(rawValue: section)! { case .albumAllPhotos: return 1 case .albumSmartAlbums: return smartAlbums.count case .albumUserCollection: return userCollections.count } } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return 64 } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: MasterTableViewCell.cellIdentifier, for: indexPath) as! MasterTableViewCell cell.selectionStyle = .none switch AlbumSession(rawValue: indexPath.section)! { case .albumAllPhotos: cell.asset = allPhotos.firstObject cell.albumTitleAndCount = ("所有照片", allPhotos.count) case .albumSmartAlbums: let collection = smartAlbums.object(at: indexPath.row) cell.asset = PHAsset.fetchAssets(in: collection, options: nil).firstObject cell.albumTitleAndCount = (collection.localizedTitle, PHAsset.fetchAssets(in: collection, options: nil).count) case .albumUserCollection: let collection = userCollections.object(at: indexPath.row) cell.asset = PHAsset.fetchAssets(in: collection as! PHAssetCollection, options: nil).firstObject cell.albumTitleAndCount = (collection.localizedTitle, PHAsset.fetchAssets(in: collection as! PHAssetCollection, options: nil).count) } return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let gridVC = NAPublishAssetViewController() switch AlbumSession(rawValue: indexPath.section)! { case .albumAllPhotos: gridVC.fetchAllPhtos = allPhotos case .albumSmartAlbums: gridVC.assetCollection = smartAlbums.object(at: indexPath.row) gridVC.fetchAllPhtos = PHAsset.fetchAssets(in: gridVC.assetCollection!, options: nil) case .albumUserCollection: gridVC.assetCollection = userCollections.object(at: indexPath.row) as? PHAssetCollection gridVC.fetchAllPhtos = PHAsset.fetchAssets(in: gridVC.assetCollection!, options: nil) } let currentCell = tableView.cellForRow(at: indexPath) as! MasterTableViewCell gridVC.title = currentCell.albumTitleAndCount?.0 navigationController?.pushViewController(gridVC, animated: true) } } // MARK: - 👉PHPhotoLibraryChangeObserver extension NAPublishAlbumTableViewController: PHPhotoLibraryChangeObserver { /// 系统相册改变 func photoLibraryDidChange(_ changeInstance: PHChange) { DispatchQueue.main.sync { if let changeDetails = changeInstance.changeDetails(for: allPhotos) { allPhotos = changeDetails.fetchResultAfterChanges } if let changeDetail = changeInstance.changeDetails(for: smartAlbums) { smartAlbums = changeDetail.fetchResultAfterChanges tableView.reloadSections(IndexSet(integer: AlbumSession.albumSmartAlbums.rawValue), with: .automatic) } if let changeDetail = changeInstance.changeDetails(for: userCollections) { userCollections = changeDetail.fetchResultAfterChanges tableView.reloadSections(IndexSet(integer: AlbumSession.albumUserCollection.rawValue), with: .automatic) } } } } // MARK: - 👉MasterTableViewCell class MasterTableViewCell: UITableViewCell { static let cellIdentifier = "MasterTableViewCellIdentifier" private var firstImageView: UIImageView? private var albumTitleLabel: UILabel? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } override func layoutSubviews() { super.layoutSubviews() updateUI() } private func updateUI() { let width = bounds.height firstImageView?.frame = CGRect(x: 0, y: 0, width: width, height: width) albumTitleLabel?.frame = CGRect(x: firstImageView!.frame.maxX + 5, y: 4, width: 200, height: width) } private func setupUI() { firstImageView = UIImageView() addSubview(firstImageView!) firstImageView?.clipsToBounds = true firstImageView?.contentMode = .scaleAspectFill albumTitleLabel = UILabel() albumTitleLabel?.font = UIFont.boldSystemFont(ofSize: 17) addSubview(albumTitleLabel!) } // 展示第一张图片和标题 var asset: PHAsset? { willSet { if newValue == nil { firstImageView?.image = UIImage.init(named: "icon-60") return } let defaultSize = CGSize(width: UIScreen.main.scale + bounds.height, height: UIScreen.main.scale + bounds.height) PHCachingImageManager.default().requestImage(for: newValue!, targetSize: defaultSize, contentMode: .aspectFill, options: nil, resultHandler: { (img, _) in self.firstImageView?.image = img }) } } var albumTitleAndCount: (String?, Int)? { willSet { if newValue == nil { return } self.albumTitleLabel?.text = (newValue!.0 ?? "") + " (\(String(describing: newValue!.1)))" } } }
// // NAPublishAssetViewController.swift //// // Created by on 2019/3/23. // Copyright © . All rights reserved. // /// 查看一个相册文件夹中的所有图片 import UIKit import Photos class NAPublishAssetViewController : UIViewController { // MARK: - 👉Properties fileprivate var collectionView: UICollectionView! fileprivate let imageManager = PHCachingImageManager() fileprivate var thumnailSize = CGSize() fileprivate var previousPreheatRect = CGRect.zero // 展示选择数量 fileprivate var countView: UIView! fileprivate var countLabel: UILabel! fileprivate var countButton: UIButton! fileprivate let countViewHeight: CGFloat = 50 fileprivate var isShowCountView = false // 是否只选择一张,如果是,则每个图片不显示选择图标 fileprivate var isOnlyOne = true // 选择图片数 fileprivate var count: Int = 0 // 选择回调 fileprivate var handlePhotos: HandlePhotos? // 回调Asset fileprivate var selectedAssets = [PHAsset]() { willSet { updateCountView(with: newValue.count) } } // 回调Image fileprivate var selectedImages = [UIImage]() // 选择标识 fileprivate var flags = [Bool]() // itemSize fileprivate let shape: CGFloat = 3 fileprivate let numbersInSingleLine: CGFloat = 4 fileprivate var cellWidth: CGFloat? { return (UIScreen.main.bounds.width - (numbersInSingleLine - 1) * shape) / numbersInSingleLine } // MARK: - 👉Lifecycle override func viewDidLoad() { super.viewDidLoad() automaticallyAdjustsScrollViewInsets = false resetCachedAssets() PHPhotoLibrary.shared().register(self) // 设置回调 count = HandleSelectionPhotosManager.share.maxCount handlePhotos = HandleSelectionPhotosManager.share.callbackPhotos isOnlyOne = count == 1 ? true : false setupUI() // 添加数量视图 addCountView() // 监测数据源 if fetchAllPhtos == nil { let allOptions = PHFetchOptions() allOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] fetchAllPhtos = PHAsset.fetchAssets(with: allOptions) collectionView.reloadData() } (0 ..< fetchAllPhtos.count).forEach { _ in flags.append(false) } } override func viewWillAppear(_ animated: Bool) { super.viewWillDisappear(animated) // 定义缓存照片尺寸 thumnailSize = CGSize(width: cellWidth! * UIScreen.main.scale, height: cellWidth! * UIScreen.main.scale) // collectionView 滑动到最底部 guard fetchAllPhtos.count > 0 else { return } let indexPath = IndexPath(item: fetchAllPhtos.count - 1, section: 0) collectionView.scrollToItem(at: indexPath, at: .bottom, animated: false) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // 更新 updateCachedAssets() } deinit { PHPhotoLibrary.shared().unregisterChangeObserver(self) } // MARK: - 👉Public // 所有图片 internal var fetchAllPhtos: PHFetchResult<PHAsset>! // 单个相册 internal var assetCollection: PHAssetCollection! // MARK: - 👉Private /// 展示 private func setupUI() { let cvLayout = UICollectionViewFlowLayout() cvLayout.itemSize = CGSize(width: cellWidth!, height: cellWidth!) cvLayout.minimumLineSpacing = shape cvLayout.minimumInteritemSpacing = shape collectionView = UICollectionView(frame: CGRect(x: 0, y: 64, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height - 64), collectionViewLayout: cvLayout) view.addSubview(collectionView) collectionView.register(GridViewCell.self, forCellWithReuseIdentifier: GridViewCell.cellIdentifier) collectionView.dataSource = self collectionView.delegate = self collectionView.backgroundColor = .white view.addSubview(collectionView) addCancleItem() } /// count private func addCountView() { countView = UIView(frame: CGRect(x: 0, y: UIScreen.main.bounds.height, width: UIScreen.main.bounds.width, height: countViewHeight)) countView.backgroundColor = UIColor(white: 0.85, alpha: 1) view.addSubview(countView) countLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 35)) countLabel.backgroundColor = .clear countLabel.textColor = .green countLabel.textAlignment = .center countLabel.text = "0/8" countLabel.font = UIFont.systemFont(ofSize: 17) countLabel.center = CGPoint(x: countView.bounds.width / 2, y: countView.bounds.height / 2) countView.addSubview(countLabel) countButton = UIButton(frame: CGRect(x: UIScreen.main.bounds.width - 68, y: 0, width: 50, height: countViewHeight)) countButton.setTitle("完成", for: .normal) countButton.setTitleColor(newColor(153, 153, 153), for: .normal) countButton.addTarget(self, action: #selector(selectedOverAction), for: .touchUpInside) countView.addSubview(countButton) } /// 照片选择结束 @objc func selectedOverAction() { handlePhotos?(selectedAssets, selectedImages) dismissAction() } /// 根据选择照片数量动态展示CountView /// /// - Parameter photoCount: photoCount description private func updateCountView(with photoCount: Int) { countLabel.text = "\(String(describing: photoCount))/8" if isShowCountView && photoCount != 0 { return } if photoCount == 0 { isShowCountView = false UIView.animate(withDuration: 0.3, animations: { self.countView.frame.origin = CGPoint(x: 0, y: UIScreen.main.bounds.height) self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentOffset.y - self.countViewHeight) }) } else { isShowCountView = true UIView.animate(withDuration: 0.3, animations: { self.countView.frame.origin = CGPoint(x: 0, y: UIScreen.main.bounds.height - self.countViewHeight) self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentOffset.y + self.countViewHeight) }) } } /// 添加取消按钮 private func addCancleItem() { let barItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(dismissAction)) navigationItem.rightBarButtonItem = barItem } @objc func dismissAction() { dismiss(animated: true, completion: nil) } // 展示选择数量的视图 // MARK: PHAsset Caching /// 重置图片缓存 private func resetCachedAssets() { imageManager.stopCachingImagesForAllAssets() previousPreheatRect = .zero } /// 更新图片缓存设置 fileprivate func updateCachedAssets() { // 视图可访问时才更新 guard isViewLoaded && view.window != nil else { return } // 预加载视图的高度是可见视图的两倍,这样滑动时才不会有阻塞 let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let preheatRect = visibleRect.insetBy(dx: 0, dy: -0.5 * visibleRect.height) // 只有可见视图与预加载视图有明显不同时,才会更新 let delta = abs(preheatRect.maxY - previousPreheatRect.maxY) guard delta > view.bounds.height / 3 else { return } // 计算 assets 用来开始和结束缓存 let (addedRects, removeRects) = differencesBetweenRects(previousPreheatRect, preheatRect) let addedAssets = addedRects .flatMap { rect in collectionView.indexPathsForElements(in: rect)} .map { indexPath in fetchAllPhtos.object(at: indexPath.item) } let removedAssets = removeRects .flatMap { rect in collectionView.indexPathsForElements(in: rect) } .map { indexPath in fetchAllPhtos.object(at: indexPath.item) } // 更新图片缓存 imageManager.startCachingImages(for: addedAssets, targetSize: thumnailSize, contentMode: .aspectFill, options: nil) imageManager.stopCachingImages(for: removedAssets, targetSize: thumnailSize, contentMode: .aspectFill, options: nil) // 保存最新的预加载尺寸用来和后面的对比 previousPreheatRect = preheatRect } /// 计算新旧位置的差值 /// /// - Parameters: /// - old: old description /// - new: new description /// - Returns: return value description private func differencesBetweenRects(_ old: CGRect, _ new: CGRect) -> (added: [CGRect], removed: [CGRect]) { // 新旧有交集 if old.intersects(new) { // 增加值 var added = [CGRect]() if new.maxY > old.maxY { added += [CGRect(x: new.origin.x, y: old.maxY, width: new.width, height: new.maxY - old.maxY)] } if new.minY < old.minY { added += [CGRect(x: new.origin.x, y: new.minY, width: new.width, height: old.minY - new.minY)] } // 移除值 var removed = [CGRect]() if new.maxY < old.maxY { removed += [CGRect(x: new.origin.x, y: new.maxY, width: new.width, height: old.maxY - new.maxY)] } if new.minY > old.minY { removed += [CGRect(x: new.origin.x, y: old.minY, width: new.width, height: new.minY - old.minY)] } return (added, removed) } // 没有交集 return ([new], [old]) } } extension NAPublishAssetViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return fetchAllPhtos.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: GridViewCell.cellIdentifier, for: indexPath) as! GridViewCell let asset = fetchAllPhtos.object(at: indexPath.item) cell.representAssetIdentifier = asset.localIdentifier // 从缓存中取出图片 imageManager.requestImage(for: asset, targetSize: thumnailSize, contentMode: .aspectFill, options: nil) { img, _ in // 代码执行到这里时cell可能已经被重用了,所以设置标识用来展示 if cell.representAssetIdentifier == asset.localIdentifier { cell.thumbnailImage = img } } // 防止重复 if isOnlyOne { cell.hiddenIcons() } else { cell.cellIsSelected = flags[indexPath.item] cell.handleSelectionAction = { isSelected in // 判断是否超过最大值 if self.selectedAssets.count > self.count - 1 && !cell.cellIsSelected { self.showAlert(with: "haha") cell.selectedButton.isSelected = false return } self.flags[indexPath.item] = isSelected cell.cellIsSelected = isSelected if isSelected { self.selectedAssets.append(self.fetchAllPhtos.object(at: indexPath.item)) self.selectedImages.append(cell.thumbnailImage!) } else { let deleteIndex1 = self.selectedAssets.index(of: self.fetchAllPhtos.object(at: indexPath.item)) self.selectedAssets.remove(at: deleteIndex1!) let deleteIndex2 = self.selectedImages.index(of: cell.thumbnailImage!) self.selectedImages.remove(at: deleteIndex2!) } } } return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard isOnlyOne else { return } let currentCell = collectionView.cellForItem(at: indexPath) as! GridViewCell handlePhotos?([fetchAllPhtos.object(at: indexPath.item)], [currentCell.thumbnailImage!]) dismissAction() } func scrollViewDidScroll(_ scrollView: UIScrollView) { updateCachedAssets() } func showAlert(with title: String) { let alertVC = UIAlertController(title: "最多只能选择 \(count) 张图片", message: nil, preferredStyle: .alert) alertVC.addAction(UIAlertAction(title: "确定", style: .default, handler: nil)) DispatchQueue.main.async { self.present(alertVC, animated: true, completion: nil) } } } // MARK: - 👉PHPhotoLibraryChangeObserver extension NAPublishAssetViewController: PHPhotoLibraryChangeObserver { func photoLibraryDidChange(_ changeInstance: PHChange) { } } // MARK: - 👉UICollectionView Extension private extension UICollectionView { /// 获取可见视图内的所有对象,用于更高效刷新 /// /// - Parameter rect: rect description /// - Returns: return value description func indexPathsForElements(in rect: CGRect) -> [IndexPath] { let allLayoutAttributes = collectionViewLayout.layoutAttributesForElements(in: rect)! return allLayoutAttributes.map { $0.indexPath } } } // MARK: - 👉GridViewCell class GridViewCell: UICollectionViewCell { // MARK: - 👉Properties private var cellImageView: UIImageView! private var selectionIcon: UIButton! var selectedButton: UIButton! private let slectionIconWidth: CGFloat = 20 static let cellIdentifier = "GridViewCell-Asset" // MARK: - 👉LifeCycle override init(frame: CGRect) { super.init(frame: frame) setupUI() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } // MARK: - 👉Public var representAssetIdentifier: String! var thumbnailImage: UIImage? { willSet { cellImageView?.image = newValue } } var cellIsSelected: Bool = false { willSet { selectionIcon.isSelected = newValue } } /// 隐藏选择按钮和图标 func hiddenIcons() { selectionIcon.isHidden = true selectedButton.isHidden = true } // 点击选择回调 var handleSelectionAction: ((Bool) -> Void)? // MARK: - 👉Private private func setupUI() { // 图片 cellImageView = UIImageView(frame: bounds) cellImageView?.clipsToBounds = true cellImageView?.contentMode = .scaleAspectFill contentView.addSubview(cellImageView!) // 选择图标 selectionIcon = UIButton(frame: CGRect(x: 0, y: 0, width: slectionIconWidth, height: slectionIconWidth)) selectionIcon.center = CGPoint(x: bounds.width - 2 - selectionIcon.bounds.width / 2, y: selectionIcon.bounds.height / 2) selectionIcon.setImage(UIImage.init(named: "ic_select"), for: .normal) selectionIcon.setImage(UIImage.init(named: "ic_select"), for: .selected) contentView.addSubview(selectionIcon) // 选择按钮 selectedButton = UIButton(frame: CGRect(x: 0, y: 0, width: bounds.width * 2 / 5, height: bounds.width * 2 / 5)) selectedButton.center = CGPoint(x: bounds.width - selectedButton.bounds.width / 2, y: selectedButton.bounds.width / 2) selectedButton.backgroundColor = .clear contentView.addSubview(selectedButton) selectedButton.addTarget(self, action: #selector(selectionItemAction(btn:)), for: .touchUpInside) } @objc private func selectionItemAction(btn: UIButton) { btn.isSelected = !btn.isSelected handleSelectionAction?(btn.isSelected) } }
使用:
@objc func tapAction(action:UITapGestureRecognizer) -> Void { let masterVC = NAPublishAlbumTableViewController () let navi = UINavigationController(rootViewController: masterVC) masterVC.title = "图片" let gridVC = NAPublishAssetViewController() gridVC.title = "所有图片" navi.pushViewController(gridVC, animated: false) getCurrentVc()!.present(navi, animated: true) //多选最多8张 HandleSelectionPhotosManager.share.getSelectedPhotos(with: 8) { (assets, images) in print("\(images)---------\(assets)") self.imagesArray = images self.reloadCollectionViewConstraints() } //单选1张 HandleSelectionPhotosManager.share.getSelectedPhotos(with: 1) { (assets, images) in print("\(images)---------\(assets)") self.imagesArray = images self.reloadCollectionViewConstraints() }
效果图:
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 百万级群聊的设计实践
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 全网最简单!3分钟用满血DeepSeek R1开发一款AI智能客服,零代码轻松接入微信、公众号、小程
· .NET 10 首个预览版发布,跨平台开发与性能全面提升
· 《HelloGitHub》第 107 期