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"?>
<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>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="8154"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Gifu-->

View File

@ -3,58 +3,70 @@ import ImageIO
@testable import Gifu
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 {
var animator: Animator?
var originalFrameCount: Int?
var preloadedFrameCount: Int?
var animator: Animator!
var originalFrameCount: Int!
override func setUp() {
super.setUp()
animator = Animator(data: imageData!, size: CGSizeZero, contentMode: .ScaleToFill, framePreloadCount: 20)
animator!.prepareFrames()
originalFrameCount = Int(CGImageSourceGetCount(animator!.imageSource))
preloadedFrameCount = animator!.animatedFrames.count
}
override func tearDown() {
super.tearDown()
animator = Animator(data: imageData, size: CGSizeZero, contentMode: .ScaleToFill, framePreloadCount: preloadFrameCount)
originalFrameCount = Int(CGImageSourceGetCount(animator.imageSource))
}
func testIsAnimatable() {
XCTAssertTrue(animator!.isAnimatable)
XCTAssertTrue(animator.isAnimatable)
}
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() {
XCTAssertNotNil(animator!.frameAtIndex(preloadedFrameCount! - 1))
}
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()
waitForExpectationsWithTimeout(1.0) { error in
if let error = error {
print("Error: \(error.localizedDescription)")
}
}
}
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 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() {
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.
override public func displayLayer(layer: CALayer) {
image = animator?.currentFrame
image = animator?.currentFrameImage ?? image
}
/// Starts the image view animation.
@ -100,16 +100,17 @@ public class AnimatableImageView: UIImageView {
displayLink.paused = true
}
/// Reset the image view values
/// Reset the image view values.
public func prepareForReuse() {
stopAnimatingGIF()
animator = nil
}
/// Update the current frame with the displayLink duration
func updateFrame() {
if animator?.updateCurrentFrame(displayLink.duration) ?? false {
layer.setNeedsDisplay()
/// Update the current frame if needed.
func updateFrameIfNeeded() {
guard let animator = animator else { return }
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.
struct AnimatedFrame {
/// The image that should be used for this frame.
let image: UIImage?
/// The duration that the frame image should be displayed.
let duration: NSTimeInterval
static func null() -> AnimatedFrame {
return AnimatedFrame(image: .None, duration: 0)
/// A placeholder frame with no image assigned.
/// 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
let contentMode: UIViewContentMode
/// Maximum number of frames to load at once
let maxFrameCount: Int
let preloadFrameCount: Int
/// The total number of frames in the GIF.
var frameCount = 0
/// A reference to the original image source.
var imageSource: CGImageSourceRef
/// The index of the current GIF frame.
var currentFrameIndex = 0
/// The index of the current GIF frame from the source.
var currentPreloadIndex = 0
var currentFrameIndex = 0 {
didSet {
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.
var timeSinceLastFrameChange: NSTimeInterval = 0.0
/// Specifies whether GIF frames should be pre-scaled.
/// - seealso: `needsPrescaling` in AnimatableImageView.
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.
var currentFrame: UIImage? {
var currentFrameImage: UIImage? {
return frameAtIndex(currentFrameIndex)
}
/// The current frame duration
var currentFrameDuration: NSTimeInterval {
return durationAtIndex(currentFrameIndex)
}
/// Is this image animatable?
var isAnimatable: Bool {
return imageSource.isAnimatedGIF
@ -46,34 +65,70 @@ class Animator {
self.imageSource = CGImageSourceCreateWithData(data, options) ?? CGImageSourceCreateIncremental(options)
self.size = size
self.contentMode = contentMode
self.maxFrameCount = framePreloadCount
self.preloadFrameCount = framePreloadCount
}
// MARK: - Frames
/// 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))
let framesToProcess = min(frameCount, maxFrameCount)
animatedFrames.reserveCapacity(framesToProcess)
animatedFrames = (0..<framesToProcess).reduce([]) { $0 + [prepareFrame($1)] }
currentPreloadIndex = framesToProcess
animatedFrames.reserveCapacity(frameCount)
dispatch_async(preloadFrameQueue) {
self.setupAnimatedFrames()
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
/// - returns: An AnimatedFrame object
func prepareFrame(index: Int) -> AnimatedFrame {
guard let frameImageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else {
return AnimatedFrame.null()
/// - parameter index: The index of the frame.
/// - returns: An optional image at a given frame.
func frameAtIndex(index: Int) -> UIImage? {
return animatedFrames[safe: index]?.image
}
let frameDuration = CGImageSourceGIFFrameDuration(imageSource, index: index)
let image = UIImage(CGImage: frameImageRef)
/// Returns the duration at a particular index.
///
/// - 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?
if needsPrescaling == true {
switch contentMode {
if needsPrescaling {
switch self.contentMode {
case .ScaleAspectFit: scaledImage = image.resizeAspectFit(size)
case .ScaleAspectFill: scaledImage = image.resizeAspectFill(size)
default: scaledImage = image.resize(size)
@ -82,35 +137,77 @@ class Animator {
scaledImage = image
}
return AnimatedFrame(image: scaledImage, duration: frameDuration)
return scaledImage
}
/// Returns the frame at a particular index.
///
/// - parameter index: The index of the frame.
/// - returns: An optional image at a given frame.
func frameAtIndex(index: Int) -> UIImage? {
return animatedFrames[index].image
/// Updates the frames by preloading new ones and replacing the previous frame with a placeholder.
func updatePreloadedFrames() {
if !preloadingIsNeeded { return }
animatedFrames[previousFrameIndex] = animatedFrames[previousFrameIndex].placeholderFrame
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.
func updateCurrentFrame(duration: CFTimeInterval) -> Bool {
/// - parameter duration: An `NSTimeInterval` value to increment the `timeSinceLastFrameChange` property with.
func incrementTimeSinceLastFrameChangeWithDuration(duration: NSTimeInterval) {
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>
<string>????</string>
<key>CFBundleVersion</key>
<string>91</string>
<string>94</string>
<key>NSPrincipalClass</key>
<string></string>
</dict>