Fix borked frame preloading

This commit is contained in:
Reda Lemeden 2016-04-24 11:28:09 +02:00
parent 47ef2e2008
commit aa2e6a11c5
No known key found for this signature in database
GPG Key ID: 2A4B46ECF1B02C90
8 changed files with 230 additions and 91 deletions

View File

@ -0,0 +1,12 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
},
"data" : [
{
"idiom" : "universal",
"filename" : "earth.gif"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="8191" systemVersion="14F27" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="vXZ-lx-hvc"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10116" systemVersion="15E65" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="vXZ-lx-hvc">
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="8154"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies> </dependencies>
<scenes> <scenes>
<!--Gifu--> <!--Gifu-->

View File

@ -3,58 +3,70 @@ import ImageIO
@testable import Gifu @testable import Gifu
private let imageData = testImageDataNamed("mugen.gif") private let imageData = testImageDataNamed("mugen.gif")
private let staticImage = UIImage(data: imageData!) private let staticImage = UIImage(data: imageData)!
private let preloadFrameCount = 20
class GifuTests: XCTestCase { class GifuTests: XCTestCase {
var animator: Animator? var animator: Animator!
var originalFrameCount: Int? var originalFrameCount: Int!
var preloadedFrameCount: Int?
override func setUp() { override func setUp() {
super.setUp() super.setUp()
animator = Animator(data: imageData!, size: CGSizeZero, contentMode: .ScaleToFill, framePreloadCount: 20) animator = Animator(data: imageData, size: CGSizeZero, contentMode: .ScaleToFill, framePreloadCount: preloadFrameCount)
animator!.prepareFrames() originalFrameCount = Int(CGImageSourceGetCount(animator.imageSource))
originalFrameCount = Int(CGImageSourceGetCount(animator!.imageSource))
preloadedFrameCount = animator!.animatedFrames.count
}
override func tearDown() {
super.tearDown()
} }
func testIsAnimatable() { func testIsAnimatable() {
XCTAssertTrue(animator!.isAnimatable) XCTAssertTrue(animator.isAnimatable)
} }
func testFramePreload() { func testFramePreload() {
XCTAssertLessThanOrEqual(preloadedFrameCount!, originalFrameCount!) let expectation = expectationWithDescription("frameDuration")
animator.prepareFrames {
let animatedFrameCount = self.animator.animatedFrames.count
XCTAssertEqual(animatedFrameCount, self.originalFrameCount)
XCTAssertNotNil(self.animator.frameAtIndex(preloadFrameCount - 1))
XCTAssertNil(self.animator.frameAtIndex(preloadFrameCount + 1)?.images)
XCTAssertEqual(self.animator.currentFrameIndex, 0)
self.animator.shouldChangeFrame(1.0) { hasNewFrame in
XCTAssertTrue(hasNewFrame)
XCTAssertEqual(self.animator.currentFrameIndex, 1)
expectation.fulfill()
}
} }
func testFrameAtIndex() { waitForExpectationsWithTimeout(1.0) { error in
XCTAssertNotNil(animator!.frameAtIndex(preloadedFrameCount! - 1)) if let error = error {
} print("Error: \(error.localizedDescription)")
func testFrameDurationPrecision() {
let image = animator!.frameAtIndex(5)
XCTAssertTrue((image!.duration - 0.05) < 0.00001)
}
func testFrameSize() {
let image = animator!.frameAtIndex(5)
XCTAssertEqual(image!.size, staticImage!.size)
}
func testPrepareFramesPerformance() {
let tempAnimator = Animator(data: imageData!, size: CGSizeZero, contentMode: .ScaleToFill, framePreloadCount: 50)
self.measureBlock() {
tempAnimator.prepareFrames()
} }
} }
} }
private func testImageDataNamed(name: String) -> NSData? { func testFrameInfo() {
let expectation = expectationWithDescription("testFrameInfoIsAccurate")
animator.prepareFrames {
let frameDuration = self.animator.frameAtIndex(5)?.duration ?? 0
XCTAssertTrue((frameDuration - 0.05) < 0.00001)
let imageSize = self.animator.frameAtIndex(5)?.size ?? CGSizeZero
XCTAssertEqual(imageSize, staticImage.size)
expectation.fulfill()
}
waitForExpectationsWithTimeout(1.0) { error in
if let error = error {
print("Error: \(error.localizedDescription)")
}
}
}
}
private func testImageDataNamed(name: String) -> NSData {
let testBundle = NSBundle(forClass: GifuTests.self) let testBundle = NSBundle(forClass: GifuTests.self)
let imagePath = testBundle.bundleURL.URLByAppendingPathComponent(name) let imagePath = testBundle.bundleURL.URLByAppendingPathComponent(name)
return NSData(contentsOfURL: imagePath) return NSData(contentsOfURL: imagePath)!
} }

View File

@ -12,7 +12,7 @@ public class AnimatableImageView: UIImageView {
} }
@objc func onScreenUpdate() { @objc func onScreenUpdate() {
target?.updateFrame() target?.updateFrameIfNeeded()
} }
} }
@ -85,7 +85,7 @@ public class AnimatableImageView: UIImageView {
/// Updates the `image` property of the image view if necessary. This method should not be called manually. /// Updates the `image` property of the image view if necessary. This method should not be called manually.
override public func displayLayer(layer: CALayer) { override public func displayLayer(layer: CALayer) {
image = animator?.currentFrame image = animator?.currentFrameImage ?? image
} }
/// Starts the image view animation. /// Starts the image view animation.
@ -100,16 +100,17 @@ public class AnimatableImageView: UIImageView {
displayLink.paused = true displayLink.paused = true
} }
/// Reset the image view values /// Reset the image view values.
public func prepareForReuse() { public func prepareForReuse() {
stopAnimatingGIF() stopAnimatingGIF()
animator = nil animator = nil
} }
/// Update the current frame with the displayLink duration /// Update the current frame if needed.
func updateFrame() { func updateFrameIfNeeded() {
if animator?.updateCurrentFrame(displayLink.duration) ?? false { guard let animator = animator else { return }
layer.setNeedsDisplay() animator.shouldChangeFrame(displayLink.duration) { hasNewFrame in
if hasNewFrame { self.layer.setNeedsDisplay() }
} }
} }

View File

@ -1,10 +1,27 @@
/// Keeps a reference to an `UIImage` instance and its duration as a GIF frame. /// Keeps a reference to an `UIImage` instance and its duration as a GIF frame.
struct AnimatedFrame { struct AnimatedFrame {
/// The image that should be used for this frame.
let image: UIImage? let image: UIImage?
/// The duration that the frame image should be displayed.
let duration: NSTimeInterval let duration: NSTimeInterval
static func null() -> AnimatedFrame { /// A placeholder frame with no image assigned.
return AnimatedFrame(image: .None, duration: 0) /// Used to replace frames that are no longer needed in the animation.
var placeholderFrame: AnimatedFrame {
return AnimatedFrame(image: nil, duration: duration)
}
/// Whether the AnimatedFrame instance contains an image or not.
var isPlaceholder: Bool {
return image == .None
}
/// Takes an optional image and returns an non-placeholder `AnimatedFrame`.
///
/// - parameter image: An optional `UIImage` instance to be assigned to the new frame.
/// - returns: A non-placeholder `AnimatedFrame` instance.
func frameWithImage(image: UIImage?) -> AnimatedFrame {
return AnimatedFrame(image: image, duration: duration)
} }
} }

View File

@ -12,26 +12,45 @@ class Animator {
/// The content mode to use when resizing /// The content mode to use when resizing
let contentMode: UIViewContentMode let contentMode: UIViewContentMode
/// Maximum number of frames to load at once /// Maximum number of frames to load at once
let maxFrameCount: Int let preloadFrameCount: Int
/// The total number of frames in the GIF. /// The total number of frames in the GIF.
var frameCount = 0 var frameCount = 0
/// A reference to the original image source. /// A reference to the original image source.
var imageSource: CGImageSourceRef var imageSource: CGImageSourceRef
/// The index of the current GIF frame. /// The index of the current GIF frame.
var currentFrameIndex = 0 var currentFrameIndex = 0 {
/// The index of the current GIF frame from the source. didSet {
var currentPreloadIndex = 0 previousFrameIndex = oldValue
}
}
/// The index of the previous GIF frame.
var previousFrameIndex = 0 {
didSet {
dispatch_async(preloadFrameQueue) {
self.updatePreloadedFrames()
}
}
}
/// Time elapsed since the last frame change. Used to determine when the frame should be updated. /// Time elapsed since the last frame change. Used to determine when the frame should be updated.
var timeSinceLastFrameChange: NSTimeInterval = 0.0 var timeSinceLastFrameChange: NSTimeInterval = 0.0
/// Specifies whether GIF frames should be pre-scaled. /// Specifies whether GIF frames should be pre-scaled.
/// - seealso: `needsPrescaling` in AnimatableImageView. /// - seealso: `needsPrescaling` in AnimatableImageView.
var needsPrescaling = true var needsPrescaling = true
/// Dispatch queue used for preloading images.
private lazy var preloadFrameQueue = dispatch_queue_create("co.kaishin.Gifu.preloadQueue", DISPATCH_QUEUE_SERIAL)
/// The current image frame to show. /// The current image frame to show.
var currentFrame: UIImage? { var currentFrameImage: UIImage? {
return frameAtIndex(currentFrameIndex) return frameAtIndex(currentFrameIndex)
} }
/// The current frame duration
var currentFrameDuration: NSTimeInterval {
return durationAtIndex(currentFrameIndex)
}
/// Is this image animatable? /// Is this image animatable?
var isAnimatable: Bool { var isAnimatable: Bool {
return imageSource.isAnimatedGIF return imageSource.isAnimatedGIF
@ -46,34 +65,70 @@ class Animator {
self.imageSource = CGImageSourceCreateWithData(data, options) ?? CGImageSourceCreateIncremental(options) self.imageSource = CGImageSourceCreateWithData(data, options) ?? CGImageSourceCreateIncremental(options)
self.size = size self.size = size
self.contentMode = contentMode self.contentMode = contentMode
self.maxFrameCount = framePreloadCount self.preloadFrameCount = framePreloadCount
} }
// MARK: - Frames // MARK: - Frames
/// Loads the frames from an image source, resizes them, then caches them in `animatedFrames`. /// Loads the frames from an image source, resizes them, then caches them in `animatedFrames`.
func prepareFrames() { func prepareFrames(completionHandler: (Void -> Void)? = .None) {
frameCount = Int(CGImageSourceGetCount(imageSource)) frameCount = Int(CGImageSourceGetCount(imageSource))
let framesToProcess = min(frameCount, maxFrameCount) animatedFrames.reserveCapacity(frameCount)
animatedFrames.reserveCapacity(framesToProcess) dispatch_async(preloadFrameQueue) {
animatedFrames = (0..<framesToProcess).reduce([]) { $0 + [prepareFrame($1)] } self.setupAnimatedFrames()
currentPreloadIndex = framesToProcess if let handler = completionHandler { handler() }
}
} }
/// Loads a single frame from an image source, resizes it, then returns an `AnimatedFrame`. /// Returns the frame at a particular index.
/// ///
/// - parameter index: The index of the GIF image source to prepare /// - parameter index: The index of the frame.
/// - returns: An AnimatedFrame object /// - returns: An optional image at a given frame.
func prepareFrame(index: Int) -> AnimatedFrame { func frameAtIndex(index: Int) -> UIImage? {
guard let frameImageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { return animatedFrames[safe: index]?.image
return AnimatedFrame.null()
} }
let frameDuration = CGImageSourceGIFFrameDuration(imageSource, index: index) /// Returns the duration at a particular index.
let image = UIImage(CGImage: frameImageRef) ///
/// - parameter index: The index of the duration.
/// - returns: The duration of the given frame.
func durationAtIndex(index: Int) -> NSTimeInterval {
return animatedFrames[index].duration
}
/// Checks whether the frame should be changed and calls a handler with the results.
///
/// - parameter duration: A `CFTimeInterval` value that will be used to determine whether frame should be changed.
/// - parameter handler: A function that takes a `Bool` and returns nothing. It will be called with the frame change result.
func shouldChangeFrame(duration: CFTimeInterval, handler: Bool -> Void) {
incrementTimeSinceLastFrameChangeWithDuration(duration)
if currentFrameDuration > timeSinceLastFrameChange {
handler(false)
} else {
resetTimeSinceLastFrameChange()
incrementCurrentFrameIndex()
handler(true)
}
}
}
private extension Animator {
/// Whether preloading is needed or not.
var preloadingIsNeeded: Bool {
return preloadFrameCount < frameCount - 1
}
/// Optionally loads a single frame from an image source, resizes it if requierd, then returns an `UIImage`.
///
/// - parameter index: The index of the frame to load.
/// - returns: An optional `UIImage` instance.
func loadFrameAtIndex(index: Int) -> UIImage? {
guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { return .None }
let image = UIImage(CGImage: imageRef)
let scaledImage: UIImage? let scaledImage: UIImage?
if needsPrescaling == true { if needsPrescaling {
switch contentMode { switch self.contentMode {
case .ScaleAspectFit: scaledImage = image.resizeAspectFit(size) case .ScaleAspectFit: scaledImage = image.resizeAspectFit(size)
case .ScaleAspectFill: scaledImage = image.resizeAspectFill(size) case .ScaleAspectFill: scaledImage = image.resizeAspectFill(size)
default: scaledImage = image.resize(size) default: scaledImage = image.resize(size)
@ -82,35 +137,77 @@ class Animator {
scaledImage = image scaledImage = image
} }
return AnimatedFrame(image: scaledImage, duration: frameDuration) return scaledImage
} }
/// Returns the frame at a particular index. /// Updates the frames by preloading new ones and replacing the previous frame with a placeholder.
/// func updatePreloadedFrames() {
/// - parameter index: The index of the frame. if !preloadingIsNeeded { return }
/// - returns: An optional image at a given frame. animatedFrames[previousFrameIndex] = animatedFrames[previousFrameIndex].placeholderFrame
func frameAtIndex(index: Int) -> UIImage? {
return animatedFrames[index].image preloadIndexesWithStartingIndex(currentFrameIndex).forEach { index in
let currentAnimatedFrame = animatedFrames[index]
if !currentAnimatedFrame.isPlaceholder { return }
animatedFrames[index] = currentAnimatedFrame.frameWithImage(loadFrameAtIndex(index))
}
} }
/// Updates the current frame if necessary using the frame timer and the duration of each frame in `animatedFrames`. /// Increments the `timeSinceLastFrameChange` property with a given duration.
/// ///
/// - returns: An optional image at a given frame. /// - parameter duration: An `NSTimeInterval` value to increment the `timeSinceLastFrameChange` property with.
func updateCurrentFrame(duration: CFTimeInterval) -> Bool { func incrementTimeSinceLastFrameChangeWithDuration(duration: NSTimeInterval) {
timeSinceLastFrameChange += min(maxTimeStep, duration) timeSinceLastFrameChange += min(maxTimeStep, duration)
guard let frameDuration = animatedFrames[safe:currentFrameIndex]?.duration where
frameDuration <= timeSinceLastFrameChange else { return false }
timeSinceLastFrameChange -= frameDuration
let lastFrameIndex = currentFrameIndex
currentFrameIndex = (currentFrameIndex + 1) % animatedFrames.count
// Loads the next needed frame for progressive loading
if animatedFrames.count < frameCount {
animatedFrames[lastFrameIndex] = prepareFrame(currentPreloadIndex)
currentPreloadIndex = (currentFrameIndex + 1) % frameCount
} }
return true /// Ensures that `timeSinceLastFrameChange` remains accurate after each frame change by substracting the `currentFrameDuration`.
func resetTimeSinceLastFrameChange() {
timeSinceLastFrameChange -= currentFrameDuration
}
/// Increments the `currentFrameIndex` property.
func incrementCurrentFrameIndex() {
currentFrameIndex = incrementFrameIndex(currentFrameIndex)
}
/// Increments a given frame index, taking into account the `frameCount` and looping when necessary.
///
/// - parameter index: The `Int` value to increment.
/// - parameter byValue: The `Int` value to increment with.
/// - returns: A new `Int` value.
func incrementFrameIndex(index: Int, byValue value: Int = 1) -> Int {
return (index + value) % frameCount
}
/// Returns the indexes of the frames to preload based on a starting frame index.
///
/// - parameter index: Starting index.
/// - returns: An array of indexes to preload.
func preloadIndexesWithStartingIndex(index: Int) -> [Int] {
let nextIndex = incrementFrameIndex(index)
let lastIndex = incrementFrameIndex(index, byValue: preloadFrameCount)
if lastIndex >= nextIndex {
return [Int](nextIndex...lastIndex)
} else {
return [Int](nextIndex..<frameCount) + [Int](0...lastIndex)
}
}
/// Set up animated frames after resetting them if necessary.
func setupAnimatedFrames() {
resetAnimatedFrames()
(0..<frameCount).forEach { index in
let frameDuration = CGImageSourceGIFFrameDuration(imageSource, index: index)
animatedFrames += [AnimatedFrame(image: .None, duration: frameDuration)]
if index > preloadFrameCount { return }
animatedFrames[index] = animatedFrames[index].frameWithImage(loadFrameAtIndex(index))
}
}
/// Reset animated frames.
func resetAnimatedFrames() {
animatedFrames = []
} }
} }

View File

@ -19,7 +19,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>91</string> <string>94</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string></string> <string></string>
</dict> </dict>