Skip to content

Instantly share code, notes, and snippets.

@SergLam
Last active March 18, 2024 09:09
Show Gist options
  • Save SergLam/8062b4aa8fca85be06e6d4c972010dce to your computer and use it in GitHub Desktop.
Save SergLam/8062b4aa8fca85be06e6d4c972010dce to your computer and use it in GitHub Desktop.
Photo selection service class for iOS
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]]
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()
}
}
}
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>)
}
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