diff --git a/BSImagePicker.xcodeproj/project.pbxproj b/BSImagePicker.xcodeproj/project.pbxproj index f0c03fc5..5e1eb1de 100644 --- a/BSImagePicker.xcodeproj/project.pbxproj +++ b/BSImagePicker.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 2F0FADE526669B0F005747B4 /* Size+BSImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0FADE426669B0F005747B4 /* Size+BSImagePicker.swift */; }; + 2F0FADE826669B3C005747B4 /* AutorizationStatusHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0FADE726669B3C005747B4 /* AutorizationStatusHeaderView.swift */; }; + 2F0FADEC2666A140005747B4 /* Alert+BSImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0FADEB2666A140005747B4 /* Alert+BSImagePicker.swift */; }; 531D8C532249C81A00281681 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531D8C522249C81A00281681 /* SettingsTests.swift */; }; 53FEDA162247FEB80098E34A /* CGSize+Scale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FEDA152247FEB80098E34A /* CGSize+Scale.swift */; }; 53FEDA18224805BA0098E34A /* CGSizeExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FEDA17224805BA0098E34A /* CGSizeExtensionTests.swift */; }; @@ -78,6 +81,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 2F0FADE426669B0F005747B4 /* Size+BSImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Size+BSImagePicker.swift"; sourceTree = ""; }; + 2F0FADE726669B3C005747B4 /* AutorizationStatusHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutorizationStatusHeaderView.swift; sourceTree = ""; }; + 2F0FADEB2666A140005747B4 /* Alert+BSImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Alert+BSImagePicker.swift"; sourceTree = ""; }; 531D8C522249C81A00281681 /* SettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTests.swift; sourceTree = ""; }; 53FEDA152247FEB80098E34A /* CGSize+Scale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Scale.swift"; sourceTree = ""; }; 53FEDA17224805BA0098E34A /* CGSizeExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensionTests.swift; sourceTree = ""; }; @@ -222,6 +228,7 @@ C2DC13C923F75BDA0035FD13 /* NumberView.swift */, 55BCF8C521D52C1000386752 /* CameraCollectionViewCell.swift */, 55F67B7A222F088500805134 /* GradientView.swift */, + 2F0FADE726669B3C005747B4 /* AutorizationStatusHeaderView.swift */, ); path = Assets; sourceTree = ""; @@ -337,6 +344,8 @@ isa = PBXGroup; children = ( 94DA686F247BDE5900CD5251 /* UIColor+BSImagePicker.swift */, + 2F0FADE426669B0F005747B4 /* Size+BSImagePicker.swift */, + 2F0FADEB2666A140005747B4 /* Alert+BSImagePicker.swift */, ); path = Extension; sourceTree = ""; @@ -462,6 +471,7 @@ 550CB425230FD01200543217 /* ImageView.swift in Sources */, 55BCF8D521D52C1000386752 /* AssetCollectionViewCell.swift in Sources */, 559DB81B21E6B43400CD58B4 /* ImagePickerController+ButtonActions.swift in Sources */, + 2F0FADEC2666A140005747B4 /* Alert+BSImagePicker.swift in Sources */, 559DB81121E6561300CD58B4 /* ImagePickerController+Closure.swift in Sources */, 55C5B802222BD529003CF3F1 /* DropdownPresentationController.swift in Sources */, 55BCF8E021D52C1000386752 /* ZoomAnimator.swift in Sources */, @@ -471,11 +481,13 @@ 94DA6870247BDE5900CD5251 /* UIColor+BSImagePicker.swift in Sources */, 5554729D21E5248400B90CA5 /* ImagePickerController.swift in Sources */, 55C5B800222BD445003CF3F1 /* DropdownTransitionDelegate.swift in Sources */, + 2F0FADE526669B0F005747B4 /* Size+BSImagePicker.swift in Sources */, 55BCF8D321D52C1000386752 /* AssetsCollectionViewDataSource.swift in Sources */, 550CB427230FD08200543217 /* ImageViewLayout.swift in Sources */, 55BCF8D621D52C1000386752 /* AlbumCell.swift in Sources */, 55BCF8D021D52C1000386752 /* AlbumsTableViewDataSource.swift in Sources */, 55F67B77222EEB2500805134 /* VideoCollectionViewCell.swift in Sources */, + 2F0FADE826669B3C005747B4 /* AutorizationStatusHeaderView.swift in Sources */, 559DB80F21E655D000CD58B4 /* ImagePickerControllerDelegate.swift in Sources */, 559DB81721E6AFD800CD58B4 /* ImagePickerController+Assets.swift in Sources */, 55CDB45B223435420050D572 /* PlayerView.swift in Sources */, diff --git a/Example.xcodeproj/project.pbxproj b/Example.xcodeproj/project.pbxproj index 9538c31c..cdb254d5 100644 --- a/Example.xcodeproj/project.pbxproj +++ b/Example.xcodeproj/project.pbxproj @@ -345,6 +345,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = Y2NHS7RD78; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -407,6 +408,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = Y2NHS7RD78; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/Example/ViewController.swift b/Example/ViewController.swift index a36de3fb..11044662 100644 --- a/Example/ViewController.swift +++ b/Example/ViewController.swift @@ -32,6 +32,7 @@ class ViewController: UIViewController { imagePicker.settings.theme.selectionStyle = .numbered imagePicker.settings.fetch.assets.supportedMediaTypes = [.image, .video] imagePicker.settings.selection.unselectOnReachingMax = true + imagePicker.settings.permission.enabled = true let start = Date() self.presentImagePicker(imagePicker, select: { (asset) in @@ -76,7 +77,7 @@ class ViewController: UIViewController { return 2 } } - + self.presentImagePicker(imagePicker, select: { (asset) in print("Selected: \(asset)") }, deselect: { (asset) in @@ -91,16 +92,17 @@ class ViewController: UIViewController { @IBAction func showImagePickerWithSelectedAssets(_ sender: UIButton) { let allAssets = PHAsset.fetchAssets(with: PHAssetMediaType.image, options: nil) var evenAssets = [PHAsset]() - + allAssets.enumerateObjects({ (asset, idx, stop) -> Void in if idx % 2 == 0 { evenAssets.append(asset) } }) - + let imagePicker = ImagePickerController(selectedAssets: evenAssets) imagePicker.settings.fetch.assets.supportedMediaTypes = [.image] - + + self.presentImagePicker(imagePicker, select: { (asset) in print("Selected: \(asset)") }, deselect: { (asset) in @@ -111,5 +113,40 @@ class ViewController: UIViewController { print("Finished with selections: \(assets)") }) } + + func showImagePickerWithPermissionHandling() { + let imagePicker = ImagePickerController() + imagePicker.settings.selection.max = 5 + imagePicker.settings.theme.selectionStyle = .numbered + imagePicker.settings.fetch.assets.supportedMediaTypes = [.image, .video] + imagePicker.settings.selection.unselectOnReachingMax = true + imagePicker.settings.permission.enabled = true + + imagePicker.imagePickerDelegate = self + self.present(imagePicker, animated: true, completion: nil) + } + } + +extension ViewController: ImagePickerControllerDelegate { + func imagePicker(_ imagePicker: ImagePickerController, didSelectAsset asset: PHAsset) { + print("Selected: \(asset)") + } + + func imagePicker(_ imagePicker: ImagePickerController, didDeselectAsset asset: PHAsset) { + print("Deselected: \(asset)") + } + + func imagePicker(_ imagePicker: ImagePickerController, didFinishWithAssets assets: [PHAsset]) { + print("Finished with selections: \(assets)") + } + + func imagePicker(_ imagePicker: ImagePickerController, didCancelWithAssets assets: [PHAsset]) { + print("Canceled with selections: \(assets)") + } + + func imagePicker(_ imagePicker: ImagePickerController, didReachSelectionLimit count: Int) { + + } +} diff --git a/Sources/Controller/ImagePickerController.swift b/Sources/Controller/ImagePickerController.swift index b3a4af0f..696821cd 100644 --- a/Sources/Controller/ImagePickerController.swift +++ b/Sources/Controller/ImagePickerController.swift @@ -28,6 +28,7 @@ import Photos @objcMembers open class ImagePickerController: UINavigationController { // MARK: Public properties public weak var imagePickerDelegate: ImagePickerControllerDelegate? + public var settings: Settings = Settings() public var doneButton: UIBarButtonItem = UIBarButtonItem(title: "", style: .done, target: nil, action: nil) public var cancelButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: nil, action: nil) @@ -93,6 +94,7 @@ import Photos // Setup view controllers albumsViewController.delegate = self assetsViewController.delegate = self + viewControllers = [assetsViewController] view.backgroundColor = settings.theme.backgroundColor diff --git a/Sources/Controller/ImagePickerControllerDelegate.swift b/Sources/Controller/ImagePickerControllerDelegate.swift index 51bd48c7..b3a26f13 100644 --- a/Sources/Controller/ImagePickerControllerDelegate.swift +++ b/Sources/Controller/ImagePickerControllerDelegate.swift @@ -49,4 +49,6 @@ public protocol ImagePickerControllerDelegate: class { /// - Parameter imagePicker: The image picker that selection limit was reached in. /// - Parameter count: Number of selected assets. func imagePicker(_ imagePicker: ImagePickerController, didReachSelectionLimit count: Int) + } + diff --git a/Sources/Extension/Alert+BSImagePicker.swift b/Sources/Extension/Alert+BSImagePicker.swift new file mode 100644 index 00000000..b98ed70c --- /dev/null +++ b/Sources/Extension/Alert+BSImagePicker.swift @@ -0,0 +1,112 @@ +// The MIT License (MIT) +// +// Copyright (c) 2021 Mithilesh Parmar +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import UIKit +import Photos + +extension AssetsViewController { + internal func showAlertForRestricedOrNotDeterminedAccess(){ + + let localizedTitle = NSLocalizedString("bsimagepicker.restrictedAccess.alert.title", + value: "Allow access to your photos", + comment: "Title of alert controller") + + let localizedMessage = NSLocalizedString("bsimagepicker.restrictedAccess.alert.message", + value: "This lets you share from your camera roll and enables other features for photos. Go to your settings and tap \"Photos\".", + comment: "Message of alert controller") + + let notNowLocalizedText = NSLocalizedString("bsimagepicker.restrictedAccess.alert.secondaryButton.title", + value: "Not now", + comment: "Secondary button title") + + let openSettinsgLocalizedText = NSLocalizedString("bsimagepicker.restrictedAccess.alert.openSettings.title", + value: "Open Settings", + comment: "Primart button title") + + let alert = UIAlertController(title: localizedTitle, message: localizedMessage, preferredStyle: .alert) + + let notNowAction = UIAlertAction(title: notNowLocalizedText, style: .cancel, handler: nil) + alert.addAction(notNowAction) + + let openSettingsAction = UIAlertAction(title: openSettinsgLocalizedText, style: .default) { [unowned self] (_) in + // Open app privacy settings + gotoAppPrivacySettings() + } + alert.addAction(openSettingsAction) + present(alert, animated: true, completion: nil) + } + + internal func showAlerForLimitedAccess(){ + + let localizedTitle = NSLocalizedString("bsimagepicker.limitedAccess.alert.title", + value: " ", + comment: "Title of Alert controller") + + let localizedMessage = NSLocalizedString("bsimagepicker.limitedAccess.alert.message", + value: "Select more photos or go to Settings to allow access to all photos.", + comment: "Message of Alert controller") + + let selectMorePhotosLocalizedText = NSLocalizedString("bsimagepicker.limitedAccess.alert.selectMorePhotos", + value: "Select more photos", + comment: "Select more photos action title") + + let allowAccessToAllPhotosLocalizedText = NSLocalizedString("bsimagepicker.limitedAccess.alert.allowAccessToAllPhotos", + value: "Allow access to all photos", + comment: "Allow access to all photos action title") + + let cancelLocalizedText = NSLocalizedString("bsimagepicker.cancel", + value: "Cancel", + comment: "Cancel action title") + + let actionSheet = UIAlertController(title: localizedTitle, message: localizedMessage, preferredStyle: .actionSheet) + + let selectPhotosAction = UIAlertAction(title: selectMorePhotosLocalizedText, style: .default) { [unowned self] (_) in + // Show limited library picker + if #available(iOS 14, *) { + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self) + } + } + actionSheet.addAction(selectPhotosAction) + + let allowFullAccessAction = UIAlertAction(title: allowAccessToAllPhotosLocalizedText, style: .default) { [unowned self] (_) in + // Open app privacy settings + self.gotoAppPrivacySettings() + } + actionSheet.addAction(allowFullAccessAction) + + let cancelAction = UIAlertAction(title: cancelLocalizedText, style: .cancel, handler: nil) + actionSheet.addAction(cancelAction) + + present(actionSheet, animated: true, completion: nil) + + } + + internal func gotoAppPrivacySettings() { + guard let url = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(url) else { + assertionFailure("Not able to open App privacy settings") + return + } + + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } +} diff --git a/Sources/Extension/Size+BSImagePicker.swift b/Sources/Extension/Size+BSImagePicker.swift new file mode 100644 index 00000000..16883e38 --- /dev/null +++ b/Sources/Extension/Size+BSImagePicker.swift @@ -0,0 +1,34 @@ +// The MIT License (MIT) +// +// Copyright (c) 2021 Mithilesh Parmar +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import UIKit + +extension UIView { + func runtimeSize()-> CGSize { + let size: CGSize = self.systemLayoutSizeFitting( + CGSize(width: UIScreen.main.bounds.width, + height: UIView.layoutFittingExpandedSize.height), + withHorizontalFittingPriority: .fittingSizeLevel, + verticalFittingPriority: .fittingSizeLevel) + return size + } +} diff --git a/Sources/Model/Settings.swift b/Sources/Model/Settings.swift index da951b22..cb713190 100755 --- a/Sources/Model/Settings.swift +++ b/Sources/Model/Settings.swift @@ -26,7 +26,7 @@ import Photos @objc(BSImagePickerSettings) // Fix for ObjC header name conflicting. @objcMembers public class Settings : NSObject { public static let shared = Settings() - + // Move all theme related stuff to UIAppearance public class Theme : NSObject { /// Main background color @@ -79,7 +79,7 @@ import Photos /// If it reaches the max limit, unselect the first selection, and allow the new selection @objc public lazy var unselectOnReachingMax : Bool = false } - + @objc(BSImagePickerList) @objcMembers public class List : NSObject { /// How much spacing between cells @@ -99,12 +99,12 @@ import Photos } } } - + public class Preview : NSObject { /// Is preview enabled? public lazy var enabled: Bool = true } - + @objc(BSImagePickerFetch) @objcMembers public class Fetch : NSObject { @objc(BSImagePickerAlbum) @@ -114,7 +114,7 @@ import Photos let fetchOptions = PHFetchOptions() return fetchOptions }() - + /// Fetch results for asset collections you want to present to the user /// Some other fetch results that you might wanna use: /// PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumFavorites, options: options), @@ -126,16 +126,16 @@ import Photos PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumUserLibrary, options: options), ] } - + @objc(BSImagePickerAssets) @objcMembers public class Assets : NSObject { /// Fetch options for assets - + /// Simple wrapper around PHAssetMediaType to ensure we only expose the supported types. public enum MediaTypes { case image case video - + fileprivate var assetMediaType: PHAssetMediaType { switch self { case .image: @@ -146,48 +146,48 @@ import Photos } } public lazy var supportedMediaTypes: Set = [.image] - + public lazy var options: PHFetchOptions = { let fetchOptions = PHFetchOptions() fetchOptions.sortDescriptors = [ NSSortDescriptor(key: "creationDate", ascending: false) ] - + let rawMediaTypes = supportedMediaTypes.map { $0.assetMediaType.rawValue } let predicate = NSPredicate(format: "mediaType IN %@", rawMediaTypes) fetchOptions.predicate = predicate - + return fetchOptions }() } - + public class Preview : NSObject { public lazy var photoOptions: PHImageRequestOptions = { let options = PHImageRequestOptions() options.isNetworkAccessAllowed = true - + return options }() - + public lazy var livePhotoOptions: PHLivePhotoRequestOptions = { let options = PHLivePhotoRequestOptions() options.isNetworkAccessAllowed = true return options }() - + public lazy var videoOptions: PHVideoRequestOptions = { let options = PHVideoRequestOptions() options.isNetworkAccessAllowed = true return options }() } - + /// Album fetch settings public lazy var album = Album() /// Asset fetch settings public lazy var assets = Assets() - + /// Preview fetch settings public lazy var preview = Preview() } @@ -195,11 +195,47 @@ import Photos public class Dismiss : NSObject { /// Should the image picker dismiss when done/cancelled public lazy var enabled = true - + /// Allow the user to dismiss the image picker by swiping down public lazy var allowSwipe = false } - + + public class Permission: NSObject { + /// should the image picker check for permission when loaded and give callbacks + + /// Localizable string keys + + /// bsimagepicker.limitedPermissionHeader.title -> Header title when access to photos is limited + /// bsimagepicker.restrictedOrNotDeterminedpermissionHeader.title -> Header title when access to photos is restricted or not determined + /// bsimagepicker.permissionHeader.button.title -> Button title of Header view + + /// bsimagepicker.limitedAccess.alert.title -> Title of alert controller when access is Limited + /// bsimagepicker.limitedAccess.alert.message -> Message of alert controller when access is Limited + /// bsimagepicker.limitedAccess.alert.selectMorePhotos -> Title of Select more photos action of alert controller when access is Limited + /// bsimagepicker.limitedAccess.alert.allowAccessToAllPhotos -> Title of Allow access to all photos of alert controller when access is Limited + /// bsimagepicker.cancel -> Title of cancel action of alert controller when access is Limited + + /// bsimagepicker.restrictedAccess.alert.title -> Title of alert controller when access is Restricted or not determined + /// bsimagepicker.restrictedAccess.alert.message -> Message of alert controller when access is Restricted or not determined + /// bsimagepicker.restrictedAccess.alert.secondaryButton.title -> Title of secondary action of alert controller when access is Restricted or not determined + /// bsimagepicker.restrictedAccess.alert.openSettings.title -> Title of primary action of alert controller when access is Restricted or not determined + + public lazy var enabled = false + + public var limitedPermissionHeaderTitle: String = NSLocalizedString("bsimagepicker.limitedPermissionHeader.title", value: "You've given access to only select number of photos.", comment: "Title of header when access is limited") + public var limitedPermissionHeaderBackgroundColor: UIColor = .clear + public var limitedPermissionHeaderTitleColor: UIColor = .systemPrimaryTextColor + + + public var restrictedOrNotDeterminedPermissionHeaderTitle: String = NSLocalizedString("bsimagepicker.restrictedOrNotDeterminedpermissionHeader.title", value: "You've given access to only select number of photos.", comment: "Title of header when access is restricted or not determined") + public var restrictedOrNotDeterminedPermissionHeaderBackgroundColor: UIColor = .clear + public var restrictedOrNotDeterminedPermissionHeaderTitleColor: UIColor = .systemPrimaryTextColor + + + public lazy var manageButtonText: String = NSLocalizedString("bsimagepicker.permissionHeader.button.title", value: "Manage", comment: "Title of the button in header view") + public lazy var manageButtonTextColor: UIColor = .systemBlue + } + /// Theme settings public lazy var theme = Theme() @@ -214,7 +250,10 @@ import Photos /// Dismiss settings public lazy var dismiss = Dismiss() - + /// Preview options public lazy var preview = Preview() + + /// permission options + public lazy var permission = Permission() } diff --git a/Sources/Scene/Assets/AssetsCollectionViewDataSource.swift b/Sources/Scene/Assets/AssetsCollectionViewDataSource.swift index 509128f9..07aee761 100755 --- a/Sources/Scene/Assets/AssetsCollectionViewDataSource.swift +++ b/Sources/Scene/Assets/AssetsCollectionViewDataSource.swift @@ -23,11 +23,13 @@ import UIKit import Photos -class AssetsCollectionViewDataSource : NSObject, UICollectionViewDataSource { +class AssetsCollectionViewDataSource : NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private static let assetCellIdentifier = "AssetCell" private static let videoCellIdentifier = "VideoCell" + private var authorizationStatus: PHAuthorizationStatus? var settings: Settings! + var fetchResult: PHFetchResult { didSet { imageManager.stopCachingImagesForAllAssets() @@ -38,11 +40,13 @@ class AssetsCollectionViewDataSource : NSObject, UICollectionViewDataSource { imageManager.stopCachingImagesForAllAssets() } } - + private let imageManager = PHCachingImageManager() private let durationFormatter = DateComponentsFormatter() private let store: AssetStore private let contentMode: PHImageContentMode = .aspectFill + weak var headerViewDelegate: AutorizationStatusHeaderViewDelegate? + init(fetchResult: PHFetchResult, store: AssetStore) { self.fetchResult = fetchResult @@ -61,6 +65,8 @@ class AssetsCollectionViewDataSource : NSObject, UICollectionViewDataSource { return fetchResult.count } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let asset = fetchResult[indexPath.row] let animationsWasEnabled = UIView.areAnimationsEnabled @@ -75,7 +81,7 @@ class AssetsCollectionViewDataSource : NSObject, UICollectionViewDataSource { cell = collectionView.dequeueReusableCell(withReuseIdentifier: AssetsCollectionViewDataSource.assetCellIdentifier, for: indexPath) as! AssetCollectionViewCell } UIView.setAnimationsEnabled(animationsWasEnabled) - + cell.accessibilityIdentifier = "Photo \(indexPath.item + 1)" cell.accessibilityTraits = UIAccessibilityTraits.button cell.isAccessibilityElement = true @@ -91,6 +97,7 @@ class AssetsCollectionViewDataSource : NSObject, UICollectionViewDataSource { static func registerCellIdentifiersForCollectionView(_ collectionView: UICollectionView?) { collectionView?.register(AssetCollectionViewCell.self, forCellWithReuseIdentifier: assetCellIdentifier) collectionView?.register(VideoCollectionViewCell.self, forCellWithReuseIdentifier: videoCellIdentifier) + collectionView?.register(AutorizationStatusHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: AutorizationStatusHeaderView.id) } private func loadImage(for asset: PHAsset, in cell: AssetCollectionViewCell) { @@ -105,6 +112,50 @@ class AssetsCollectionViewDataSource : NSObject, UICollectionViewDataSource { cell.imageView.image = image }) } + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + + if kind == UICollectionView.elementKindSectionHeader { + + let cell = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: AutorizationStatusHeaderView.id, for: indexPath) as! AutorizationStatusHeaderView + cell.delegate = headerViewDelegate + cell.manageButton.setTitle(settings.permission.manageButtonText, for: .normal) + cell.manageButton.setTitleColor(settings.permission.manageButtonTextColor, for: .normal) + + if let authorizationStatus = authorizationStatus { + switch authorizationStatus { + case .limited: + cell.titleLabel.text = settings.permission.limitedPermissionHeaderTitle + cell.titleLabel.textColor = settings.permission.limitedPermissionHeaderTitleColor + cell.backgroundColor = settings.permission.limitedPermissionHeaderBackgroundColor + cell.layoutIfNeeded() + break + case .restricted, .notDetermined: + cell.titleLabel.text = settings.permission.restrictedOrNotDeterminedPermissionHeaderTitle + cell.titleLabel.textColor = settings.permission.restrictedOrNotDeterminedPermissionHeaderTitleColor + cell.backgroundColor = settings.permission.restrictedOrNotDeterminedPermissionHeaderBackgroundColor + cell.layoutIfNeeded() + case .authorized, .denied: + break + @unknown default: + break + } + } + + cell.authorizationStatus = authorizationStatus + return cell + } + + fatalError("No header of kind \(kind) registerd for section \(indexPath.section)") + } + + func setStatus(_ status: PHAuthorizationStatus) { + self.authorizationStatus = status + } + + func getStatus() -> PHAuthorizationStatus? { + return self.authorizationStatus + } } extension AssetsCollectionViewDataSource: UICollectionViewDataSourcePrefetching { @@ -112,7 +163,7 @@ extension AssetsCollectionViewDataSource: UICollectionViewDataSourcePrefetching let assets = indexPaths.map { fetchResult[$0.row] } imageManager.startCachingImages(for: assets, targetSize: imageSize, contentMode: contentMode, options: nil) } - + func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { } } diff --git a/Sources/Scene/Assets/AssetsViewController.swift b/Sources/Scene/Assets/AssetsViewController.swift index 389730e2..6fb76a8e 100644 --- a/Sources/Scene/Assets/AssetsViewController.swift +++ b/Sources/Scene/Assets/AssetsViewController.swift @@ -23,7 +23,7 @@ import UIKit import Photos -protocol AssetsViewControllerDelegate: class { +protocol AssetsViewControllerDelegate: AnyObject { func assetsViewController(_ assetsViewController: AssetsViewController, didSelectAsset asset: PHAsset) func assetsViewController(_ assetsViewController: AssetsViewController, didDeselectAsset asset: PHAsset) func assetsViewController(_ assetsViewController: AssetsViewController, didLongPressCell cell: AssetCollectionViewCell, displayingAsset asset: PHAsset) @@ -31,66 +31,80 @@ protocol AssetsViewControllerDelegate: class { class AssetsViewController: UIViewController { weak var delegate: AssetsViewControllerDelegate? + + var settings: Settings! { didSet { dataSource.settings = settings } } - + private let store: AssetStore - private let collectionView: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + internal let collectionView: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) private var fetchResult: PHFetchResult = PHFetchResult() { didSet { dataSource.fetchResult = fetchResult } } - private let dataSource: AssetsCollectionViewDataSource - + + internal let dataSource: AssetsCollectionViewDataSource + private let selectionFeedback = UISelectionFeedbackGenerator() - + + init(store: AssetStore) { self.store = store dataSource = AssetsCollectionViewDataSource(fetchResult: fetchResult, store: store) super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + deinit { PHPhotoLibrary.shared().unregisterChangeObserver(self) } - + override func viewDidLoad() { super.viewDidLoad() - + PHPhotoLibrary.shared().register(self) - + view = collectionView - + // Set an empty title to get < back button title = " " - + collectionView.allowsMultipleSelection = true collectionView.bounces = true collectionView.alwaysBounceVertical = true collectionView.backgroundColor = settings.theme.backgroundColor collectionView.delegate = self + dataSource.headerViewDelegate = self collectionView.dataSource = dataSource collectionView.prefetchDataSource = dataSource AssetsCollectionViewDataSource.registerCellIdentifiersForCollectionView(collectionView) - + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(AssetsViewController.collectionViewLongPressed(_:))) longPressRecognizer.minimumPressDuration = 0.5 collectionView.addGestureRecognizer(longPressRecognizer) - + + if settings.permission.enabled { + if #available(iOS 14, *) { + dataSource.setStatus(PHPhotoLibrary.authorizationStatus(for: .readWrite)) + } else { + dataSource.setStatus(PHPhotoLibrary.authorizationStatus()) + } + } + + syncSelections(store.assets) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) updateCollectionViewLayout(for: traitCollection) } - + func showAssets(in album: PHAssetCollection) { fetchResult = PHAsset.fetchAssets(in: album, options: settings.fetch.assets.options) collectionView.reloadData() @@ -98,10 +112,10 @@ class AssetsViewController: UIViewController { syncSelections(selections) collectionView.setContentOffset(.zero, animated: false) } - + private func syncSelections(_ assets: [PHAsset]) { collectionView.allowsMultipleSelection = true - + // Unselect all for indexPath in collectionView.indexPathsForSelectedItems ?? [] { collectionView.deselectItem(at: indexPath, animated: false) @@ -116,13 +130,13 @@ class AssetsViewController: UIViewController { updateSelectionIndexForCell(at: indexPath) } } - + func unselect(asset: PHAsset) { let index = fetchResult.index(of: asset) guard index != NSNotFound else { return } let indexPath = IndexPath(item: index, section: 0) collectionView.deselectItem(at:indexPath, animated: false) - + for indexPath in collectionView.indexPathsForSelectedItems ?? [] { updateSelectionIndexForCell(at: indexPath) } @@ -132,37 +146,37 @@ class AssetsViewController: UIViewController { super.traitCollectionDidChange(previousTraitCollection) updateCollectionViewLayout(for: traitCollection) } - + @objc func collectionViewLongPressed(_ sender: UILongPressGestureRecognizer) { guard settings.preview.enabled else { return } guard sender.state == .began else { return } - + selectionFeedback.selectionChanged() - + // Calculate which index path long press came from let location = sender.location(in: collectionView) guard let indexPath = collectionView.indexPathForItem(at: location) else { return } guard let cell = collectionView.cellForItem(at: indexPath) as? AssetCollectionViewCell else { return } let asset = fetchResult.object(at: indexPath.row) - + delegate?.assetsViewController(self, didLongPressCell: cell, displayingAsset: asset) } - + private func updateCollectionViewLayout(for traitCollection: UITraitCollection) { guard let collectionViewFlowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return } - + let itemSpacing = settings.list.spacing let itemsPerRow = settings.list.cellsPerRow(traitCollection.verticalSizeClass, traitCollection.horizontalSizeClass) let itemWidth = (collectionView.bounds.width - CGFloat(itemsPerRow - 1) * itemSpacing) / CGFloat(itemsPerRow) let itemSize = CGSize(width: itemWidth, height: itemWidth) - + collectionViewFlowLayout.minimumLineSpacing = itemSpacing collectionViewFlowLayout.minimumInteritemSpacing = itemSpacing collectionViewFlowLayout.itemSize = itemSize - + dataSource.imageSize = itemSize.resize(by: UIScreen.main.scale) } - + private func updateSelectionIndexForCell(at indexPath: IndexPath) { guard settings.theme.selectionStyle == .numbered else { return } guard let cell = collectionView.cellForItem(at: indexPath) as? AssetCollectionViewCell else { return } @@ -171,17 +185,39 @@ class AssetsViewController: UIViewController { } } -extension AssetsViewController: UICollectionViewDelegate { +extension AssetsViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + + guard let status = dataSource.getStatus() else { + return CGSize(width: UIScreen.main.bounds.width, height: 0) + } + + + if status == .denied { + return CGSize(width: UIScreen.main.bounds.width, height: 80) + } + + if #available(iOS 14, *) { + if status == .limited { + return CGSize(width: UIScreen.main.bounds.width, height: 80) + } + } + + return CGSize(width: UIScreen.main.bounds.width, height: 0) + + } + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { selectionFeedback.selectionChanged() - + let asset = fetchResult.object(at: indexPath.row) store.append(asset) delegate?.assetsViewController(self, didSelectAsset: asset) - + updateSelectionIndexForCell(at: indexPath) } - + func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { selectionFeedback.selectionChanged() @@ -193,13 +229,14 @@ extension AssetsViewController: UICollectionViewDelegate { updateSelectionIndexForCell(at: indexPath) } } - + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { guard store.count < settings.selection.max || settings.selection.unselectOnReachingMax else { return false } selectionFeedback.prepare() - + return true } + } extension AssetsViewController: PHPhotoLibraryChangeObserver { @@ -210,7 +247,7 @@ extension AssetsViewController: PHPhotoLibraryChangeObserver { if changes.hasIncrementalChanges { self.collectionView.performBatchUpdates({ self.fetchResult = changes.fetchResultAfterChanges - + // For indexes to make sense, updates must be in this order: // delete, insert, move if let removed = changes.removedIndexes, removed.count > 0 { @@ -231,7 +268,7 @@ extension AssetsViewController: PHPhotoLibraryChangeObserver { to: IndexPath(item: toIndex, section: 0)) } }) - + // "Use these indices to reconfigure the corresponding cells after performBatchUpdates" // https://developer.apple.com/documentation/photokit/phobjectchangedetails if let changed = changes.changedIndexes, changed.count > 0 { @@ -241,9 +278,27 @@ extension AssetsViewController: PHPhotoLibraryChangeObserver { self.fetchResult = changes.fetchResultAfterChanges self.collectionView.reloadData() } - + // No matter if we have incremental changes or not, sync the selections self.syncSelections(self.store.assets) } } } + +extension AssetsViewController: AutorizationStatusHeaderViewDelegate { + func didTapManageButton(for status: PHAuthorizationStatus) { + switch status { + case .restricted, .notDetermined: + showAlertForRestricedOrNotDeterminedAccess() + break + case .limited: + showAlerForLimitedAccess() + break + // don't do anything if user as authorized access to photos + // not handling .denied case as the controller will not be shown if access is denied + case .authorized, .denied: break + @unknown default: break + } + } +} + diff --git a/Sources/Scene/Assets/AutorizationStatusHeaderView.swift b/Sources/Scene/Assets/AutorizationStatusHeaderView.swift new file mode 100644 index 00000000..40405530 --- /dev/null +++ b/Sources/Scene/Assets/AutorizationStatusHeaderView.swift @@ -0,0 +1,90 @@ +// The MIT License (MIT) +// +// Copyright (c) 2021 Mithilesh Parmar +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import UIKit +import Photos + +protocol AutorizationStatusHeaderViewDelegate: AnyObject { + func didTapManageButton(for status: PHAuthorizationStatus) +} + +class AutorizationStatusHeaderView : UICollectionReusableView { + static let id = "PHAutorizationHeaderView" + + weak var delegate: AutorizationStatusHeaderViewDelegate? + + private(set) lazy var titleLabel: UILabel = { + let lbl = UILabel() + lbl.translatesAutoresizingMaskIntoConstraints = false + lbl.numberOfLines = 3 + return lbl + }() + + private(set) lazy var manageButton: UIButton = { + let btn = UIButton() + btn.translatesAutoresizingMaskIntoConstraints = false + return btn + }() + + var authorizationStatus: PHAuthorizationStatus? + + override init(frame: CGRect) { + super.init(frame: frame) + addViews() + constraintLayout() + addTargets() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addViews(){ + addSubview(titleLabel) + addSubview(manageButton) + } + + private func constraintLayout(){ + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: self.topAnchor), + titleLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor), + titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: self.manageButton.leadingAnchor, constant: -8), + + manageButton.topAnchor.constraint(equalTo: self.topAnchor), + manageButton.bottomAnchor.constraint(equalTo: self.bottomAnchor), + manageButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -16), + manageButton.widthAnchor.constraint(greaterThanOrEqualToConstant: manageButton.runtimeSize().width) + + ]) + } + + private func addTargets(){ + manageButton.addTarget(self, action: #selector(didTapManage(_:)), for: .touchUpInside) + } + + @objc func didTapManage(_ sender: UIButton){ + guard let status = authorizationStatus else { return } + delegate?.didTapManageButton(for: status) + } + +}