Skip to content

Commit

Permalink
Add AnimatedImageView to improve performance of animated GIF.
Browse files Browse the repository at this point in the history
  • Loading branch information
xspyhack committed Apr 22, 2016
1 parent efa15d0 commit f622b4c
Show file tree
Hide file tree
Showing 3 changed files with 385 additions and 0 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
353 changes: 353 additions & 0 deletions Sources/AnimatedImageView.swift
Original file line number Diff line number Diff line change
@@ -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..<frameToProcess).reduce([]) { $0 + pure(prepareFrame($1))}
}

func prepareFrame(index: Int) -> 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 <rdar://problem/7689300> and <http://webkit.org/b/36082> 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<T>(a: T) -> [T] {
return [a]
}
Loading

0 comments on commit f622b4c

Please sign in to comment.