From e366e4b07df781565457c91fae360d311d9996b2 Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Sun, 11 Jun 2023 23:19:52 +0800 Subject: [PATCH 1/3] feat: introducing a custom image source provider to enable third-party image processors to utilize AnimatedImageView. --- Sources/Image/GIFAnimatedImage.swift | 40 +++++++++++++-- Sources/Image/Image.swift | 60 +++++++++++++++++----- Sources/Image/ImageDrawing.swift | 4 +- Sources/Views/AnimatedImageView.swift | 73 ++++++++++++++++++++------- 4 files changed, 140 insertions(+), 37 deletions(-) diff --git a/Sources/Image/GIFAnimatedImage.swift b/Sources/Image/GIFAnimatedImage.swift index 8b2480f5a..418fe14a9 100644 --- a/Sources/Image/GIFAnimatedImage.swift +++ b/Sources/Image/GIFAnimatedImage.swift @@ -74,13 +74,13 @@ public class GIFAnimatedImage { let images: [KFCrossPlatformImage] let duration: TimeInterval - init?(from imageSource: CGImageSource, for info: [String: Any], options: ImageCreatingOptions) { - let frameCount = CGImageSourceGetCount(imageSource) + init?(from frameSource: ImageFrameSource, options: ImageCreatingOptions) { + let frameCount = frameSource.frameCount var images = [KFCrossPlatformImage]() var gifDuration = 0.0 for i in 0 ..< frameCount { - guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, i, info as CFDictionary) else { + guard let imageRef = frameSource.frame(at: i) else { return nil } @@ -88,7 +88,7 @@ public class GIFAnimatedImage { gifDuration = .infinity } else { // Get current animated GIF frame duration - gifDuration += GIFAnimatedImage.getFrameDuration(from: imageSource, at: i) + gifDuration += frameSource.duration(at: i) } images.append(KingfisherWrapper.image(cgImage: imageRef, scale: options.scale, refImage: nil)) if options.onlyFirstFrame { break } @@ -97,6 +97,11 @@ public class GIFAnimatedImage { self.duration = gifDuration } + convenience init?(from imageSource: CGImageSource, for info: [String: Any], options: ImageCreatingOptions) { + let frameSource = CGImageFrameSource(data: nil, imageSource: imageSource, options: info) + self.init(from: frameSource, options: options) + } + /// Calculates frame duration for a gif frame out of the kCGImagePropertyGIFDictionary dictionary. public static func getFrameDuration(from gifInfo: [String: Any]?) -> TimeInterval { let defaultFrameDuration = 0.1 @@ -119,3 +124,30 @@ public class GIFAnimatedImage { return getFrameDuration(from: gifInfo) } } + +/// Represents a frame source for animated image +public protocol ImageFrameSource { + var data: Data? { get } + var frameCount: Int { get } + func frame(at index: Int) -> CGImage? + func duration(at index: Int) -> TimeInterval +} + +struct CGImageFrameSource: ImageFrameSource { + let data: Data? + let imageSource: CGImageSource + let options: [String: Any]? + + var frameCount: Int { + return CGImageSourceGetCount(imageSource) + } + + func frame(at index: Int) -> CGImage? { + return CGImageSourceCreateImageAtIndex(imageSource, index, options as CFDictionary?) + } + + func duration(at index: Int) -> TimeInterval { + return GIFAnimatedImage.getFrameDuration(from: imageSource, at: index) + } +} + diff --git a/Sources/Image/Image.swift b/Sources/Image/Image.swift index e594e5ba6..714c80a55 100644 --- a/Sources/Image/Image.swift +++ b/Sources/Image/Image.swift @@ -91,7 +91,14 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage { var size: CGSize { return base.size } /// The image source reference of current image. - public private(set) var imageSource: CGImageSource? { + public var imageSource: CGImageSource? { + get { + guard let frameSource = frameSource as? CGImageFrameSource else { return nil } + return frameSource.imageSource + } + } + + public private(set) var frameSource: ImageFrameSource? { get { return getAssociatedObject(base, &imageSourceKey) } set { setRetainedAssociatedObject(base, &imageSourceKey, newValue) } } @@ -274,29 +281,51 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage { guard let imageSource = CGImageSourceCreateWithData(data as CFData, info as CFDictionary) else { return nil } - + let frameSource = CGImageFrameSource(data: data, imageSource: imageSource, options: info) #if os(macOS) - guard let animatedImage = GIFAnimatedImage(from: imageSource, for: info, options: options) else { + let baseImage = KFCrossPlatformImage(data: data) + #else + let baseImage = KFCrossPlatformImage(data: data, scale: options.scale) + #endif + return animatedImage(source: frameSource, options: options, baseImage: baseImage) + } + + /// Creates an animated image from a given frame source. + /// + /// - Parameters: + /// - source: The frame source to create animated image from. + /// - options: Options to use when creating the animated image. + /// - baseImage: An optional image object to be used as the key frame of the animated image. If `nil`, the first + /// frame of the `source` will be used. + /// - Returns: An `Image` object represents the animated image. It is in form of an array of image frames with a + /// certain duration. `nil` if anything wrong when creating animated image. + public static func animatedImage(source: ImageFrameSource, options: ImageCreatingOptions, baseImage: KFCrossPlatformImage? = nil) -> KFCrossPlatformImage? { + #if os(macOS) + guard let animatedImage = GIFAnimatedImage(from: source, options: options) else { return nil } var image: KFCrossPlatformImage? if options.onlyFirstFrame { image = animatedImage.images.first } else { - image = KFCrossPlatformImage(data: data) + if let baseImage = baseImage { + image = baseImage + } else { + image = animatedImage.images.first + } var kf = image?.kf kf?.images = animatedImage.images kf?.duration = animatedImage.duration } - image?.kf.animatedImageData = data - image?.kf.imageFrameCount = Int(CGImageSourceGetCount(imageSource)) + image?.kf.animatedImageData = source.data + image?.kf.imageFrameCount = source.frameCount return image #else var image: KFCrossPlatformImage? if options.preloadAll || options.onlyFirstFrame { // Use `images` image if you want to preload all animated data - guard let animatedImage = GIFAnimatedImage(from: imageSource, for: info, options: options) else { + guard let animatedImage = GIFAnimatedImage(from: source, options: options) else { return nil } if options.onlyFirstFrame { @@ -305,15 +334,22 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage { let duration = options.duration <= 0.0 ? animatedImage.duration : options.duration image = .animatedImage(with: animatedImage.images, duration: duration) } - image?.kf.animatedImageData = data + image?.kf.animatedImageData = source.data } else { - image = KFCrossPlatformImage(data: data, scale: options.scale) + if let baseImage = baseImage { + image = baseImage + } else { + guard let firstFrame = source.frame(at: 0) else { + return nil + } + image = KFCrossPlatformImage(cgImage: firstFrame, scale: options.scale, orientation: .up) + } var kf = image?.kf - kf?.imageSource = imageSource - kf?.animatedImageData = data + kf?.frameSource = source + kf?.animatedImageData = source.data } - image?.kf.imageFrameCount = Int(CGImageSourceGetCount(imageSource)) + image?.kf.imageFrameCount = source.frameCount return image #endif } diff --git a/Sources/Image/ImageDrawing.swift b/Sources/Image/ImageDrawing.swift index 63f135a1a..25e53136d 100644 --- a/Sources/Image/ImageDrawing.swift +++ b/Sources/Image/ImageDrawing.swift @@ -514,7 +514,7 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage { public func decoded(scale: CGFloat) -> KFCrossPlatformImage { // Prevent animated image (GIF) losing it's images #if os(iOS) - if imageSource != nil { return base } + if frameSource != nil { return base } #else if images != nil { return base } #endif @@ -543,7 +543,7 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage { public func decoded(on context: CGContext) -> KFCrossPlatformImage { // Prevent animated image (GIF) losing it's images #if os(iOS) - if imageSource != nil { return base } + if frameSource != nil { return base } #else if images != nil { return base } #endif diff --git a/Sources/Views/AnimatedImageView.swift b/Sources/Views/AnimatedImageView.swift index 85cee13dd..079aa0b93 100644 --- a/Sources/Views/AnimatedImageView.swift +++ b/Sources/Views/AnimatedImageView.swift @@ -264,10 +264,10 @@ open class AnimatedImageView: UIImageView { // Reset the animator. private func reset() { animator = nil - if let image = image, let imageSource = image.kf.imageSource { + if let image = image, let frameSource = image.kf.frameSource { let targetSize = bounds.scaled(UIScreen.main.scale).size let animator = Animator( - imageSource: imageSource, + frameSource: frameSource, contentMode: contentMode, size: targetSize, imageSize: image.kf.size, @@ -385,7 +385,7 @@ extension AnimatedImageView { /// The maximum count of image frames that needs preload. public let maxFrameCount: Int - private let imageSource: CGImageSource + private let frameSource: ImageFrameSource private let maxRepeatCount: RepeatCount private let maxTimeStep: TimeInterval = 1.0 @@ -467,7 +467,37 @@ extension AnimatedImageView { /// - count: Count of frames needed to be preloaded. /// - repeatCount: The repeat count should this animator uses. /// - preloadQueue: Dispatch queue used for preloading images. - init(imageSource source: CGImageSource, + convenience init(imageSource source: CGImageSource, + contentMode mode: UIView.ContentMode, + size: CGSize, + imageSize: CGSize, + imageScale: CGFloat, + framePreloadCount count: Int, + repeatCount: RepeatCount, + preloadQueue: DispatchQueue) { + let frameSource = CGImageFrameSource(data: nil, imageSource: source, options: nil) + self.init(frameSource: frameSource, + contentMode: mode, + size: size, + imageSize: imageSize, + imageScale: imageScale, + framePreloadCount: count, + repeatCount: repeatCount, + preloadQueue: preloadQueue) + } + + /// Creates an animator with a custom image frame source. + /// + /// - Parameters: + /// - frameSource: The reference of animated image. + /// - mode: Content mode of the `AnimatedImageView`. + /// - size: Size of the `AnimatedImageView`. + /// - imageSize: Size of the `KingfisherWrapper`. + /// - imageScale: Scale of the `KingfisherWrapper`. + /// - count: Count of frames needed to be preloaded. + /// - repeatCount: The repeat count should this animator uses. + /// - preloadQueue: Dispatch queue used for preloading images. + init(frameSource source: ImageFrameSource, contentMode mode: UIView.ContentMode, size: CGSize, imageSize: CGSize, @@ -475,7 +505,7 @@ extension AnimatedImageView { framePreloadCount count: Int, repeatCount: RepeatCount, preloadQueue: DispatchQueue) { - self.imageSource = source + self.frameSource = source self.contentMode = mode self.size = size self.imageSize = imageSize @@ -504,7 +534,7 @@ extension AnimatedImageView { } func prepareFramesAsynchronously() { - frameCount = Int(CGImageSourceGetCount(imageSource)) + frameCount = frameSource.frameCount animatedFrames.reserveCapacity(frameCount) preloadQueue.async { [weak self] in self?.setupAnimatedFrames() @@ -529,7 +559,7 @@ extension AnimatedImageView { var duration: TimeInterval = 0 (0.. UIImage? { - let resize = needsPrescaling && size != .zero - let options: [CFString: Any]? - if resize { - options = [ - kCGImageSourceCreateThumbnailFromImageIfAbsent: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true, - kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) - ] + let cgImage: CGImage? + if let imageSource = frameSource as? CGImageFrameSource { + let resize = needsPrescaling && size != .zero + let options: [CFString: Any]? + if resize { + options = [ + kCGImageSourceCreateThumbnailFromImageIfAbsent: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) + ] + } else { + options = nil + } + cgImage = CGImageSourceCreateImageAtIndex(imageSource.imageSource, index, options as CFDictionary?) } else { - options = nil + cgImage = frameSource.frame(at: index) } - - guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, index, options as CFDictionary?) else { + guard let cgImage = cgImage else { return nil } From 86b4c21401fda6789ec5f2cd4fd9ff7e0835b2a6 Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Mon, 12 Jun 2023 12:32:00 +0800 Subject: [PATCH 2/3] complete documentation --- Sources/Image/GIFAnimatedImage.swift | 7 +++++++ Sources/Image/Image.swift | 1 + 2 files changed, 8 insertions(+) diff --git a/Sources/Image/GIFAnimatedImage.swift b/Sources/Image/GIFAnimatedImage.swift index 418fe14a9..ef79f1421 100644 --- a/Sources/Image/GIFAnimatedImage.swift +++ b/Sources/Image/GIFAnimatedImage.swift @@ -127,9 +127,16 @@ public class GIFAnimatedImage { /// Represents a frame source for animated image public protocol ImageFrameSource { + /// Source data associated with this frame source. var data: Data? { get } + + /// Count of total frames in this frame source. var frameCount: Int { get } + + /// Retrieves the frame at a specific index. If the index is invalid, implementors should return `nil`. func frame(at index: Int) -> CGImage? + + /// Retrieves the duration at a specific index. If the index is invalid, implementors should return `0.0`. func duration(at index: Int) -> TimeInterval } diff --git a/Sources/Image/Image.swift b/Sources/Image/Image.swift index 714c80a55..b6419e119 100644 --- a/Sources/Image/Image.swift +++ b/Sources/Image/Image.swift @@ -98,6 +98,7 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage { } } + /// The custom frame source of current image. public private(set) var frameSource: ImageFrameSource? { get { return getAssociatedObject(base, &imageSourceKey) } set { setRetainedAssociatedObject(base, &imageSourceKey, newValue) } From b3872aba7825a8e7262898ef64db180d938dc55a Mon Sep 17 00:00:00 2001 From: Yang Chao Date: Mon, 12 Jun 2023 23:44:16 +0800 Subject: [PATCH 3/3] feat: add `maxSize` parameter when extracting image frames --- Sources/Image/GIFAnimatedImage.swift | 23 ++++++++++++++++++++--- Sources/Views/AnimatedImageView.swift | 22 +++------------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Sources/Image/GIFAnimatedImage.swift b/Sources/Image/GIFAnimatedImage.swift index ef79f1421..323fe4292 100644 --- a/Sources/Image/GIFAnimatedImage.swift +++ b/Sources/Image/GIFAnimatedImage.swift @@ -133,13 +133,21 @@ public protocol ImageFrameSource { /// Count of total frames in this frame source. var frameCount: Int { get } - /// Retrieves the frame at a specific index. If the index is invalid, implementors should return `nil`. - func frame(at index: Int) -> CGImage? + /// Retrieves the frame at a specific index. The result image is expected to be + /// no larger than `maxSize`. If the index is invalid, implementors should return `nil`. + func frame(at index: Int, maxSize: CGSize?) -> CGImage? /// Retrieves the duration at a specific index. If the index is invalid, implementors should return `0.0`. func duration(at index: Int) -> TimeInterval } +public extension ImageFrameSource { + /// Retrieves the frame at a specific index. If the index is invalid, implementors should return `nil`. + func frame(at index: Int) -> CGImage? { + return frame(at: index, maxSize: nil) + } +} + struct CGImageFrameSource: ImageFrameSource { let data: Data? let imageSource: CGImageSource @@ -149,7 +157,16 @@ struct CGImageFrameSource: ImageFrameSource { return CGImageSourceGetCount(imageSource) } - func frame(at index: Int) -> CGImage? { + func frame(at index: Int, maxSize: CGSize?) -> CGImage? { + var options = self.options as? [CFString: Any] + if let maxSize = maxSize, maxSize != .zero { + options = (options ?? [:]).merging([ + kCGImageSourceCreateThumbnailFromImageIfAbsent: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: max(maxSize.width, maxSize.height) + ], uniquingKeysWith: { $1 }) + } return CGImageSourceCreateImageAtIndex(imageSource, index, options as CFDictionary?) } diff --git a/Sources/Views/AnimatedImageView.swift b/Sources/Views/AnimatedImageView.swift index 079aa0b93..6471398b7 100644 --- a/Sources/Views/AnimatedImageView.swift +++ b/Sources/Views/AnimatedImageView.swift @@ -575,25 +575,9 @@ extension AnimatedImageView { } private func loadFrame(at index: Int) -> UIImage? { - let cgImage: CGImage? - if let imageSource = frameSource as? CGImageFrameSource { - let resize = needsPrescaling && size != .zero - let options: [CFString: Any]? - if resize { - options = [ - kCGImageSourceCreateThumbnailFromImageIfAbsent: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true, - kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) - ] - } else { - options = nil - } - cgImage = CGImageSourceCreateImageAtIndex(imageSource.imageSource, index, options as CFDictionary?) - } else { - cgImage = frameSource.frame(at: index) - } - guard let cgImage = cgImage else { + let resize = needsPrescaling && size != .zero + let maxSize = resize ? size : nil + guard let cgImage = frameSource.frame(at: index, maxSize: maxSize) else { return nil }