Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce a custom image source provider to enable third-party image processors to utilize AnimatedImageView. #2094

Merged
merged 3 commits into from
Jun 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions Sources/Image/GIFAnimatedImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,21 @@ 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
}

if frameCount == 1 {
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 }
Expand All @@ -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
Expand All @@ -119,3 +124,54 @@ public class GIFAnimatedImage {
return getFrameDuration(from: gifInfo)
}
}

/// 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. 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
let options: [String: Any]?

var frameCount: Int {
return CGImageSourceGetCount(imageSource)
}

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?)
}

func duration(at index: Int) -> TimeInterval {
return GIFAnimatedImage.getFrameDuration(from: imageSource, at: index)
}
}

61 changes: 49 additions & 12 deletions Sources/Image/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,15 @@ 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
}
}

/// The custom frame source of current image.
public private(set) var frameSource: ImageFrameSource? {
get { return getAssociatedObject(base, &imageSourceKey) }
set { setRetainedAssociatedObject(base, &imageSourceKey, newValue) }
}
Expand Down Expand Up @@ -274,29 +282,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 {
Expand All @@ -305,15 +335,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
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Image/ImageDrawing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 39 additions & 20 deletions Sources/Views/AnimatedImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -467,15 +467,45 @@ 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,
imageScale: CGFloat,
framePreloadCount count: Int,
repeatCount: RepeatCount,
preloadQueue: DispatchQueue) {
self.imageSource = source
self.frameSource = source
self.contentMode = mode
self.size = size
self.imageSize = imageSize
Expand Down Expand Up @@ -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()
Expand All @@ -529,7 +559,7 @@ extension AnimatedImageView {
var duration: TimeInterval = 0

(0..<frameCount).forEach { index in
let frameDuration = GIFAnimatedImage.getFrameDuration(from: imageSource, at: index)
let frameDuration = frameSource.duration(at: index)
duration += min(frameDuration, maxTimeStep)
animatedFrames.append(AnimatedFrame(image: nil, duration: frameDuration))

Expand All @@ -546,19 +576,8 @@ extension AnimatedImageView {

private func loadFrame(at index: Int) -> 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)
]
} else {
options = nil
}

guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, index, options as CFDictionary?) else {
let maxSize = resize ? size : nil
guard let cgImage = frameSource.frame(at: index, maxSize: maxSize) else {
return nil
}

Expand Down