diff --git a/Demo/demo/Images.xcassets/earth.dataset/Contents.json b/Demo/demo/Images.xcassets/earth.dataset/Contents.json new file mode 100644 index 0000000..42b0809 --- /dev/null +++ b/Demo/demo/Images.xcassets/earth.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "data" : [ + { + "idiom" : "universal", + "filename" : "earth.gif" + } + ] +} \ No newline at end of file diff --git a/Demo/demo/Images.xcassets/earth.dataset/earth.gif b/Demo/demo/Images.xcassets/earth.dataset/earth.gif new file mode 100644 index 0000000..6b90dae Binary files /dev/null and b/Demo/demo/Images.xcassets/earth.dataset/earth.gif differ diff --git a/Demo/demo/Main.storyboard b/Demo/demo/Main.storyboard index d478106..624da18 100755 --- a/Demo/demo/Main.storyboard +++ b/Demo/demo/Main.storyboard @@ -1,8 +1,8 @@ - + - + diff --git a/GifuTests/GifuTests.swift b/GifuTests/GifuTests.swift index 8a54ee8..46c0e54 100644 --- a/GifuTests/GifuTests.swift +++ b/GifuTests/GifuTests.swift @@ -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() + } + } + + waitForExpectationsWithTimeout(1.0) { error in + if let error = error { + print("Error: \(error.localizedDescription)") + } + } } - func testFrameAtIndex() { - XCTAssertNotNil(animator!.frameAtIndex(preloadedFrameCount! - 1)) - } + func testFrameInfo() { + let expectation = expectationWithDescription("testFrameInfoIsAccurate") - func testFrameDurationPrecision() { - let image = animator!.frameAtIndex(5) - XCTAssertTrue((image!.duration - 0.05) < 0.00001) - } + animator.prepareFrames { + let frameDuration = self.animator.frameAtIndex(5)?.duration ?? 0 + XCTAssertTrue((frameDuration - 0.05) < 0.00001) - func testFrameSize() { - let image = animator!.frameAtIndex(5) - XCTAssertEqual(image!.size, staticImage!.size) - } + let imageSize = self.animator.frameAtIndex(5)?.size ?? CGSizeZero + XCTAssertEqual(imageSize, staticImage.size) - func testPrepareFramesPerformance() { - let tempAnimator = Animator(data: imageData!, size: CGSizeZero, contentMode: .ScaleToFill, framePreloadCount: 50) + expectation.fulfill() + } - self.measureBlock() { - tempAnimator.prepareFrames() + waitForExpectationsWithTimeout(1.0) { error in + if let error = error { + print("Error: \(error.localizedDescription)") + } } } } -private func testImageDataNamed(name: String) -> NSData? { +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)! } diff --git a/Source/AnimatableImageView.swift b/Source/AnimatableImageView.swift index 4bfae8a..83d966a 100644 --- a/Source/AnimatableImageView.swift +++ b/Source/AnimatableImageView.swift @@ -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() } } } diff --git a/Source/AnimatedFrame.swift b/Source/AnimatedFrame.swift index 9f76a89..2462b55 100644 --- a/Source/AnimatedFrame.swift +++ b/Source/AnimatedFrame.swift @@ -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) } } diff --git a/Source/Animator.swift b/Source/Animator.swift index 452cd17..f58054e 100644 --- a/Source/Animator.swift +++ b/Source/Animator.swift @@ -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.. 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 - /// Updates the current frame if necessary using the frame timer and the duration of each frame in `animatedFrames`. - /// - /// - returns: An optional image at a given frame. - 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 = (currentFrameIndex + 1) % animatedFrames.count - - // Loads the next needed frame for progressive loading - if animatedFrames.count < frameCount { - animatedFrames[lastFrameIndex] = prepareFrame(currentPreloadIndex) - currentPreloadIndex = (currentFrameIndex + 1) % frameCount + preloadIndexesWithStartingIndex(currentFrameIndex).forEach { index in + let currentAnimatedFrame = animatedFrames[index] + if !currentAnimatedFrame.isPlaceholder { return } + animatedFrames[index] = currentAnimatedFrame.frameWithImage(loadFrameAtIndex(index)) } - - return true + } + + /// Increments the `timeSinceLastFrameChange` property with a given duration. + /// + /// - parameter duration: An `NSTimeInterval` value to increment the `timeSinceLastFrameChange` property with. + func incrementTimeSinceLastFrameChangeWithDuration(duration: NSTimeInterval) { + timeSinceLastFrameChange += min(maxTimeStep, duration) + } + + /// 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.. preloadFrameCount { return } + animatedFrames[index] = animatedFrames[index].frameWithImage(loadFrameAtIndex(index)) + } + } + + /// Reset animated frames. + func resetAnimatedFrames() { + animatedFrames = [] } } diff --git a/Source/Info.plist b/Source/Info.plist index a4b68b8..06adfe8 100644 --- a/Source/Info.plist +++ b/Source/Info.plist @@ -19,7 +19,7 @@ CFBundleSignature ???? CFBundleVersion - 91 + 94 NSPrincipalClass