Last active
March 18, 2024 09:09
-
-
Save SergLam/8062b4aa8fca85be06e6d4c972010dce to your computer and use it in GitHub Desktop.
Photo selection service class for iOS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
typealias Localizable = R.string.localizable | |
typealias TypeClosure<T> = (T) -> Void | |
typealias VoidClosure = () -> Void | |
typealias VoidResult = Swift.Result<Void, Error> | |
typealias VoidResultClosure = (Swift.Result<Void, Error>) -> Void | |
typealias ImagePickerConfiguration = (source: UIImagePickerController.SourceType, | |
isLimited: Bool, | |
handler: PhotoSelectionController) | |
typealias DataUpdateInfo = [String: [String: Any]] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import AVFoundation | |
import FLAnimatedImage | |
import MobileCoreServices | |
import Photos | |
import PhotosUI | |
import UIKit | |
protocol PhotoSelectionControllerDelegate: class { | |
func photoSelectionCanceled() | |
func didFailWithError(_ error: String) | |
func didFailToGetPermission(_ message: String) | |
func didUserSelectImage(_ image: SelectedImage) | |
func didUserSelectAnimatedImage(_ image: AnimatedImage) | |
func didPhotoSelectionCompleted() | |
} | |
typealias FileData = (data: Data, type: CacheFileType) | |
typealias SelectedImage = (image: UIImage, type: CacheFileType) | |
typealias AnimatedImage = (image: FLAnimatedImage, type: CacheFileType) | |
typealias ImageFetchClosure = (_ image: UIImage?) -> Void | |
typealias PhotoAuthStatus = (isAllowed: Bool, isLimited: Bool) | |
final class PhotoSelectionController: NSObject, PhotoSelectionControllerProtocol { | |
weak var delegate: PhotoSelectionControllerDelegate? | |
var phManager: PHImageManager = PHImageManager.default() | |
var library: PHPhotoLibrary = PHPhotoLibrary.shared() | |
// MARK: - Life cycle | |
deinit { | |
if #available(iOS 13, *) { | |
library.unregisterAvailabilityObserver(self) | |
} | |
library.unregisterChangeObserver(self) | |
} | |
override init() { | |
super.init() | |
if #available(iOS 13, *) { | |
library.register(self as PHPhotoLibraryAvailabilityObserver) | |
} | |
library.register(self as PHPhotoLibraryChangeObserver) | |
} | |
func checkCameraAccess(at vc: UIViewController, completion: @escaping TypeClosure<Bool>) { | |
guard AVCaptureDevice.default(for: .video) != nil else { | |
delegate?.didFailWithError(Localizable.errorCameraNotAvailable()) | |
completion(false) | |
return | |
} | |
switch AVCaptureDevice.authorizationStatus(for: .video) { | |
case .denied, .restricted: | |
delegate?.didFailToGetPermission(Localizable.accessErrorCamera()) | |
completion(false) | |
case .authorized: | |
completion(true) | |
case .notDetermined: | |
AVCaptureDevice.requestAccess(for: .video) { [weak self] success in | |
if !success { | |
self?.delegate?.didFailToGetPermission(Localizable.accessErrorCamera()) | |
} | |
completion(success) | |
} | |
@unknown default: | |
break | |
} | |
} | |
func checkPhotosAccess(at vc: UIViewController, completion: @escaping TypeClosure<PhotoAuthStatus>) { | |
let status: PHAuthorizationStatus | |
if #available(iOS 14.0, *) { | |
status = PHPhotoLibrary.authorizationStatus(for: .readWrite) | |
} else { | |
status = PHPhotoLibrary.authorizationStatus() | |
} | |
switch status { | |
case .authorized: | |
completion((isAllowed: true, isLimited: false)) | |
case .limited: | |
if #available(iOS 14.0, *) { | |
completion((isAllowed: true, isLimited: true)) | |
} else { | |
completion((isAllowed: true, isLimited: true)) | |
} | |
completion((isAllowed: true, isLimited: true)) | |
case .denied, .restricted: | |
delegate?.didFailToGetPermission(Localizable.accessErrorPhotos()) | |
completion((isAllowed: false, isLimited: false)) | |
case .notDetermined: | |
requestPhotoLibraryAuthorization(at: vc, completion: completion) | |
@unknown default: | |
break | |
} | |
} | |
func requestPhotoLibraryAuthorization(at vc: UIViewController, completion: @escaping TypeClosure<PhotoAuthStatus>) { | |
if #available(iOS 14.0, *) { | |
PHPhotoLibrary.requestAuthorization(for: .readWrite) { [weak self] status in | |
self?.handlePhotoLibraryAuthorizationStatus(at: vc, status: status, completion: completion) | |
} | |
} else { | |
PHPhotoLibrary.requestAuthorization { [weak self] status in | |
self?.handlePhotoLibraryAuthorizationStatus(at: vc, status: status, completion: completion) | |
} | |
} | |
} | |
private func handlePhotoLibraryAuthorizationStatus(at vc: UIViewController, | |
status: PHAuthorizationStatus, | |
completion: @escaping TypeClosure<PhotoAuthStatus>) { | |
switch status { | |
case .authorized: | |
completion((isAllowed: true, isLimited: false)) | |
case .limited: | |
if #available(iOS 14.0, *) { | |
completion((isAllowed: true, isLimited: true)) | |
} else { | |
completion((isAllowed: true, isLimited: true)) | |
} | |
case .denied, .restricted: | |
delegate?.didFailToGetPermission(Localizable.accessErrorPhotos()) | |
completion((isAllowed: false, isLimited: false)) | |
case .notDetermined: | |
break // won't happen but still | |
@unknown default: | |
break | |
} | |
} | |
} | |
// MARK: - PHPhotoLibraryChangeObserver | |
extension PhotoSelectionController: PHPhotoLibraryChangeObserver { | |
func photoLibraryDidChange(_ changeInstance: PHChange) { | |
// NOTE: - For cases while you present custom UI - trigger updates methods from here | |
} | |
} | |
// MARK: - PHPhotoLibraryAvailabilityObserver | |
extension PhotoSelectionController: PHPhotoLibraryAvailabilityObserver { | |
@available(iOS 13, *) | |
func photoLibraryDidBecomeUnavailable(_ photoLibrary: PHPhotoLibrary) { | |
// NOTE: - For cases while you present custom UI - trigger updates methods from here | |
} | |
} | |
// MARK: - Image picker | |
extension PhotoSelectionController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { | |
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { | |
picker.dismiss(animated: true, completion: nil) | |
delegate?.photoSelectionCanceled() | |
} | |
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { | |
// NOTE: image captured by device camera do not have image URL | |
guard let imageURL = info[UIImagePickerController.InfoKey.imageURL] as? URL else { | |
handleCapturedImage(picker, info: info, imageType: .jpeg) | |
return | |
} | |
guard let type = CacheFileType(rawValue: imageURL.pathExtension) else { | |
let message: String = "Unknown file type value" | |
LoggerService.logErrorWithTrace(message) | |
return | |
} | |
handleCapturedImage(picker, info: info, imageType: type) | |
} | |
private func handleCapturedImage(_ picker: UIImagePickerController, | |
info: [UIImagePickerController.InfoKey: Any], | |
imageType: CacheFileType) { | |
var selectedImage: UIImage? | |
let editedImage = info[.editedImage] as? UIImage | |
if editedImage?.jpegData(compressionQuality: 1.0) == nil { | |
// Image was NOT edited | |
guard let image = info[.originalImage] as? UIImage else { | |
let message: String = "Unable to get image" | |
LoggerService.logErrorWithTrace(message) | |
return | |
} | |
selectedImage = image | |
} else { | |
// Image was edited | |
guard let image = info[.editedImage] as? UIImage else { | |
let message: String = "Unable to get image" | |
LoggerService.logErrorWithTrace(message) | |
return | |
} | |
selectedImage = image | |
} | |
guard let image = selectedImage else { | |
let message: String = "Image wasn't provided" | |
LoggerService.logErrorWithTrace(message) | |
return | |
} | |
guard imageType == .gif else { | |
delegate?.didUserSelectImage(SelectedImage(image: image, type: imageType)) | |
picker.dismiss(animated: true) { [weak self] in | |
self?.delegate?.didPhotoSelectionCompleted() | |
} | |
return | |
} | |
guard let imgUrl = info[UIImagePickerController.InfoKey.imageURL] as? URL else { | |
let message: String = "Unable to get image asset" | |
LoggerService.logErrorWithTrace(message) | |
return | |
} | |
do { | |
let data = try Data(contentsOf: imgUrl) | |
guard let animImage = FLAnimatedImage(animatedGIFData: data) else { | |
let message: String = "Unable to create animated image" | |
LoggerService.logErrorWithTrace(message) | |
return | |
} | |
delegate?.didUserSelectAnimatedImage(AnimatedImage(image: animImage, type: imageType)) | |
picker.dismiss(animated: true) { [weak self] in | |
self?.delegate?.didPhotoSelectionCompleted() | |
} | |
} catch { | |
let message: String = error.localizedDescription | |
LoggerService.logErrorWithTrace(message) | |
} | |
} | |
private func requestGIFData(_ asset: PHAsset, completion: @escaping ImageFetchClosure) { | |
let options = PHImageRequestOptions() | |
options.isNetworkAccessAllowed = false | |
options.isSynchronous = true | |
options.resizeMode = .exact | |
options.deliveryMode = .highQualityFormat | |
options.version = .original | |
phManager.requestImageData(for: asset, options: options, resultHandler: { imageData, UTI, _, _ in | |
guard let uti = UTI else { | |
let message: String = "Unable to get image UTI" | |
LoggerService.logErrorWithTrace(message) | |
return | |
} | |
let isGif = UTTypeConformsTo(uti as CFString, kUTTypeGIF) | |
guard let data = imageData, isGif else{ | |
let message: String = "Unable to get GIF image" | |
LoggerService.logErrorWithTrace(message) | |
return | |
} | |
let image = UIImage(data: data) | |
completion(image) | |
}) | |
} | |
} | |
// MARK: - PHPickerViewControllerDelegate | |
extension PhotoSelectionController: PHPickerViewControllerDelegate { | |
// NOTE: - Single photo selection handling | |
@available(iOS 14, *) | |
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { | |
picker.dismiss(animated: true, completion: { [weak self] in | |
self?.executeOnMain { | |
let data: DataUpdateInfo = ModuleTypeDeinitMessage(type: PHPickerViewController.self).toDataUpdate() | |
AppDelegate.shared.notifyObservers(about: .moduleTypeDeinit, with: data) | |
} | |
}) | |
// Empty results - user canceled photo selection | |
guard results.isEmpty else { | |
guard let itemProvider = results.first?.itemProvider else { | |
let error: String = Localizable.imageErrorUnableToGetFile() | |
self.failedToLoadImage(with: error) | |
return | |
} | |
guard itemProvider.canLoadObject(ofClass: UIImage.self) else { | |
let error: String = Localizable.imageErrorUnableToGetFile() | |
self.failedToLoadImage(with: error) | |
return | |
} | |
let _ = itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in | |
guard let err = error else { | |
guard let img = image as? UIImage else { | |
let error: String = Localizable.imageErrorUnableToGetFile() | |
self?.failedToLoadImage(with: error) | |
return | |
} | |
let selectedImage: SelectedImage = (image: img, type: .png) | |
self?.executeOnMain { [weak self] in | |
self?.delegate?.didUserSelectImage(selectedImage) | |
self?.delegate?.didPhotoSelectionCompleted() | |
} | |
return | |
} | |
self?.failedToLoadImage(with: err.localizedDescription) | |
} | |
return | |
} | |
delegate?.photoSelectionCanceled() | |
} | |
private func failedToLoadImage(with error: String) { | |
executeOnMain { [weak self] in | |
self?.delegate?.didFailWithError(error) | |
self?.delegate?.photoSelectionCanceled() | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Photos | |
import PhotosUI | |
import UIKit | |
protocol PhotoSelectionControllerProtocol: class { | |
var delegate: PhotoSelectionControllerDelegate? { get set } | |
var phManager: PHImageManager { get set } | |
var library: PHPhotoLibrary { get set } | |
func checkCameraAccess(at vc: UIViewController, completion: @escaping TypeClosure<Bool>) | |
func checkPhotosAccess(at vc: UIViewController, completion: @escaping TypeClosure<PhotoAuthStatus>) | |
func requestPhotoLibraryAuthorization(at vc: UIViewController, completion: @escaping TypeClosure<PhotoAuthStatus>) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
final class ViewController: UIViewController { | |
let photoSelectionHandler = PhotoSelectionController() | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
photoSelectionHandler.delegate = self | |
} | |
} | |
// MARK: - PhotoSelectionControllerDelegate | |
extension ViewController: PhotoSelectionControllerDelegate { | |
func photoSelectionCanceled() { | |
// NOTE: - User canceled photo selection process | |
} | |
func didFailWithError(_ error: String) { | |
AlertPresenter.showErrorAlert(at: self, errorMessage: error) | |
} | |
func didFailToGetPermission(_ message: String) { | |
AlertPresenter.showPermissionDeniedAlert(at: self, errorMessage: message, cancelClosure: {}) | |
} | |
func didUserSelectImage(_ image: SelectedImage) { | |
} | |
func didUserSelectAnimatedImage(_ image: AnimatedImage) { | |
} | |
func didPhotoSelectionCompleted() { | |
// NOTE: - Photo selection process finished, pisker dismissed. | |
// Ready to go and update data on server side / local database. | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment