From cdec977dee1ce21f1f17ff1bdadcc0f4f69ca8e9 Mon Sep 17 00:00:00 2001 From: Borut Tomazin Date: Fri, 14 Jun 2024 08:44:52 +0200 Subject: [PATCH 1/6] ImageTools. --- Sources/Utilities/ImageTools/ImageTools.swift | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 Sources/Utilities/ImageTools/ImageTools.swift diff --git a/Sources/Utilities/ImageTools/ImageTools.swift b/Sources/Utilities/ImageTools/ImageTools.swift new file mode 100644 index 00000000..39ed2e8b --- /dev/null +++ b/Sources/Utilities/ImageTools/ImageTools.swift @@ -0,0 +1,356 @@ +// +// ImageTools.swift +// PovioKit +// +// Created by Borut Tomazin on 13/06/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +import UIKit + +/// A utility class for performing various image-related operations. +public final class ImageTools: NSObject { +#if canImport(Kingfisher) + private var prefetcher: ImagePrefetcher? +#endif + + override public init() { + super.init() + } +} + +public extension ImageTools { + enum ImageError: LocalizedError { + case invalidPercentage + case compression + case invalidSize + } + + enum ImageFormat { + case jpeg(compressionRatio: CGFloat) + case png + } + + /// Saves the given image to the photo library asynchronously. + /// + /// This function saves the provided UIImage to the user's photo library. It performs + /// the operation asynchronously and throws an error if the save operation fails. + /// + /// - Parameters: + /// - image: The UIImage to be saved to the photo library. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// try await saveImageToPhotoLibrary(image) + /// print("Image saved successfully.") + /// } catch { + /// print("Failed to save image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: An error if the save operation fails. + func saveToPhotoLibrary(image: UIImage) async throws { + try await withCheckedThrowingContinuation { continuation in + let continuationWrapper = ContinuationWrapper(continuation: continuation) + UIImageWriteToSavedPhotosAlbum( + image, + self, + #selector(saveCompleted), + Unmanaged.passRetained(continuationWrapper).toOpaque() + ) + } + } + + /// Downsizes the given image to the specified target size asynchronously. + /// - Parameters: + /// - image: The original UIImage to be downsized. + /// - targetSize: The desired size to which the image should be downsized. + /// - Returns: An optional UIImage that is the result of the downsizing operation. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let targetSize = CGSize(width: 100, height: 100) + /// let resizedImage = await downsize(image, to: targetSize) + /// imageView.image = resizedImage + /// } catch { + /// print("Failed to downsize image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + func downsize(image: UIImage, toTargetSize targetSize: CGSize) async throws -> UIImage { + guard !targetSize.width.isZero, !targetSize.height.isZero else { + throw ImageError.invalidSize + } + + return await Task.detached(priority: .high) { + let originalSize = image.size + let widthRatio = targetSize.width / originalSize.width + let heightRatio = targetSize.height / originalSize.height + let scaleFactor = min(widthRatio, heightRatio) + let newSize = CGSize( + width: originalSize.width * scaleFactor, + height: originalSize.height * scaleFactor + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let newImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + + return newImage + }.value + } + + /// Downsizes the given image by the specified percentage asynchronously. + /// - Parameters: + /// - image: The original UIImage to be downsized. + /// - percentage: The percentage by which the image should be downsized. Must be greater than 0 and less than or equal to 100. + /// - Returns: A UIImage that is the result of the downsizing operation. + /// + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let percentage: CGFloat = 50.0 + /// let resizedImage = try await downsize(image, by: percentage) + /// imageView.image = resizedImage + /// } catch { + /// print("Failed to downsize image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: `ImageError.invalidPercentage` if the percentage is not within the valid range. + func downsize(image: UIImage, byPercentage percentage: CGFloat) async throws -> UIImage { + guard percentage > 0 && percentage <= 100 else { + throw ImageError.invalidPercentage + } + + return await Task.detached(priority: .high) { + let scaleFactor = percentage / 100.0 + let newSize = CGSize( + width: image.size.width * scaleFactor, + height: image.size.height * scaleFactor + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let newImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + + return newImage + }.value + } + + /// Compresses the given image to the specified format. + /// + /// This function compresses the provided UIImage to the specified format (JPEG or PNG). + /// It returns the compressed image data. + /// If the compression operation fails, the function throws an error. + /// + /// - Parameters: + /// - image: The UIImage to be compressed. + /// - format: The desired image format (JPEG or PNG). + /// - Returns: The compressed image data as `Data`. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let compressedData = try await compress(image, format: .jpeg(compressionRatio: 0.8)) + /// } catch { + /// print("Failed to compress image: \(error)") + /// } + /// ``` + /// - Throws: `ImageError.compression` if the compression operation fails. + func compress(image: UIImage, withFormat format: ImageFormat) async throws -> Data { + try await Task.detached(priority: .high) { + let compressedImage: Data? + switch format { + case .jpeg(let compressionRatio): + compressedImage = image.jpegData(compressionQuality: compressionRatio) + case .png: + compressedImage = image.pngData() + } + + guard let compressedImage else { throw ImageError.compression } + + print("Image compressed to:", Double(compressedImage.count) / 1024.0 / 1024.0, "MB") + return compressedImage + }.value + } + + /// Compresses the given image to given `maxKbSize`. + /// + /// This function compresses the provided UIImage to the specified size in KB. + /// It returns the compressed image data. + /// If the compression operation fails, the function throws an error. + /// + /// - Parameters: + /// - image: The UIImage to be compressed. + /// - maxSizeInKb: The desired max size in KB. + /// - Returns: The compressed image data as `Data`. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let compressedData = try await compress(image, toMaxKbSize: 500) + /// } catch { + /// print("Failed to compress image: \(error)") + /// } + /// ``` + /// - Throws: `ImageError.compression` if the compression operation fails. + func compress(image: UIImage, toMaxKbSize maxKbSize: CGFloat) async throws -> Data { + guard maxKbSize > 0 else { throw ImageError.invalidSize } + + return try await Task.detached(priority: .high) { + let maxBytes = Int(maxKbSize * 1024) + var compression: CGFloat = 1.0 + var compressedData: Data? + + // try to compress the image by reducing the quality until reached desired `maxSizeInKb` + while compression > 0.0 { + let data = try await self.compress(image: image, withFormat: .jpeg(compressionRatio: compression)) + if data.count <= maxBytes { + compressedData = data + break + } else { + compression -= 0.1 + } + } + + guard let compressedData else { throw ImageError.compression } + return compressedData + }.value + } +} + +#if canImport(Kingfisher) +import Kingfisher + +// MARK: - Operations based on Kingfisher lib +public extension ImageTools { + struct PrefetchResult { + let skipped: Int + let failed: Int + let completed: Int + } + + /// Clears the image cache. + /// + /// This function clears the image cache using the specified ImageCache instance. + /// If no cache instance is provided, it defaults to using the shared cache of the KingfisherManager. + /// + /// - Parameters: + /// - cache: The ImageCache instance to be cleared. Defaults to `KingfisherManager.shared.cache`. + /// + /// - Example: + /// ```swift + /// // Clear the default shared cache + /// clearCache() + /// + /// // Clear a specific cache instance + /// let customCache = ImageCache(name: "customCache") + /// clearCache(customCache) + /// ``` + func clear(cache: ImageCache = KingfisherManager.shared.cache) { + cache.clearCache() + } + + /// Downloads an image from the given URL asynchronously. + /// + /// This function uses the Kingfisher library to download an image from the specified URL. + /// It performs the download operation asynchronously and returns the downloaded UIImage. + /// If the download operation fails, the function throws an error. + /// + /// - Parameters: + /// - url: The URL from which to download the image. + /// - Returns: The downloaded UIImage. + /// + /// - Example: + /// ```swift + /// do { + /// let url = URL(string: "https://example.com/image.jpg")! + /// let downloadedImage = try await download(from: url) + /// imageView.image = downloadedImage + /// } catch { + /// print("Failed to download image: \(error)") + /// } + /// ``` + /// + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: An error if the download operation fails. + func download(url: URL) async throws -> UIImage { + try await withCheckedThrowingContinuation { continuation in + KingfisherManager.shared.retrieveImage(with: url, options: nil, progressBlock: nil, downloadTaskUpdated: nil) { + switch $0 { + case .success(let result): + continuation.resume(returning: result.image) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Prefetches images from the given URLs asynchronously. + /// + /// This function uses the Kingfisher library to prefetch images from the specified URLs. + /// It performs the prefetch operation asynchronously and returns a `PrefetchResult` containing + /// the counts of skipped, failed, and completed prefetch operations. + /// + /// It is usefull when we need to have images ready before we present the UI. + /// + /// - Parameters: + /// - urls: An array of URLs from which to prefetch images. + /// - Returns: A `PrefetchResult` containing the counts of skipped, failed, and completed prefetch operations. + /// - Example: + /// ```swift + /// let urls = [ + /// URL(string: "https://example.com/image1.jpg")!, + /// URL(string: "https://example.com/image2.jpg")! + /// ] + /// let result = await prefetch(urls: urls) + /// print("Skipped: \(result.skipped), Failed: \(result.failed), Completed: \(result.completed)") + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + @discardableResult + func prefetch(urls: [URL]) async -> PrefetchResult { + await withCheckedContinuation { continuation in + prefetcher = ImagePrefetcher(urls: urls, options: nil) { skipped, failed, completed in + let result = PrefetchResult( + skipped: skipped.count, + failed: failed.count, + completed: completed.count + ) + continuation.resume(with: .success(result)) + } + prefetcher?.start() + } + } +} +#endif + +// MARK: - Private Methods +private extension ImageTools { + @objc func saveCompleted( + _ image: UIImage, + didFinishSavingWithError error: Swift.Error?, + contextInfo: UnsafeRawPointer + ) { + let continuationWrapper = Unmanaged.fromOpaque(contextInfo).takeRetainedValue() + if let error { + continuationWrapper.continuation.resume(throwing: error) + } else { + continuationWrapper.continuation.resume(returning: ()) + } + } + + class ContinuationWrapper { + let continuation: CheckedContinuation + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + } +} From 4d43886a7e5c1c8b3f6cf096f2993c14f575dd85 Mon Sep 17 00:00:00 2001 From: Borut Tomazin Date: Thu, 1 Aug 2024 16:40:36 +0200 Subject: [PATCH 2/6] Unit tests. --- Package.swift | 3 + Sources/Utilities/ImageTools/ImageTools.swift | 12 +- Tests/Tests/Resources/PovioKit.png | Bin 0 -> 23882 bytes .../ImageTools/ImageToolsTests.swift | 114 ++++++++++++++++++ 4 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 Tests/Tests/Resources/PovioKit.png create mode 100644 Tests/Tests/Utilities/ImageTools/ImageToolsTests.swift diff --git a/Package.swift b/Package.swift index b758da9c..32341806 100644 --- a/Package.swift +++ b/Package.swift @@ -105,6 +105,9 @@ let package = Package( "PovioKitSwiftUI", "PovioKitUtilities", "PovioKitAsync", + ], + resources: [ + .process("Resources/") ] ), ], diff --git a/Sources/Utilities/ImageTools/ImageTools.swift b/Sources/Utilities/ImageTools/ImageTools.swift index 39ed2e8b..18272d05 100644 --- a/Sources/Utilities/ImageTools/ImageTools.swift +++ b/Sources/Utilities/ImageTools/ImageTools.swift @@ -7,6 +7,7 @@ // import UIKit +import PovioKitCore /// A utility class for performing various image-related operations. public final class ImageTools: NSObject { @@ -62,7 +63,7 @@ public extension ImageTools { } } - /// Downsizes the given image to the specified target size asynchronously. + /// Downsizes the given image to the specified target size, asynchronously, respecting aspect ratio. /// - Parameters: /// - image: The original UIImage to be downsized. /// - targetSize: The desired size to which the image should be downsized. @@ -90,8 +91,8 @@ public extension ImageTools { let heightRatio = targetSize.height / originalSize.height let scaleFactor = min(widthRatio, heightRatio) let newSize = CGSize( - width: originalSize.width * scaleFactor, - height: originalSize.height * scaleFactor + width: floor(originalSize.width * scaleFactor), + height: floor(originalSize.height * scaleFactor) ) let renderer = UIGraphicsImageRenderer(size: newSize) @@ -175,7 +176,7 @@ public extension ImageTools { guard let compressedImage else { throw ImageError.compression } - print("Image compressed to:", Double(compressedImage.count) / 1024.0 / 1024.0, "MB") + Logger.debug("Image compressed to \(Double(compressedImage.count) / 1024.0) KB.") return compressedImage }.value } @@ -205,6 +206,7 @@ public extension ImageTools { return try await Task.detached(priority: .high) { let maxBytes = Int(maxKbSize * 1024) + let compressionStep: CGFloat = 0.05 var compression: CGFloat = 1.0 var compressedData: Data? @@ -215,7 +217,7 @@ public extension ImageTools { compressedData = data break } else { - compression -= 0.1 + compression -= compressionStep } } diff --git a/Tests/Tests/Resources/PovioKit.png b/Tests/Tests/Resources/PovioKit.png new file mode 100644 index 0000000000000000000000000000000000000000..45386ca6bba05ff939017b0de0b39af186f94c34 GIT binary patch literal 23882 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 literal 0 HcmV?d00001 diff --git a/Tests/Tests/Utilities/ImageTools/ImageToolsTests.swift b/Tests/Tests/Utilities/ImageTools/ImageToolsTests.swift new file mode 100644 index 00000000..613e103d --- /dev/null +++ b/Tests/Tests/Utilities/ImageTools/ImageToolsTests.swift @@ -0,0 +1,114 @@ +// +// ImageToolsTests.swift +// +// +// Created by Borut Tomazin on 01/08/2024. +// + +import XCTest +import PovioKitUtilities + +final class ImageToolsTests: XCTestCase { + private let imageTools = ImageTools() + private let image: UIImage? = UIImage(named: "PovioKit", in: .module, with: nil) + + func testDownsizeToTargetSize() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Downsizing...") + + Task { + do { + let downsizedImage = try await imageTools.downsize(image: image, toTargetSize: .init(size: 200)) + XCTAssertLessThanOrEqual(downsizedImage.size.width, 200, "The width of the downsized image should be 200") + XCTAssertLessThanOrEqual(downsizedImage.size.height, 200, "The height of the downsized image should be 200") + } catch { + XCTFail("The image failed to downsize.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testDownsizeByPercentage() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Downsizing...") + let originalSize = image.size + + Task { + do { + let downsizedImage = try await imageTools.downsize(image: image, byPercentage: 50) + XCTAssertEqual(downsizedImage.size.width, originalSize.width / 2, "The width of the downsized image should be 200") + XCTAssertEqual(downsizedImage.size.height, originalSize.height / 2, "The height of the downsized image should be 200") + } catch { + XCTFail("The image failed to downsize.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressWithPngFormat() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + Task { + do { + let downsizedImage = try await imageTools.compress(image: image, withFormat: .png) + // verify the image format by checking the PNG signature + let pngSignature: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + let downsizedImageSignature = [UInt8](downsizedImage.prefix(pngSignature.count)) + + XCTAssertEqual(downsizedImageSignature, pngSignature, "The image was not compressed as PNG.") + } catch { + XCTFail("The image failed to compress.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressWithJpgFormat() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + Task { + do { + let downsizedImage = try await imageTools.compress(image: image, withFormat: .jpeg(compressionRatio: 0.5)) + // verify the image format by checking the JPEG signature + let jpegSignature: [UInt8] = [0xFF, 0xD8] + let downsizedImageSignature = [UInt8](downsizedImage.prefix(jpegSignature.count)) + + XCTAssertEqual(downsizedImageSignature, jpegSignature, "The image was not compressed as JPEG.") + } catch { + XCTFail("The image failed to compress.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressToMaxSize() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + let targetKB = 10.0 + + Task { + do { + let downsizedImage = try await imageTools.compress(image: image, toMaxKbSize: targetKB) + // Check if the size is 1KB or less + let imageSizeInKB = Double(downsizedImage.count) / 1024.0 + XCTAssertLessThanOrEqual(imageSizeInKB, targetKB, "The compressed image size is greater than 1KB.") + } catch { + XCTFail("The image failed to compress. \(error)") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } +} From b70a1cf599e0f5e8767966416b6439873aafc8f3 Mon Sep 17 00:00:00 2001 From: Borut Tomazin Date: Fri, 2 Aug 2024 13:00:44 +0200 Subject: [PATCH 3/6] Remove detached task. --- Sources/Utilities/ImageTools/ImageTools.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Utilities/ImageTools/ImageTools.swift b/Sources/Utilities/ImageTools/ImageTools.swift index 18272d05..49519c53 100644 --- a/Sources/Utilities/ImageTools/ImageTools.swift +++ b/Sources/Utilities/ImageTools/ImageTools.swift @@ -85,7 +85,7 @@ public extension ImageTools { throw ImageError.invalidSize } - return await Task.detached(priority: .high) { + return await Task(priority: .high) { let originalSize = image.size let widthRatio = targetSize.width / originalSize.width let heightRatio = targetSize.height / originalSize.height @@ -128,7 +128,7 @@ public extension ImageTools { throw ImageError.invalidPercentage } - return await Task.detached(priority: .high) { + return await Task(priority: .high) { let scaleFactor = percentage / 100.0 let newSize = CGSize( width: image.size.width * scaleFactor, @@ -165,7 +165,7 @@ public extension ImageTools { /// ``` /// - Throws: `ImageError.compression` if the compression operation fails. func compress(image: UIImage, withFormat format: ImageFormat) async throws -> Data { - try await Task.detached(priority: .high) { + try await Task(priority: .high) { let compressedImage: Data? switch format { case .jpeg(let compressionRatio): @@ -204,7 +204,7 @@ public extension ImageTools { func compress(image: UIImage, toMaxKbSize maxKbSize: CGFloat) async throws -> Data { guard maxKbSize > 0 else { throw ImageError.invalidSize } - return try await Task.detached(priority: .high) { + return try await Task(priority: .high) { let maxBytes = Int(maxKbSize * 1024) let compressionStep: CGFloat = 0.05 var compression: CGFloat = 1.0 From 234b6aabc1f586217e3e191ef2fc66789e7966c1 Mon Sep 17 00:00:00 2001 From: Borut Tomazin Date: Thu, 26 Sep 2024 11:13:25 +0200 Subject: [PATCH 4/6] Replace ImageTools with extension methods. --- .../DecodableDictionary+PovioKit.swift | 2 +- .../Extensions/UIKit/UIImage+Kingfisher.swift | 112 ++++++ .../Extensions/UIKit/UIImage+PovioKit.swift | 255 ++++++++++++- Sources/Utilities/ImageTools/ImageTools.swift | 358 ------------------ .../ImageTools/ImageToolsTests.swift | 114 ------ 5 files changed, 364 insertions(+), 477 deletions(-) create mode 100644 Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift delete mode 100644 Sources/Utilities/ImageTools/ImageTools.swift delete mode 100644 Tests/Tests/Utilities/ImageTools/ImageToolsTests.swift diff --git a/Sources/Core/Extensions/Foundation/DecodableDictionary+PovioKit.swift b/Sources/Core/Extensions/Foundation/DecodableDictionary+PovioKit.swift index ea68058c..9292c086 100644 --- a/Sources/Core/Extensions/Foundation/DecodableDictionary+PovioKit.swift +++ b/Sources/Core/Extensions/Foundation/DecodableDictionary+PovioKit.swift @@ -89,7 +89,7 @@ public extension UnkeyedDecodingContainer { // MARK: - Private Model private struct AnyCodingKey: CodingKey { let stringValue: String - private (set) var intValue: Int? + private(set) var intValue: Int? init?(stringValue: String) { self.stringValue = stringValue } init?(intValue: Int) { diff --git a/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift b/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift new file mode 100644 index 00000000..327a504d --- /dev/null +++ b/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift @@ -0,0 +1,112 @@ +// +// File.swift +// PovioKit +// +// Created by Borut Tomazin on 26. 9. 24. +// + +#if canImport(Kingfisher) +import UIKit +import Kingfisher + +extension UIKit { + struct PrefetchResult { + let skipped: Int + let failed: Int + let completed: Int + } + + /// Downloads an image from the given URL asynchronously. + /// + /// This function uses the Kingfisher library to download an image from the specified URL. + /// It performs the download operation asynchronously and returns the downloaded UIImage. + /// If the download operation fails, the function throws an error. + /// + /// - Parameters: + /// - url: The URL from which to download the image. + /// - Returns: The downloaded UIImage. + /// + /// - Example: + /// ```swift + /// do { + /// let url = URL(string: "https://example.com/image.jpg")! + /// let downloadedImage = try await download(from: url) + /// imageView.image = downloadedImage + /// } catch { + /// print("Failed to download image: \(error)") + /// } + /// ``` + /// + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: An error if the download operation fails. + func download(from url: URL) async throws -> UIImage { + try await withCheckedThrowingContinuation { continuation in + KingfisherManager.shared.retrieveImage(with: url, options: nil, progressBlock: nil, downloadTaskUpdated: nil) { + switch $0 { + case .success(let result): + continuation.resume(returning: result.image) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Prefetches images from the given URLs asynchronously. + /// + /// This function uses the Kingfisher library to prefetch images from the specified URLs. + /// It performs the prefetch operation asynchronously and returns a `PrefetchResult` containing + /// the counts of skipped, failed, and completed prefetch operations. + /// + /// It is usefull when we need to have images ready before we present the UI. + /// + /// - Parameters: + /// - urls: An array of URLs from which to prefetch images. + /// - Returns: A `PrefetchResult` containing the counts of skipped, failed, and completed prefetch operations. + /// - Example: + /// ```swift + /// let urls = [ + /// URL(string: "https://example.com/image1.jpg")!, + /// URL(string: "https://example.com/image2.jpg")! + /// ] + /// let result = await prefetch(urls: urls) + /// print("Skipped: \(result.skipped), Failed: \(result.failed), Completed: \(result.completed)") + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + @discardableResult + func prefetch(from urls: [URL]) async -> PrefetchResult { + await withCheckedContinuation { continuation in + let prefetcher = ImagePrefetcher(urls: urls, options: nil) { skipped, failed, completed in + let result = PrefetchResult( + skipped: skipped.count, + failed: failed.count, + completed: completed.count + ) + continuation.resume(with: .success(result)) + } + prefetcher.start() + } + } + + /// Clears the image cache. + /// + /// This function clears the image cache using the specified ImageCache instance. + /// If no cache instance is provided, it defaults to using the shared cache of the KingfisherManager. + /// + /// - Parameters: + /// - cache: The ImageCache instance to be cleared. Defaults to `KingfisherManager.shared.cache`. + /// + /// - Example: + /// ```swift + /// // Clear the default shared cache + /// clearCache() + /// + /// // Clear a specific cache instance + /// let customCache = ImageCache(name: "customCache") + /// clearCache(customCache) + /// ``` + func clear(cache: ImageCache = KingfisherManager.shared.cache) { + cache.clearCache() + } +} +#endif diff --git a/Sources/Core/Extensions/UIKit/UIImage+PovioKit.swift b/Sources/Core/Extensions/UIKit/UIImage+PovioKit.swift index 6cbeeb90..f088ab83 100644 --- a/Sources/Core/Extensions/UIKit/UIImage+PovioKit.swift +++ b/Sources/Core/Extensions/UIKit/UIImage+PovioKit.swift @@ -11,11 +11,21 @@ import UIKit public extension UIImage { /// Initializes a symbol image on iOS 13 or image from the given `bundle` for given `name` + @available(*, deprecated, message: "This method doesn't bring any good value, therefore it will be removed in future versions.") convenience init?(systemNameOr name: String, in bundle: Bundle? = Bundle.main, compatibleWith traitCollection: UITraitCollection? = nil) { self.init(systemName: name, compatibleWith: traitCollection) } - /// Tints image with given color + /// Tints image with the given color. + /// + /// This method creates a new image by applying a color overlay to the original image. + /// The color overlay is blended using the `.sourceIn` blend mode, which means the + /// resulting image will have the shape of the original image but filled with the specified color. + /// + /// - Parameter color: The `UIColor` to use as the tint color. + /// - Returns: A new `UIImage` instance that is tinted with the specified color. + /// + /// - Note: If the tinting operation fails, the original image is returned. func tinted(with color: UIColor) -> UIImage { UIGraphicsBeginImageContextWithOptions(size, false, 0) let context = UIGraphicsGetCurrentContext() @@ -32,7 +42,16 @@ public extension UIImage { return tintedImage ?? self } - /// Generates new *UIImage* tinted with given color and size + /// Creates new image tinted with the given color and size. + /// + /// This method generates a new image of the given size, completely filled with the given color. + /// + /// - Parameters: + /// - color: The `UIColor` to fill the image with. + /// - size: The `CGSize` that defines the dimensions of the new image. + /// - Returns: A new `UIImage` instance filled with the specified color and size. If the image creation fails, an empty `UIImage` is returned. + /// + /// - Note: The resulting image is not resizable and will have the exact dimensions specified by the `size` parameter. static func with(color: UIColor, size: CGSize) -> UIImage { UIGraphicsBeginImageContext(size) let path = UIBezierPath(rect: CGRect(origin: CGPoint.zero, size: size)) @@ -43,13 +62,22 @@ public extension UIImage { return image ?? UIImage() } - /// Returns existing image clipped to a circle + /// Returns existing image clipped to a circle. + /// Clips the image to a circle. + /// + /// This creates a new image by clipping the original image to a circular shape. + /// The circular clipping is achieved by applying a corner radius that is half the width of the image. + /// + /// - Returns: A new `UIImage` instance that is clipped to a circle. If the clipping operation fails, the original image is returned. + /// + /// - Note: The resulting image will have a circular shape inscribed within the original image's bounds. + /// If the original image is not square, the image will still be clipped to a circle, with the diameter + /// equal to the shorter side of the original image. var clipToCircle: UIImage { let layer = CALayer() layer.frame = .init(origin: .zero, size: size) layer.contents = cgImage layer.masksToBounds = true - layer.cornerRadius = size.width / 2 UIGraphicsBeginImageContext(size) @@ -59,6 +87,225 @@ public extension UIImage { UIGraphicsEndImageContext() return roundedImage ?? self } + + /// Saves the image to the photo library asynchronously. + /// + /// This function saves this UIImage to the user's photo library. It performs + /// the operation asynchronously and throws an error if the save operation fails. + /// + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// try await image.saveImageToPhotoLibrary() + /// print("Image saved successfully.") + /// } catch { + /// print("Failed to save image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: An error if the save operation fails. + func saveToPhotoLibrary() async throws { + try await withCheckedThrowingContinuation { continuation in + let continuationWrapper = ContinuationWrapper(continuation: continuation) + UIImageWriteToSavedPhotosAlbum( + self, + self, + #selector(saveCompleted), + Unmanaged.passRetained(continuationWrapper).toOpaque() + ) + } + } + + /// Downsizes the image to the specified target size, asynchronously, respecting aspect ratio. + /// - Parameters: + /// - targetSize: The desired size to which the image should be downsized. + /// - Returns: An optional UIImage that is the result of the downsizing operation. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let targetSize = CGSize(width: 100, height: 100) + /// let resizedImage = await image.downsize(toTargetSize: targetSize) + /// imageView.image = resizedImage + /// } catch { + /// print("Failed to downsize image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + func downsize(toTargetSize targetSize: CGSize) async throws -> UIImage { + guard !targetSize.width.isZero, !targetSize.height.isZero else { + throw ImageError.invalidSize + } + + return await Task(priority: .high) { + let widthRatio = targetSize.width / size.width + let heightRatio = targetSize.height / size.height + let scaleFactor = min(widthRatio, heightRatio) + let newSize = CGSize( + width: floor(size.width * scaleFactor), + height: floor(size.height * scaleFactor) + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let newImage = renderer.image { _ in + draw(in: CGRect(origin: .zero, size: newSize)) + } + + return newImage + }.value + } + + /// Downsizes the image by the specified percentage asynchronously. + /// - Parameters: + /// - percentage: The percentage by which the image should be downsized. Must be greater than 0 and less than or equal to 100. + /// - Returns: A UIImage that is the result of the downsizing operation. + /// + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let percentage: CGFloat = 50.0 + /// let resizedImage = try await image.downsize(byPercentage: percentage) + /// imageView.image = resizedImage + /// } catch { + /// print("Failed to downsize image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: `ImageError.invalidPercentage` if the percentage is not within the valid range. + func downsize(byPercentage percentage: CGFloat) async throws -> UIImage { + guard percentage > 0 && percentage <= 100 else { + throw ImageError.invalidPercentage + } + + return await Task(priority: .high) { + let scaleFactor = percentage / 100.0 + let newSize = CGSize( + width: size.width * scaleFactor, + height: size.height * scaleFactor + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let newImage = renderer.image { _ in + draw(in: CGRect(origin: .zero, size: newSize)) + } + + return newImage + }.value + } + + /// Compresses the image to the specified format. + /// + /// This function compresses UIImage to the specified format (JPEG or PNG). + /// It returns the compressed image data. + /// If the compression operation fails, the function throws an error. + /// + /// - Parameters: + /// - format: The desired image format (JPEG or PNG). + /// - Returns: The compressed image data as `Data`. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let compressedData = try await image.compress(to: .jpeg(compressionRatio: 0.8)) + /// } catch { + /// print("Failed to compress image: \(error)") + /// } + /// ``` + /// - Throws: `ImageError.compression` if the compression operation fails. + func compress(toFormat format: ImageFormat) async throws -> Data { + try await Task(priority: .high) { + let compressedImage: Data? + switch format { + case .jpeg(let compressionRatio): + compressedImage = jpegData(compressionQuality: compressionRatio) + case .png: + compressedImage = pngData() + } + + guard let compressedImage else { throw ImageError.compression } + + Logger.debug("Image compressed to \(Double(compressedImage.count) / 1024.0) KB.") + return compressedImage + }.value + } + + /// Compresses the image to given `maxKbSize`. + /// + /// This function compresses UIImage to the specified size in KB. + /// It returns the compressed image data. + /// If the compression operation fails, the function throws an error. + /// + /// - Parameters: + /// - maxSizeInKb: The desired max size in KB. + /// - Returns: The compressed image data as `Data`. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let compressedData = try await image.compress(toMaxKbSize: 500) + /// } catch { + /// print("Failed to compress image: \(error)") + /// } + /// ``` + /// - Throws: `ImageError.compression` if the compression operation fails. + func compress(toMaxKbSize maxKbSize: CGFloat) async throws -> Data { + guard maxKbSize > 0 else { throw ImageError.invalidSize } + + return try await Task(priority: .high) { + let maxBytes = Int(maxKbSize * 1024) + let compressionStep: CGFloat = 0.05 + var compression: CGFloat = 1.0 + var compressedData: Data? + + // try to compress the image by reducing the quality until reached desired `maxSizeInKb` + while compression > 0.0 { + let data = try await compress(toFormat: .jpeg(compressionRatio: compression)) + if data.count <= maxBytes { + compressedData = data + break + } else { + compression -= compressionStep + } + } + + guard let compressedData else { throw ImageError.compression } + return compressedData + }.value + } + + enum ImageFormat { + case jpeg(compressionRatio: CGFloat) + case png + } +} + +// MARK: - Private Methods +private extension UIImage { + class ContinuationWrapper { + let continuation: CheckedContinuation + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + } + + @objc func saveCompleted(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + let continuationWrapper = Unmanaged.fromOpaque(contextInfo).takeRetainedValue() + + if let error = error { + continuationWrapper.continuation.resume(throwing: error) + } else { + continuationWrapper.continuation.resume() + } + } + + enum ImageError: LocalizedError { + case invalidPercentage + case compression + case invalidSize + } } #endif diff --git a/Sources/Utilities/ImageTools/ImageTools.swift b/Sources/Utilities/ImageTools/ImageTools.swift deleted file mode 100644 index 49519c53..00000000 --- a/Sources/Utilities/ImageTools/ImageTools.swift +++ /dev/null @@ -1,358 +0,0 @@ -// -// ImageTools.swift -// PovioKit -// -// Created by Borut Tomazin on 13/06/2024. -// Copyright © 2024 Povio Inc. All rights reserved. -// - -import UIKit -import PovioKitCore - -/// A utility class for performing various image-related operations. -public final class ImageTools: NSObject { -#if canImport(Kingfisher) - private var prefetcher: ImagePrefetcher? -#endif - - override public init() { - super.init() - } -} - -public extension ImageTools { - enum ImageError: LocalizedError { - case invalidPercentage - case compression - case invalidSize - } - - enum ImageFormat { - case jpeg(compressionRatio: CGFloat) - case png - } - - /// Saves the given image to the photo library asynchronously. - /// - /// This function saves the provided UIImage to the user's photo library. It performs - /// the operation asynchronously and throws an error if the save operation fails. - /// - /// - Parameters: - /// - image: The UIImage to be saved to the photo library. - /// - Example: - /// ```swift - /// do { - /// let image = UIImage(named: "exampleImage")! - /// try await saveImageToPhotoLibrary(image) - /// print("Image saved successfully.") - /// } catch { - /// print("Failed to save image: \(error)") - /// } - /// ``` - /// - Note: This function should be called from an asynchronous context using `await`. - /// - Throws: An error if the save operation fails. - func saveToPhotoLibrary(image: UIImage) async throws { - try await withCheckedThrowingContinuation { continuation in - let continuationWrapper = ContinuationWrapper(continuation: continuation) - UIImageWriteToSavedPhotosAlbum( - image, - self, - #selector(saveCompleted), - Unmanaged.passRetained(continuationWrapper).toOpaque() - ) - } - } - - /// Downsizes the given image to the specified target size, asynchronously, respecting aspect ratio. - /// - Parameters: - /// - image: The original UIImage to be downsized. - /// - targetSize: The desired size to which the image should be downsized. - /// - Returns: An optional UIImage that is the result of the downsizing operation. - /// - Example: - /// ```swift - /// do { - /// let image = UIImage(named: "exampleImage")! - /// let targetSize = CGSize(width: 100, height: 100) - /// let resizedImage = await downsize(image, to: targetSize) - /// imageView.image = resizedImage - /// } catch { - /// print("Failed to downsize image: \(error)") - /// } - /// ``` - /// - Note: This function should be called from an asynchronous context using `await`. - func downsize(image: UIImage, toTargetSize targetSize: CGSize) async throws -> UIImage { - guard !targetSize.width.isZero, !targetSize.height.isZero else { - throw ImageError.invalidSize - } - - return await Task(priority: .high) { - let originalSize = image.size - let widthRatio = targetSize.width / originalSize.width - let heightRatio = targetSize.height / originalSize.height - let scaleFactor = min(widthRatio, heightRatio) - let newSize = CGSize( - width: floor(originalSize.width * scaleFactor), - height: floor(originalSize.height * scaleFactor) - ) - - let renderer = UIGraphicsImageRenderer(size: newSize) - let newImage = renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: newSize)) - } - - return newImage - }.value - } - - /// Downsizes the given image by the specified percentage asynchronously. - /// - Parameters: - /// - image: The original UIImage to be downsized. - /// - percentage: The percentage by which the image should be downsized. Must be greater than 0 and less than or equal to 100. - /// - Returns: A UIImage that is the result of the downsizing operation. - /// - /// - Example: - /// ```swift - /// do { - /// let image = UIImage(named: "exampleImage")! - /// let percentage: CGFloat = 50.0 - /// let resizedImage = try await downsize(image, by: percentage) - /// imageView.image = resizedImage - /// } catch { - /// print("Failed to downsize image: \(error)") - /// } - /// ``` - /// - Note: This function should be called from an asynchronous context using `await`. - /// - Throws: `ImageError.invalidPercentage` if the percentage is not within the valid range. - func downsize(image: UIImage, byPercentage percentage: CGFloat) async throws -> UIImage { - guard percentage > 0 && percentage <= 100 else { - throw ImageError.invalidPercentage - } - - return await Task(priority: .high) { - let scaleFactor = percentage / 100.0 - let newSize = CGSize( - width: image.size.width * scaleFactor, - height: image.size.height * scaleFactor - ) - - let renderer = UIGraphicsImageRenderer(size: newSize) - let newImage = renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: newSize)) - } - - return newImage - }.value - } - - /// Compresses the given image to the specified format. - /// - /// This function compresses the provided UIImage to the specified format (JPEG or PNG). - /// It returns the compressed image data. - /// If the compression operation fails, the function throws an error. - /// - /// - Parameters: - /// - image: The UIImage to be compressed. - /// - format: The desired image format (JPEG or PNG). - /// - Returns: The compressed image data as `Data`. - /// - Example: - /// ```swift - /// do { - /// let image = UIImage(named: "exampleImage")! - /// let compressedData = try await compress(image, format: .jpeg(compressionRatio: 0.8)) - /// } catch { - /// print("Failed to compress image: \(error)") - /// } - /// ``` - /// - Throws: `ImageError.compression` if the compression operation fails. - func compress(image: UIImage, withFormat format: ImageFormat) async throws -> Data { - try await Task(priority: .high) { - let compressedImage: Data? - switch format { - case .jpeg(let compressionRatio): - compressedImage = image.jpegData(compressionQuality: compressionRatio) - case .png: - compressedImage = image.pngData() - } - - guard let compressedImage else { throw ImageError.compression } - - Logger.debug("Image compressed to \(Double(compressedImage.count) / 1024.0) KB.") - return compressedImage - }.value - } - - /// Compresses the given image to given `maxKbSize`. - /// - /// This function compresses the provided UIImage to the specified size in KB. - /// It returns the compressed image data. - /// If the compression operation fails, the function throws an error. - /// - /// - Parameters: - /// - image: The UIImage to be compressed. - /// - maxSizeInKb: The desired max size in KB. - /// - Returns: The compressed image data as `Data`. - /// - Example: - /// ```swift - /// do { - /// let image = UIImage(named: "exampleImage")! - /// let compressedData = try await compress(image, toMaxKbSize: 500) - /// } catch { - /// print("Failed to compress image: \(error)") - /// } - /// ``` - /// - Throws: `ImageError.compression` if the compression operation fails. - func compress(image: UIImage, toMaxKbSize maxKbSize: CGFloat) async throws -> Data { - guard maxKbSize > 0 else { throw ImageError.invalidSize } - - return try await Task(priority: .high) { - let maxBytes = Int(maxKbSize * 1024) - let compressionStep: CGFloat = 0.05 - var compression: CGFloat = 1.0 - var compressedData: Data? - - // try to compress the image by reducing the quality until reached desired `maxSizeInKb` - while compression > 0.0 { - let data = try await self.compress(image: image, withFormat: .jpeg(compressionRatio: compression)) - if data.count <= maxBytes { - compressedData = data - break - } else { - compression -= compressionStep - } - } - - guard let compressedData else { throw ImageError.compression } - return compressedData - }.value - } -} - -#if canImport(Kingfisher) -import Kingfisher - -// MARK: - Operations based on Kingfisher lib -public extension ImageTools { - struct PrefetchResult { - let skipped: Int - let failed: Int - let completed: Int - } - - /// Clears the image cache. - /// - /// This function clears the image cache using the specified ImageCache instance. - /// If no cache instance is provided, it defaults to using the shared cache of the KingfisherManager. - /// - /// - Parameters: - /// - cache: The ImageCache instance to be cleared. Defaults to `KingfisherManager.shared.cache`. - /// - /// - Example: - /// ```swift - /// // Clear the default shared cache - /// clearCache() - /// - /// // Clear a specific cache instance - /// let customCache = ImageCache(name: "customCache") - /// clearCache(customCache) - /// ``` - func clear(cache: ImageCache = KingfisherManager.shared.cache) { - cache.clearCache() - } - - /// Downloads an image from the given URL asynchronously. - /// - /// This function uses the Kingfisher library to download an image from the specified URL. - /// It performs the download operation asynchronously and returns the downloaded UIImage. - /// If the download operation fails, the function throws an error. - /// - /// - Parameters: - /// - url: The URL from which to download the image. - /// - Returns: The downloaded UIImage. - /// - /// - Example: - /// ```swift - /// do { - /// let url = URL(string: "https://example.com/image.jpg")! - /// let downloadedImage = try await download(from: url) - /// imageView.image = downloadedImage - /// } catch { - /// print("Failed to download image: \(error)") - /// } - /// ``` - /// - /// - Note: This function should be called from an asynchronous context using `await`. - /// - Throws: An error if the download operation fails. - func download(url: URL) async throws -> UIImage { - try await withCheckedThrowingContinuation { continuation in - KingfisherManager.shared.retrieveImage(with: url, options: nil, progressBlock: nil, downloadTaskUpdated: nil) { - switch $0 { - case .success(let result): - continuation.resume(returning: result.image) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - /// Prefetches images from the given URLs asynchronously. - /// - /// This function uses the Kingfisher library to prefetch images from the specified URLs. - /// It performs the prefetch operation asynchronously and returns a `PrefetchResult` containing - /// the counts of skipped, failed, and completed prefetch operations. - /// - /// It is usefull when we need to have images ready before we present the UI. - /// - /// - Parameters: - /// - urls: An array of URLs from which to prefetch images. - /// - Returns: A `PrefetchResult` containing the counts of skipped, failed, and completed prefetch operations. - /// - Example: - /// ```swift - /// let urls = [ - /// URL(string: "https://example.com/image1.jpg")!, - /// URL(string: "https://example.com/image2.jpg")! - /// ] - /// let result = await prefetch(urls: urls) - /// print("Skipped: \(result.skipped), Failed: \(result.failed), Completed: \(result.completed)") - /// ``` - /// - Note: This function should be called from an asynchronous context using `await`. - @discardableResult - func prefetch(urls: [URL]) async -> PrefetchResult { - await withCheckedContinuation { continuation in - prefetcher = ImagePrefetcher(urls: urls, options: nil) { skipped, failed, completed in - let result = PrefetchResult( - skipped: skipped.count, - failed: failed.count, - completed: completed.count - ) - continuation.resume(with: .success(result)) - } - prefetcher?.start() - } - } -} -#endif - -// MARK: - Private Methods -private extension ImageTools { - @objc func saveCompleted( - _ image: UIImage, - didFinishSavingWithError error: Swift.Error?, - contextInfo: UnsafeRawPointer - ) { - let continuationWrapper = Unmanaged.fromOpaque(contextInfo).takeRetainedValue() - if let error { - continuationWrapper.continuation.resume(throwing: error) - } else { - continuationWrapper.continuation.resume(returning: ()) - } - } - - class ContinuationWrapper { - let continuation: CheckedContinuation - - init(continuation: CheckedContinuation) { - self.continuation = continuation - } - } -} diff --git a/Tests/Tests/Utilities/ImageTools/ImageToolsTests.swift b/Tests/Tests/Utilities/ImageTools/ImageToolsTests.swift deleted file mode 100644 index 613e103d..00000000 --- a/Tests/Tests/Utilities/ImageTools/ImageToolsTests.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// ImageToolsTests.swift -// -// -// Created by Borut Tomazin on 01/08/2024. -// - -import XCTest -import PovioKitUtilities - -final class ImageToolsTests: XCTestCase { - private let imageTools = ImageTools() - private let image: UIImage? = UIImage(named: "PovioKit", in: .module, with: nil) - - func testDownsizeToTargetSize() { - guard let image else { XCTFail("Failed to load image"); return } - let promise = expectation(description: "Downsizing...") - - Task { - do { - let downsizedImage = try await imageTools.downsize(image: image, toTargetSize: .init(size: 200)) - XCTAssertLessThanOrEqual(downsizedImage.size.width, 200, "The width of the downsized image should be 200") - XCTAssertLessThanOrEqual(downsizedImage.size.height, 200, "The height of the downsized image should be 200") - } catch { - XCTFail("The image failed to downsize.") - } - promise.fulfill() - } - - waitForExpectations(timeout: 1) - } - - func testDownsizeByPercentage() { - guard let image else { XCTFail("Failed to load image"); return } - let promise = expectation(description: "Downsizing...") - let originalSize = image.size - - Task { - do { - let downsizedImage = try await imageTools.downsize(image: image, byPercentage: 50) - XCTAssertEqual(downsizedImage.size.width, originalSize.width / 2, "The width of the downsized image should be 200") - XCTAssertEqual(downsizedImage.size.height, originalSize.height / 2, "The height of the downsized image should be 200") - } catch { - XCTFail("The image failed to downsize.") - } - promise.fulfill() - } - - waitForExpectations(timeout: 1) - } - - func testCompressWithPngFormat() { - guard let image else { XCTFail("Failed to load image"); return } - let promise = expectation(description: "Compressing...") - - Task { - do { - let downsizedImage = try await imageTools.compress(image: image, withFormat: .png) - // verify the image format by checking the PNG signature - let pngSignature: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] - let downsizedImageSignature = [UInt8](downsizedImage.prefix(pngSignature.count)) - - XCTAssertEqual(downsizedImageSignature, pngSignature, "The image was not compressed as PNG.") - } catch { - XCTFail("The image failed to compress.") - } - promise.fulfill() - } - - waitForExpectations(timeout: 1) - } - - func testCompressWithJpgFormat() { - guard let image else { XCTFail("Failed to load image"); return } - let promise = expectation(description: "Compressing...") - - Task { - do { - let downsizedImage = try await imageTools.compress(image: image, withFormat: .jpeg(compressionRatio: 0.5)) - // verify the image format by checking the JPEG signature - let jpegSignature: [UInt8] = [0xFF, 0xD8] - let downsizedImageSignature = [UInt8](downsizedImage.prefix(jpegSignature.count)) - - XCTAssertEqual(downsizedImageSignature, jpegSignature, "The image was not compressed as JPEG.") - } catch { - XCTFail("The image failed to compress.") - } - promise.fulfill() - } - - waitForExpectations(timeout: 1) - } - - func testCompressToMaxSize() { - guard let image else { XCTFail("Failed to load image"); return } - let promise = expectation(description: "Compressing...") - - let targetKB = 10.0 - - Task { - do { - let downsizedImage = try await imageTools.compress(image: image, toMaxKbSize: targetKB) - // Check if the size is 1KB or less - let imageSizeInKB = Double(downsizedImage.count) / 1024.0 - XCTAssertLessThanOrEqual(imageSizeInKB, targetKB, "The compressed image size is greater than 1KB.") - } catch { - XCTFail("The image failed to compress. \(error)") - } - promise.fulfill() - } - - waitForExpectations(timeout: 1) - } -} From 4eacbe21bfdbfe13dcea27eafc9933396e6e20e3 Mon Sep 17 00:00:00 2001 From: Borut Tomazin Date: Thu, 26 Sep 2024 11:13:36 +0200 Subject: [PATCH 5/6] Cleanup. --- .../Extensions/SwiftUI}/View+PovioKit.swift | 5 + .../Core/Extensions/UIKit/UIImageTests.swift | 117 ++++++++++++++++++ .../BundleReader/BundleReaderTests.swift | 3 +- 3 files changed, 124 insertions(+), 1 deletion(-) rename Sources/{UI/SwiftUI/Extensions => Core/Extensions/SwiftUI}/View+PovioKit.swift (75%) create mode 100644 Tests/Tests/Core/Extensions/UIKit/UIImageTests.swift diff --git a/Sources/UI/SwiftUI/Extensions/View+PovioKit.swift b/Sources/Core/Extensions/SwiftUI/View+PovioKit.swift similarity index 75% rename from Sources/UI/SwiftUI/Extensions/View+PovioKit.swift rename to Sources/Core/Extensions/SwiftUI/View+PovioKit.swift index 8a6ec67b..9ab8beb9 100644 --- a/Sources/UI/SwiftUI/Extensions/View+PovioKit.swift +++ b/Sources/Core/Extensions/SwiftUI/View+PovioKit.swift @@ -14,6 +14,11 @@ public extension View { frame(width: size, height: size, alignment: alignment) } + /// Returns square frame for given CGSize. + func frame(size: CGSize, alignment: Alignment = .center) -> some View { + frame(width: size.width, height: size.height, alignment: alignment) + } + /// Hides view using opacity. func hidden(_ hidden: Bool) -> some View { opacity(hidden ? 0 : 1) diff --git a/Tests/Tests/Core/Extensions/UIKit/UIImageTests.swift b/Tests/Tests/Core/Extensions/UIKit/UIImageTests.swift new file mode 100644 index 00000000..3e8909be --- /dev/null +++ b/Tests/Tests/Core/Extensions/UIKit/UIImageTests.swift @@ -0,0 +1,117 @@ +// +// UIImageTests.swift +// PovioKit_Tests +// +// Created by Borut Tomazin on 26/09/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +#if os(iOS) +import XCTest +import UIKit +import PovioKitCore + +class UIImageTests: XCTestCase { + private let image: UIImage? = UIImage(named: "PovioKit", in: .module, with: nil) + + func testDownsizeToTargetSize() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Downsizing...") + + Task { + do { + let downsizedImage = try await image.downsize(toTargetSize: .init(size: 200)) + XCTAssertLessThanOrEqual(downsizedImage.size.width, 200, "The width of the downsized image should be 200") + XCTAssertLessThanOrEqual(downsizedImage.size.height, 200, "The height of the downsized image should be 200") + } catch { + XCTFail("The image failed to downsize.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testDownsizeByPercentage() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Downsizing...") + let originalSize = image.size + + Task { + do { + let downsizedImage = try await image.downsize(byPercentage: 50) + XCTAssertEqual(downsizedImage.size.width, originalSize.width / 2, "The width of the downsized image should be 200") + XCTAssertEqual(downsizedImage.size.height, originalSize.height / 2, "The height of the downsized image should be 200") + } catch { + XCTFail("The image failed to downsize.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressWithPngFormat() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + Task { + do { + let downsizedImage = try await image.compress(toFormat: .png) + // verify the image format by checking the PNG signature + let pngSignature: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + let downsizedImageSignature = [UInt8](downsizedImage.prefix(pngSignature.count)) + + XCTAssertEqual(downsizedImageSignature, pngSignature, "The image was not compressed as PNG.") + } catch { + XCTFail("The image failed to compress.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressWithJpgFormat() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + Task { + do { + let downsizedImage = try await image.compress(toFormat: .jpeg(compressionRatio: 0.5)) + // verify the image format by checking the JPEG signature + let jpegSignature: [UInt8] = [0xFF, 0xD8] + let downsizedImageSignature = [UInt8](downsizedImage.prefix(jpegSignature.count)) + + XCTAssertEqual(downsizedImageSignature, jpegSignature, "The image was not compressed as JPEG.") + } catch { + XCTFail("The image failed to compress.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressToMaxSize() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + let targetKB = 10.0 + + Task { + do { + let downsizedImage = try await image.compress(toMaxKbSize: targetKB) + // Check if the size is 1KB or less + let imageSizeInKB = Double(downsizedImage.count) / 1024.0 + XCTAssertLessThanOrEqual(imageSizeInKB, targetKB, "The compressed image size is greater than 1KB.") + } catch { + XCTFail("The image failed to compress. \(error)") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } +} +#endif diff --git a/Tests/Tests/Core/Utilities/BundleReader/BundleReaderTests.swift b/Tests/Tests/Core/Utilities/BundleReader/BundleReaderTests.swift index aae4e73b..314ec884 100644 --- a/Tests/Tests/Core/Utilities/BundleReader/BundleReaderTests.swift +++ b/Tests/Tests/Core/Utilities/BundleReader/BundleReaderTests.swift @@ -40,7 +40,8 @@ private extension BundleReaderTests { return (sut, reader) } } -private class BundleSpy: Bundle { + +private class BundleSpy: Bundle, @unchecked Sendable { private(set) var capturedRead: String? private var internalDictionary: [String: String] = [:] override func object(forInfoDictionaryKey key: String) -> Any? { From 3d620d178d49ab484341a543693d62ab07726b9a Mon Sep 17 00:00:00 2001 From: Borut Tomazin Date: Tue, 12 Nov 2024 11:29:33 +0100 Subject: [PATCH 6/6] Public access. --- .../Extensions/UIKit/UIImage+Kingfisher.swift | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift b/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift index 327a504d..0d976071 100644 --- a/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift +++ b/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift @@ -9,7 +9,7 @@ import UIKit import Kingfisher -extension UIKit { +public extension UIImage { struct PrefetchResult { let skipped: Int let failed: Int @@ -30,16 +30,16 @@ extension UIKit { /// ```swift /// do { /// let url = URL(string: "https://example.com/image.jpg")! - /// let downloadedImage = try await download(from: url) + /// let downloadedImage = try await UIImage.download(from: url) /// imageView.image = downloadedImage /// } catch { - /// print("Failed to download image: \(error)") + /// Logger.error("Failed to download image: \(error)") /// } /// ``` /// /// - Note: This function should be called from an asynchronous context using `await`. /// - Throws: An error if the download operation fails. - func download(from url: URL) async throws -> UIImage { + static func download(from url: URL) async throws -> UIImage { try await withCheckedThrowingContinuation { continuation in KingfisherManager.shared.retrieveImage(with: url, options: nil, progressBlock: nil, downloadTaskUpdated: nil) { switch $0 { @@ -63,18 +63,20 @@ extension UIKit { /// - Parameters: /// - urls: An array of URLs from which to prefetch images. /// - Returns: A `PrefetchResult` containing the counts of skipped, failed, and completed prefetch operations. + /// /// - Example: /// ```swift /// let urls = [ - /// URL(string: "https://example.com/image1.jpg")!, - /// URL(string: "https://example.com/image2.jpg")! + /// URL(string: "https://example.com/image1.jpg")!, + /// URL(string: "https://example.com/image2.jpg")! /// ] - /// let result = await prefetch(urls: urls) - /// print("Skipped: \(result.skipped), Failed: \(result.failed), Completed: \(result.completed)") + /// let result = await UIImage.prefetch(urls: urls) + /// Logger.info("Skipped: \(result.skipped), Failed: \(result.failed), Completed: \(result.completed)") /// ``` + /// /// - Note: This function should be called from an asynchronous context using `await`. @discardableResult - func prefetch(from urls: [URL]) async -> PrefetchResult { + static func prefetch(from urls: [URL]) async -> PrefetchResult { await withCheckedContinuation { continuation in let prefetcher = ImagePrefetcher(urls: urls, options: nil) { skipped, failed, completed in let result = PrefetchResult( @@ -99,13 +101,13 @@ extension UIKit { /// - Example: /// ```swift /// // Clear the default shared cache - /// clearCache() + /// UIImage.clearCache() /// /// // Clear a specific cache instance /// let customCache = ImageCache(name: "customCache") - /// clearCache(customCache) + /// UIImage.clearCache(customCache) /// ``` - func clear(cache: ImageCache = KingfisherManager.shared.cache) { + public static func clearCache(_ cache: ImageCache = KingfisherManager.shared.cache) { cache.clearCache() } }