Fix borked frame preloading
This commit is contained in:
parent
47ef2e2008
commit
aa2e6a11c5
|
@ -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 |
|
@ -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-->
|
||||
|
|
|
@ -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)!
|
||||
}
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>91</string>
|
||||
<string>94</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string></string>
|
||||
</dict>
|
||||
|
|
Loading…
Reference in New Issue