From f622b4c201131d3928d236e072153fd1cace1093 Mon Sep 17 00:00:00 2001 From: xspyhack Date: Sat, 23 Apr 2016 00:48:18 +0800 Subject: [PATCH] Add AnimatedImageView to improve performance of animated GIF. --- README.md | 7 + Sources/AnimatedImageView.swift | 353 ++++++++++++++++++++++++++++++++ Sources/Image.swift | 25 +++ 3 files changed, 385 insertions(+) create mode 100644 Sources/AnimatedImageView.swift diff --git a/README.md b/README.md index 86bbfc316..1ce0ccb1d 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,13 @@ prefetcher.stop() After prefetching, you could retrieve image or set the image view with other Kingfisher's methods, with the same `ImageCache` object you used for the prefetching. +### Animated GIF + +You can load animated GIF by replacing `UIImageView` with `AnimatedImageView` +```swift +imageView = AnimatedImageView() +``` + ## Future of Kingfisher I want to keep Kingfisher slim. This framework will focus on providing a simple solution for image downloading and caching. But that does not mean the framework will not be improved. Kingfisher is far away from perfect, and necessary and useful features will be added later to make it better. diff --git a/Sources/AnimatedImageView.swift b/Sources/AnimatedImageView.swift new file mode 100644 index 000000000..cd3155651 --- /dev/null +++ b/Sources/AnimatedImageView.swift @@ -0,0 +1,353 @@ +// +// AnimatableImageView.swift +// Kingfisher +// +// Created by bl4ckra1sond3tre on 4/22/16. +// Copyright © 2016 Wei Wang. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import UIKit +import ImageIO + +/// `AnimatedImageView` is a subclass of `UIImageView` for displaying animated image. +public class AnimatedImageView: UIImageView { + + /// Proxy object for prevending a reference cycle between the CADDisplayLink and AnimatedImageView. + class TargetProxy { + private weak var target: AnimatedImageView? + + init(target: AnimatedImageView) { + self.target = target + } + + @objc func onScreenUpdate() { + target?.updateFrame() + } + } + + // MARK: - Public property + /// Whether automatically play the animation when the view become visible. Default is true. + public var autoPlayAnimatedImage = true + + /// The size of the frame cache. + public var framePreloadCount = 10 + + /// Specifies whether the GIF frames should be pre-scaled to save memory. Default is true. + public var needsPrescaling = true + + /// The animation timer's run loop mode. Default is `NSRunLoopCommonModes`. Set this property to `NSDefaultRunLoopMode` will make the animation pause during UIScrollView scrolling. + public var runLoopMode = NSRunLoopCommonModes { + willSet { + if runLoopMode == newValue { + return + } else { + stopAnimating() + displayLink.removeFromRunLoop(NSRunLoop.mainRunLoop(), forMode: runLoopMode) + displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: newValue) + startAnimating() + } + } + } + + // MARK: - Private property + /// `Animator` instance that holds the frames of a specific image in memory. + private var animator: Animator? + + /// A flag to avoid invalidating the displayLink on deinit if it was never created, because displayLink is so lazy. :D + private var displayLinkInitialized: Bool = false + + /// A display link that keeps calling the `updateFrame` method on every screen refresh. + private lazy var displayLink: CADisplayLink = { + self.displayLinkInitialized = true + let displayLink = CADisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate)) + displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: self.runLoopMode) + displayLink.paused = true + return displayLink + }() + + // MARK: - Override + override public var image: Image? { + didSet { + if image != oldValue { + reset() + } + setNeedsDisplay() + } + } + + deinit { + if displayLinkInitialized { + displayLink.invalidate() + } + } + + override public func isAnimating() -> Bool { + return !displayLink.paused + } + + /// Starts the animation. + override public func startAnimating() { + if self.isAnimating() { + return + } else { + displayLink.paused = false + } + } + + /// Stops the animation. + override public func stopAnimating() { + super.stopAnimating() + displayLink.paused = true + } + + override public func displayLayer(layer: CALayer) { + if let currentFrame = animator?.currentFrame { + layer.contents = currentFrame.CGImage + } + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + didMove() + } + + override public func didMoveToSuperview() { + super.didMoveToSuperview() + didMove() + } + + // MARK: - Private method + /// Reset the animator. + private func reset() { + animator = nil + if let imageSource = image?.kf_imageSource?.imageRef { + animator = Animator(imageSource: imageSource, contentMode: contentMode, size: bounds.size, framePreloadCount: framePreloadCount) + animator?.needsPrescaling = needsPrescaling + animator?.prepareFrames() + } + didMove() + } + + private func didMove() { + if autoPlayAnimatedImage && animator != nil { + if let _ = superview, _ = window { + startAnimating() + } else { + stopAnimating() + } + } + } + + /// Update the current frame with the displayLink duration. + private func updateFrame() { + if animator?.updateCurrentFrame(displayLink.duration) ?? false { + layer.setNeedsDisplay() + } + } +} + +/// Keeps a reference to an `Image` instance and its duration as a GIF frame. +struct AnimatedFrame { + var image: Image? + let duration: NSTimeInterval + + static func null() -> AnimatedFrame { + return AnimatedFrame(image: .None, duration: 0.0) + } +} + +// MARK: - Animator +/// +class Animator { + // MARK: Private property + private let size: CGSize + private let maxFrameCount: Int + private let imageSource: CGImageSourceRef + + private var animatedFrames = [AnimatedFrame]() + private let maxTimeStep: NSTimeInterval = 1.0 + private var frameCount = 0 + private var currentFrameIndex = 0 + private var currentPreloadIndex = 0 + private var timeSinceLastFrameChange: NSTimeInterval = 0.0 + private var needsPrescaling = true + + /// Loop count of animatd image. + private var loopCount = 0 + + var currentFrame: UIImage? { + return frameAtIndex(currentFrameIndex) + } + + var contentMode: UIViewContentMode = .ScaleToFill + + /** + Init an animator with image source reference. + + - parameter imageSource: The reference of animated image. + + - parameter contentMode: Content mode of AnimatedImageView. + + - parameter size: Size of AnimatedImageView. + + - framePreloadCount: Frame cache size. + + - returns: The animator object. + */ + init(imageSource src: CGImageSourceRef, contentMode mode: UIViewContentMode, size: CGSize, framePreloadCount: Int) { + self.imageSource = src + self.contentMode = mode + self.size = size + self.maxFrameCount = framePreloadCount + } + + func frameAtIndex(index: Int) -> Image? { + return animatedFrames[index].image + } + + func prepareFrames() { + frameCount = CGImageSourceGetCount(imageSource) + + if let properties = CGImageSourceCopyProperties(imageSource, nil), + gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary, + loopCount = gifInfo[kCGImagePropertyGIFLoopCount as String] as? Int { + self.loopCount = loopCount + } + + let frameToProcess = min(frameCount, maxFrameCount) + animatedFrames.reserveCapacity(frameToProcess) + animatedFrames = (0.. AnimatedFrame { + guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { + return AnimatedFrame.null() + } + + let frameDuration = imageSource.kf_GIFPropertiesAtIndex(index).flatMap { (gifInfo) -> Double? in + let unclampedDelayTime = gifInfo[kCGImagePropertyGIFUnclampedDelayTime as String] as Double? + let delayTime = gifInfo[kCGImagePropertyGIFDelayTime as String] as Double? + let duration = unclampedDelayTime ?? delayTime + /** + http://opensource.apple.com/source/WebCore/WebCore-7600.1.25/platform/graphics/cg/ImageSourceCG.cpp + Many annoying ads specify a 0 duration to make an image flash as quickly as + possible. We follow Safari and Firefox's behavior and use a duration of 100 ms + for any frames that specify a duration of <= 10 ms. + See and for more information. + + See also: http://nullsleep.tumblr.com/post/16524517190/animated-gif-minimum-frame-delay-browser. + */ + return duration > 0.011 ? duration : 0.100 + } + + let image = Image(CGImage: imageRef) + let scaledImage: Image? + + if needsPrescaling { + scaledImage = image.kf_resizeToSize(size, contentMode: contentMode) + } else { + scaledImage = image + } + + return AnimatedFrame(image: scaledImage, duration: frameDuration ?? 0.0) + } + + /** + Updates the current frame if necessary using the frame timer and the duration of each frame in `animatedFrames`. + */ + func updateCurrentFrame(duration: CFTimeInterval) -> Bool { + timeSinceLastFrameChange += min(maxTimeStep, duration) + guard let frameDuration = animatedFrames[safe: currentFrameIndex]?.duration where frameDuration <= timeSinceLastFrameChange else { + return false + } + + timeSinceLastFrameChange -= frameDuration + let lastFrameIndex = currentFrameIndex + currentFrameIndex += 1 + currentFrameIndex = currentFrameIndex % animatedFrames.count + + if animatedFrames.count < frameCount { + animatedFrames[lastFrameIndex] = prepareFrame(currentPreloadIndex) + currentPreloadIndex += 1 + currentPreloadIndex = currentPreloadIndex % frameCount + } + return true + } +} + +// MARK: - Resize +extension Image { + func kf_resizeToSize(size: CGSize, contentMode: UIViewContentMode) -> Image { + switch contentMode { + case .ScaleAspectFit: + let newSize = self.size.kf_sizeConstrainedSize(size) + return kf_resizeToSize(newSize) + case .ScaleAspectFill: + let newSize = self.size.kf_sizeFillingSize(size) + return kf_resizeToSize(newSize) + default: + return kf_resizeToSize(size) + } + } + + private func kf_resizeToSize(size: CGSize) -> Image { + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + drawInRect(CGRect(origin: CGPoint.zero, size: size)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return resizedImage ?? self + } +} + +extension CGSize { + func kf_sizeConstrainedSize(size: CGSize) -> CGSize { + let aspectWidth = round(kf_aspectRatio * size.height) + let aspectHeight = round(size.width / kf_aspectRatio) + + return aspectWidth > size.width ? CGSize(width: size.width, height: aspectHeight) : CGSize(width: aspectWidth, height: size.height) + } + + func kf_sizeFillingSize(size: CGSize) -> CGSize { + let aspectWidth = round(kf_aspectRatio * size.height) + let aspectHeight = round(size.width / kf_aspectRatio) + + return aspectWidth < size.width ? CGSize(width: size.width, height: aspectHeight) : CGSize(width: aspectWidth, height: size.height) + } + private var kf_aspectRatio: CGFloat { + return height == 0.0 ? 1.0 : width / height + } +} + +extension CGImageSourceRef { + func kf_GIFPropertiesAtIndex(index: Int) -> [String: Double]? { + let properties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) as Dictionary? + return properties?[kCGImagePropertyGIFDictionary as String] as? [String: Double] + } +} + +extension Array { + subscript(safe index: Int) -> Element? { + return indices ~= index ? self[index] : .None + } +} + +func pure(a: T) -> [T] { + return [a] +} diff --git a/Sources/Image.swift b/Sources/Image.swift index a6af4346b..330c08c02 100644 --- a/Sources/Image.swift +++ b/Sources/Image.swift @@ -35,6 +35,8 @@ private var durationKey: Void? import UIKit.UIImage import MobileCoreServices public typealias Image = UIImage + +private var imageSourceKey: Void? #endif import ImageIO @@ -81,6 +83,15 @@ extension Image { var kf_duration: NSTimeInterval { return duration } + + private(set) var kf_imageSource: ImageSource? { + get { + return objc_getAssociatedObject(self, &imageSourceKey) as? ImageSource + } + set { + objc_setAssociatedObject(self, &imageSourceKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } #endif } @@ -242,7 +253,13 @@ extension Image { } return nil #else + #if os(tvOS) || os(watchOS) return Image.kf_animatedImageWithImages(images, duration: duration <= 0.0 ? gifDuration : duration) + #else + let image = Image(data: data) + image?.kf_imageSource = ImageSource(ref: imageSource) + return image + #endif #endif } } @@ -299,6 +316,14 @@ extension Image { } } +/// Reference the source image reference +class ImageSource { + var imageRef: CGImageSourceRef? + init(ref: CGImageSourceRef) { + self.imageRef = ref + } +} + // MARK: - Image format private let pngHeader: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] private let jpgHeaderSOI: [UInt8] = [0xFF, 0xD8]