diff --git a/Demo/demo/Main.storyboard b/Demo/demo/Main.storyboard
index ca738ab..a4c999b 100755
--- a/Demo/demo/Main.storyboard
+++ b/Demo/demo/Main.storyboard
@@ -23,7 +23,7 @@
-
+
diff --git a/Demo/demo/classes/ViewController.swift b/Demo/demo/classes/ViewController.swift
index 5a0fb8d..c3709a6 100755
--- a/Demo/demo/classes/ViewController.swift
+++ b/Demo/demo/classes/ViewController.swift
@@ -2,7 +2,7 @@ import UIKit
import Gifu
class ViewController: UIViewController {
- @IBOutlet weak var imageView: AnimatableImageView!
+ @IBOutlet weak var imageView: GIFImageView!
override func viewDidLoad() {
super.viewDidLoad()
@@ -17,4 +17,3 @@ class ViewController: UIViewController {
}
}
}
-
diff --git a/Gifu.xcodeproj/project.pbxproj b/Gifu.xcodeproj/project.pbxproj
index 998adda..e836684 100644
--- a/Gifu.xcodeproj/project.pbxproj
+++ b/Gifu.xcodeproj/project.pbxproj
@@ -9,14 +9,17 @@
/* Begin PBXBuildFile section */
0036ABB71BBD1D0B00C6CC3D /* mugen.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0036ABB61BBD1D0B00C6CC3D /* mugen.gif */; };
0036ABB91BBD1D1400C6CC3D /* nailed.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0036ABB81BBD1D1400C6CC3D /* nailed.gif */; };
- 005656ED1A6F14D6008A0ED1 /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005656EC1A6F14D6008A0ED1 /* Animator.swift */; };
- 005656EF1A6F1C26008A0ED1 /* AnimatableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005656EE1A6F1C26008A0ED1 /* AnimatableImageView.swift */; };
+ 005656ED1A6F14D6008A0ED1 /* FrameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005656EC1A6F14D6008A0ED1 /* FrameStore.swift */; };
+ 005656EF1A6F1C26008A0ED1 /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005656EE1A6F1C26008A0ED1 /* GIFImageView.swift */; };
007E08441BD95E6200883D0C /* ArrayExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007E08431BD95E6200883D0C /* ArrayExtension.swift */; };
+ 00978B6C1D9C6D2A00A6575F /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00978B6B1D9C6D2A00A6575F /* Animator.swift */; };
+ 00978B6E1D9FA99D00A6575F /* AnimatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00978B6D1D9FA99D00A6575F /* AnimatorDelegate.swift */; };
009BD1391BBC7F6500FC982B /* GifuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009BD1381BBC7F6500FC982B /* GifuTests.swift */; };
009BD13B1BBC7F6500FC982B /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 00B8C73E1A364DA400C188E7 /* Gifu.framework */; };
009BD1441BBC93C800FC982B /* CGSizeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009BD1431BBC93C800FC982B /* CGSizeExtension.swift */; };
00B8C75F1A364DCE00C188E7 /* ImageSourceHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B8C75C1A364DCE00C188E7 /* ImageSourceHelpers.swift */; };
00B8C7961A3650EE00C188E7 /* Gifu.h in Headers */ = {isa = PBXBuildFile; fileRef = 00B8C7951A3650EE00C188E7 /* Gifu.h */; settings = {ATTRIBUTES = (Public, ); }; };
+ 00BF42CC1D99A1DC00C6F28D /* GIFAnimatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF42CB1D99A1DC00C6F28D /* GIFAnimatable.swift */; };
EAF49C7F1A3A4DE000B395DF /* UIImageExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF49C7E1A3A4DE000B395DF /* UIImageExtension.swift */; };
EAF49CB11A3B6EEB00B395DF /* AnimatedFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */; };
/* End PBXBuildFile section */
@@ -34,9 +37,11 @@
/* Begin PBXFileReference section */
0036ABB61BBD1D0B00C6CC3D /* mugen.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; name = mugen.gif; path = Demo/GIFs/mugen.gif; sourceTree = SOURCE_ROOT; };
0036ABB81BBD1D1400C6CC3D /* nailed.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; name = nailed.gif; path = Demo/GIFs/nailed.gif; sourceTree = SOURCE_ROOT; };
- 005656EC1A6F14D6008A0ED1 /* Animator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animator.swift; sourceTree = ""; };
- 005656EE1A6F1C26008A0ED1 /* AnimatableImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatableImageView.swift; sourceTree = ""; };
+ 005656EC1A6F14D6008A0ED1 /* FrameStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameStore.swift; sourceTree = ""; };
+ 005656EE1A6F1C26008A0ED1 /* GIFImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = ""; };
007E08431BD95E6200883D0C /* ArrayExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayExtension.swift; sourceTree = ""; };
+ 00978B6B1D9C6D2A00A6575F /* Animator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animator.swift; sourceTree = ""; };
+ 00978B6D1D9FA99D00A6575F /* AnimatorDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatorDelegate.swift; sourceTree = ""; };
009BD1361BBC7F6500FC982B /* GifuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GifuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
009BD1381BBC7F6500FC982B /* GifuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifuTests.swift; sourceTree = ""; };
009BD13A1BBC7F6500FC982B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
@@ -45,6 +50,7 @@
00B8C7421A364DA400C188E7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../Source/Info.plist; sourceTree = ""; };
00B8C75C1A364DCE00C188E7 /* ImageSourceHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageSourceHelpers.swift; sourceTree = ""; };
00B8C7951A3650EE00C188E7 /* Gifu.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Gifu.h; sourceTree = ""; };
+ 00BF42CB1D99A1DC00C6F28D /* GIFAnimatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GIFAnimatable.swift; sourceTree = ""; };
EAF49C7E1A3A4DE000B395DF /* UIImageExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIImageExtension.swift; sourceTree = ""; };
EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedFrame.swift; sourceTree = ""; };
/* End PBXFileReference section */
@@ -98,9 +104,12 @@
007E08421BD95C6100883D0C /* Classes */ = {
isa = PBXGroup;
children = (
- 005656EE1A6F1C26008A0ED1 /* AnimatableImageView.swift */,
EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */,
- 005656EC1A6F14D6008A0ED1 /* Animator.swift */,
+ 00978B6B1D9C6D2A00A6575F /* Animator.swift */,
+ 005656EC1A6F14D6008A0ED1 /* FrameStore.swift */,
+ 00BF42CB1D99A1DC00C6F28D /* GIFAnimatable.swift */,
+ 005656EE1A6F1C26008A0ED1 /* GIFImageView.swift */,
+ 00978B6D1D9FA99D00A6575F /* AnimatorDelegate.swift */,
);
name = Classes;
sourceTree = "";
@@ -293,11 +302,14 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- 005656EF1A6F1C26008A0ED1 /* AnimatableImageView.swift in Sources */,
- 005656ED1A6F14D6008A0ED1 /* Animator.swift in Sources */,
+ 005656EF1A6F1C26008A0ED1 /* GIFImageView.swift in Sources */,
+ 00978B6E1D9FA99D00A6575F /* AnimatorDelegate.swift in Sources */,
+ 00BF42CC1D99A1DC00C6F28D /* GIFAnimatable.swift in Sources */,
+ 005656ED1A6F14D6008A0ED1 /* FrameStore.swift in Sources */,
009BD1441BBC93C800FC982B /* CGSizeExtension.swift in Sources */,
EAF49CB11A3B6EEB00B395DF /* AnimatedFrame.swift in Sources */,
00B8C75F1A364DCE00C188E7 /* ImageSourceHelpers.swift in Sources */,
+ 00978B6C1D9C6D2A00A6575F /* Animator.swift in Sources */,
007E08441BD95E6200883D0C /* ArrayExtension.swift in Sources */,
EAF49C7F1A3A4DE000B395DF /* UIImageExtension.swift in Sources */,
);
diff --git a/GifuTests/GifuTests.swift b/GifuTests/GifuTests.swift
index 05e13c4..08abad8 100644
--- a/GifuTests/GifuTests.swift
+++ b/GifuTests/GifuTests.swift
@@ -6,39 +6,50 @@ private let imageData = testImageDataNamed("mugen.gif")
private let staticImage = UIImage(data: imageData)!
private let preloadFrameCount = 20
+class DummyAnimatorDelegate: AnimatorDelegate {
+ func animatorHasNewFrame() { }
+}
+
class GifuTests: XCTestCase {
var animator: Animator!
var originalFrameCount: Int!
+ let delegate = DummyAnimatorDelegate()
override func setUp() {
super.setUp()
- animator = Animator(data: imageData, size: CGSize.zero, contentMode: .scaleToFill, framePreloadCount: preloadFrameCount)
- originalFrameCount = Int(CGImageSourceGetCount(animator.imageSource))
+ animator = Animator(withDelegate: delegate)
+ animator.prepareForAnimation(withGIFData: imageData, size: staticImage.size, contentMode: .scaleToFill)
+ originalFrameCount = 44
}
func testIsAnimatable() {
- XCTAssertTrue(animator.isAnimatable)
+ XCTAssertNotNil(animator.frameStore)
+ guard let store = animator.frameStore else { return }
+ XCTAssertTrue(store.isAnimatable)
}
func testCurrentFrame() {
- XCTAssertEqual(animator.currentFrameIndex, 0)
- XCTAssertEqual(animator.currentFrameDuration, TimeInterval.infinity)
- XCTAssertNil(animator.currentFrameImage)
+ XCTAssertNotNil(animator.frameStore)
+ guard let store = animator.frameStore else { return }
+ XCTAssertEqual(store.currentFrameIndex, 0)
}
func testFramePreload() {
+ XCTAssertNotNil(animator.frameStore)
+ guard let store = animator.frameStore else { return }
+
let expectation = self.expectation(description: "frameDuration")
- animator.prepareFrames {
- let animatedFrameCount = self.animator.animatedFrames.count
+ store.prepareFrames {
+ let animatedFrameCount = store.animatedFrames.count
XCTAssertEqual(animatedFrameCount, self.originalFrameCount)
- XCTAssertNotNil(self.animator.frame(at: preloadFrameCount - 1))
- XCTAssertNil(self.animator.frame(at: preloadFrameCount + 1)?.images)
- XCTAssertEqual(self.animator.currentFrameIndex, 0)
+ XCTAssertNotNil(store.frame(at: preloadFrameCount - 1))
+ XCTAssertNil(store.frame(at: preloadFrameCount + 1)?.images)
+ XCTAssertEqual(store.currentFrameIndex, 0)
- self.animator.shouldChangeFrame(with: 1.0) { hasNewFrame in
+ store.shouldChangeFrame(with: 1.0) { hasNewFrame in
XCTAssertTrue(hasNewFrame)
- XCTAssertEqual(self.animator.currentFrameIndex, 1)
+ XCTAssertEqual(store.currentFrameIndex, 1)
expectation.fulfill()
}
}
@@ -51,13 +62,16 @@ class GifuTests: XCTestCase {
}
func testFrameInfo() {
+ XCTAssertNotNil(animator.frameStore)
+ guard let store = animator.frameStore else { return }
+
let expectation = self.expectation(description: "testFrameInfoIsAccurate")
- animator.prepareFrames {
- let frameDuration = self.animator.frame(at: 5)?.duration ?? 0
+ store.prepareFrames {
+ let frameDuration = store.frame(at: 5)?.duration ?? 0
XCTAssertTrue((frameDuration - 0.05) < 0.00001)
- let imageSize = self.animator.frame(at: 5)?.size ?? CGSize.zero
+ let imageSize = store.frame(at: 5)?.size ?? CGSize.zero
XCTAssertEqual(imageSize, staticImage.size)
expectation.fulfill()
diff --git a/Source/AnimatableImageView.swift b/Source/AnimatableImageView.swift
deleted file mode 100644
index dbf7744..0000000
--- a/Source/AnimatableImageView.swift
+++ /dev/null
@@ -1,134 +0,0 @@
-import UIKit
-
-/// A subclass of `UIImageView` that can be animated using an image name string or raw data.
-public class AnimatableImageView: UIImageView {
- /// Proxy object for preventing a reference cycle between the CADisplayLink and AnimatableImageView.
- /// Source: http://merowing.info/2015/11/the-beauty-of-imperfection/
- fileprivate class TargetProxy {
- private weak var target: AnimatableImageView?
-
- init(target: AnimatableImageView) {
- self.target = target
- }
-
- @objc func onScreenUpdate() {
- target?.updateFrameIfNeeded()
- }
- }
-
- /// An `Animator` instance that holds the frames of a specific image in memory.
- var animator: Animator?
-
- /// A flag to avoid invalidating the displayLink on deinit if it was never created
- private var displayLinkInitialized: Bool = false
-
- /// A display link that keeps calling the `updateFrame` method on every screen refresh.
- lazy var displayLink: CADisplayLink = { [unowned self] in
- self.displayLinkInitialized = true
- let display = CADisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate))
- display.isPaused = true
- return display
- }()
-
- /// The size of the frame cache.
- public var framePreloadCount = 50
-
- /// Specifies whether the GIF frames should be pre-scaled to save memory. Default is **false**.
- public var needsPrescaling = false
-
- /// A computed property that returns whether the image view is animating.
- public var isAnimatingGIF: Bool {
- return !displayLink.isPaused
- }
-
- /// A computed property that returns the total number of frames in the GIF.
- public var frameCount: Int {
- return animator?.frameCount ?? 0
- }
-
- /// Prepares the frames using a GIF image file name, without starting the animation.
- /// The file name should include the `.gif` extension.
- ///
- /// - parameter imageName: The name of the GIF file. The method looks for the file in the app bundle.
- public func prepareForAnimation(withGIFNamed imageName: String) {
- guard let extensionRemoved = imageName.components(separatedBy: ".")[safe: 0],
- let imagePath = Bundle.main.url(forResource: extensionRemoved, withExtension: "gif"),
- let data = try? Data(contentsOf: imagePath) else { return }
-
- prepareForAnimation(withGIFData: data)
- }
-
- /// Prepares the frames using raw GIF image data, without starting the animation.
- ///
- /// - parameter data: GIF image data.
- public func prepareForAnimation(withGIFData imageData: Data) {
- image = UIImage(data: imageData)
- animator = Animator(data: imageData, size: frame.size, contentMode: contentMode, framePreloadCount: framePreloadCount)
- animator?.needsPrescaling = needsPrescaling
- animator?.prepareFrames()
- attachDisplayLink()
- }
-
- /// Prepares the frames using a GIF image file name and starts animating the image view.
- ///
- /// - parameter imageName: The name of the GIF file. The method looks for the file in the app bundle.
- public func animate(withGIFNamed imageName: String) {
- prepareForAnimation(withGIFNamed: imageName)
- startAnimatingGIF()
- }
-
- /// Prepares the frames using raw GIF image data and starts animating the image view.
- ///
- /// - parameter data: GIF image data.
- public func animate(withGIFData data: Data) {
- prepareForAnimation(withGIFData: data)
- startAnimatingGIF()
- }
-
- /// Updates the `image` property of the image view if necessary. This method should not be called manually.
- override public func display(_ layer: CALayer) {
- image = animator?.currentFrameImage ?? image
- }
-
- /// Starts the image view animation.
- public func startAnimatingGIF() {
- if animator?.isAnimatable ?? false {
- displayLink.isPaused = false
- }
- }
-
- /// Stops the image view animation.
- public func stopAnimatingGIF() {
- displayLink.isPaused = true
- }
-
- /// Reset the image view values.
- public func prepareForReuse() {
- stopAnimatingGIF()
- animator = nil
- }
-
- /// Update the current frame if needed.
- func updateFrameIfNeeded() {
- guard let animator = animator else { return }
- animator.shouldChangeFrame(with: displayLink.duration) { hasNewFrame in
- if hasNewFrame { self.layer.setNeedsDisplay() }
- }
- }
-
- /// Invalidate the displayLink so it releases its target.
- deinit {
- if displayLinkInitialized {
- displayLink.invalidate()
- }
- }
-
- /// Attaches the display link.
- func attachDisplayLink() {
- displayLink.add(to: .main, forMode: RunLoopMode.commonModes)
- }
-
- public override var intrinsicContentSize: CGSize {
- return image?.size ?? CGSize.zero
- }
-}
diff --git a/Source/AnimatedFrame.swift b/Source/AnimatedFrame.swift
index c89f6d1..c235000 100644
--- a/Source/AnimatedFrame.swift
+++ b/Source/AnimatedFrame.swift
@@ -1,8 +1,10 @@
-/// Keeps a reference to an `UIImage` instance and its duration as a GIF frame.
+/// Represents a single frame in a GIF.
struct AnimatedFrame {
- /// The image that should be used for this frame.
+
+ /// The image to display for this frame. Its value is nil when the frame is removed from the buffer.
let image: UIImage?
- /// The duration that the frame image should be displayed.
+
+ /// The duration that this frame should remain active.
let duration: TimeInterval
/// A placeholder frame with no image assigned.
@@ -11,15 +13,15 @@ struct AnimatedFrame {
return AnimatedFrame(image: nil, duration: duration)
}
- /// Whether the AnimatedFrame instance contains an image or not.
+ /// Whether this frame instance contains an image or not.
var isPlaceholder: Bool {
return image == .none
}
- /// Takes an optional image and returns an non-placeholder `AnimatedFrame`.
+ /// Returns a new instance from an ptional image.
///
/// - parameter image: An optional `UIImage` instance to be assigned to the new frame.
- /// - returns: A non-placeholder `AnimatedFrame` instance.
+ /// - returns: An `AnimatedFrame` instance.
func animatedFrame(with newImage: UIImage?) -> AnimatedFrame {
return AnimatedFrame(image: newImage, duration: duration)
}
diff --git a/Source/Animator.swift b/Source/Animator.swift
index 896ca1a..8fa3cdb 100644
--- a/Source/Animator.swift
+++ b/Source/Animator.swift
@@ -1,214 +1,154 @@
-import UIKit
-import ImageIO
-/// Responsible for storing and updating the frames of a `AnimatableImageView` instance via delegation.
-class Animator {
- /// Maximum duration to increment the frame timer with.
- let maxTimeStep = 1.0
- /// An array of animated frames from a single GIF image.
- var animatedFrames = [AnimatedFrame]()
- /// The size to resize all frames to
- let size: CGSize
- /// The content mode to use when resizing
- let contentMode: UIViewContentMode
- /// Maximum number of frames to load at once
- let preloadFrameCount: Int
- /// The total number of frames in the GIF.
- var frameCount = 0
- /// A reference to the original image source.
- var imageSource: CGImageSource
+/// Handles the GIF animation logic.
+public class Animator {
- /// The index of the current GIF frame.
- var currentFrameIndex = 0 {
- didSet {
- previousFrameIndex = oldValue
- }
- }
+ /// Number of frame to buffer.
+ public var frameBufferCount = 50
- /// The index of the previous GIF frame.
- var previousFrameIndex = 0 {
- didSet {
- preloadFrameQueue.async {
- self.updatePreloadedFrames()
- }
- }
- }
- /// Time elapsed since the last frame change. Used to determine when the frame should be updated.
- var timeSinceLastFrameChange: TimeInterval = 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: DispatchQueue = {
- return DispatchQueue(label: "co.kaishin.Gifu.preloadQueue")
+ /// Specifies whether GIF frames should be resized.
+ public var shouldResizeFrames = false
+
+ /// Responsible for loading individual frames and resizing them if necessary.
+ var frameStore: FrameStore?
+
+ /// Tracks whether the display link is initialized.
+ private var displayLinkInitialized: Bool = false
+
+ /// A delegate responsible for displaying the GIF frames.
+ private weak var delegate: AnimatorDelegate!
+
+ /// Responsible for starting and stopping the animation.
+ private lazy var displayLink: CADisplayLink = { [unowned self] in
+ self.displayLinkInitialized = true
+ let display = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.onScreenUpdate))
+ display.isPaused = true
+ return display
}()
- /// The current image frame to show.
- var currentFrameImage: UIImage? {
- return frame(at: currentFrameIndex)
+
+ /// Introspect whether the `displayLink` is paused.
+ var isAnimating: Bool {
+ return !displayLink.isPaused
}
- /// The current frame duration
- var currentFrameDuration: TimeInterval {
- return duration(at: currentFrameIndex)
+ /// Total frame count of the GIF.
+ var frameCount: Int {
+ return frameStore?.frameCount ?? 0
}
- /// Is this image animatable?
- var isAnimatable: Bool {
- return imageSource.isAnimatedGIF
- }
-
- /// Initializes an animator instance from raw GIF image data and an `Animatable` delegate.
+ /// Instantiates a new animator object with a delegate view.
///
- /// - parameter data: The raw GIF image data.
- /// - parameter delegate: An `Animatable` delegate.
- init(data: Data, size: CGSize, contentMode: UIViewContentMode, framePreloadCount: Int) {
- let options = [String(kCGImageSourceShouldCache): kCFBooleanFalse] as CFDictionary
- self.imageSource = CGImageSourceCreateWithData(data as CFData, options) ?? CGImageSourceCreateIncremental(options)
- self.size = size
- self.contentMode = contentMode
- self.preloadFrameCount = framePreloadCount
+ /// - parameter view: A view object that implements the `GIFAnimatable` protocol.
+ ///
+ /// - returns: A new animator instance.
+ public init(withDelegate delegate: AnimatorDelegate) {
+ self.delegate = delegate
}
- // MARK: - Frames
- /// Loads the frames from an image source, resizes them, then caches them in `animatedFrames`.
- func prepareFrames(_ completionHandler: ((Void) -> Void)? = .none) {
- frameCount = Int(CGImageSourceGetCount(imageSource))
- animatedFrames.reserveCapacity(frameCount)
- preloadFrameQueue.async {
- self.setupAnimatedFrames()
- if let handler = completionHandler { handler() }
+ /// Checks if there is a new frame to display.
+ fileprivate func updateFrameIfNeeded() {
+ guard let store = frameStore else { return }
+ store.shouldChangeFrame(with: displayLink.duration) {
+ if $0 { delegate.animatorHasNewFrame() }
}
}
- /// Returns the frame at a particular index.
+
+ /// Prepares the animator instance for animation.
///
- /// - parameter index: The index of the frame.
- /// - returns: An optional image at a given frame.
- func frame(at index: Int) -> UIImage? {
- return animatedFrames[safe: index]?.image
+ /// - parameter imageName: The file name of the GIF in the main bundle.
+ /// - parameter size: The target size of the individual frames.
+ /// - parameter contentMode: The view content mode to use for the individual frames.
+ func prepareForAnimation(withGIFNamed imageName: String, size: CGSize, contentMode: UIViewContentMode) {
+ guard let extensionRemoved = imageName.components(separatedBy: ".")[safe: 0],
+ let imagePath = Bundle.main.url(forResource: extensionRemoved, withExtension: "gif"),
+ let data = try? Data(contentsOf: imagePath) else { return }
+
+ prepareForAnimation(withGIFData: data, size: size, contentMode: contentMode)
}
- /// Returns the duration at a particular index.
+ /// Prepares the animator instance for animation.
///
- /// - parameter index: The index of the duration.
- /// - returns: The duration of the given frame.
- func duration(at index: Int) -> TimeInterval {
- return animatedFrames[safe: index]?.duration ?? TimeInterval.infinity
+ /// - parameter imageData: GIF image data.
+ /// - parameter size: The target size of the individual frames.
+ /// - parameter contentMode: The view content mode to use for the individual frames.
+ func prepareForAnimation(withGIFData imageData: Data, size: CGSize, contentMode: UIViewContentMode) {
+ frameStore = FrameStore(data: imageData, size: size, contentMode: contentMode, framePreloadCount: frameBufferCount)
+ frameStore?.shouldResizeFrames = shouldResizeFrames
+ frameStore?.prepareFrames()
+ attachDisplayLink()
}
- /// 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(with duration: CFTimeInterval, handler: (Bool) -> Void) {
- incrementTimeSinceLastFrameChange(with: duration)
- if currentFrameDuration > timeSinceLastFrameChange {
- handler(false)
- } else {
- resetTimeSinceLastFrameChange()
- incrementCurrentFrameIndex()
- handler(true)
+ /// Add the display link to the main run loop.
+ private func attachDisplayLink() {
+ displayLink.add(to: .main, forMode: RunLoopMode.commonModes)
+ }
+
+ deinit {
+ if displayLinkInitialized {
+ displayLink.invalidate()
}
}
+
+ /// Start animating.
+ func startAnimating() {
+ if frameStore?.isAnimatable ?? false {
+ displayLink.isPaused = false
+ }
+ }
+
+ /// Stop animating.
+ func stopAnimating() {
+ displayLink.isPaused = true
+ }
+
+ /// Prepare for animation and start animating immediately.
+ ///
+ /// - parameter imageName: The file name of the GIF in the main bundle.
+ /// - parameter size: The target size of the individual frames.
+ /// - parameter contentMode: The view content mode to use for the individual frames.
+ func animate(withGIFNamed imageName: String, size: CGSize, contentMode: UIViewContentMode) {
+ prepareForAnimation(withGIFNamed: imageName, size: size, contentMode: contentMode)
+ startAnimating()
+ }
+
+ /// Prepare for animation and start animating immediately.
+ ///
+ /// - parameter imageData: GIF image data.
+ /// - parameter size: The target size of the individual frames.
+ /// - parameter contentMode: The view content mode to use for the individual frames.
+ func animate(withGIFData data: Data, size: CGSize, contentMode: UIViewContentMode) {
+ prepareForAnimation(withGIFData: data, size: size, contentMode: contentMode)
+ startAnimating()
+ }
+
+ /// Stop animating and nullify the frame store.
+ func prepareForReuse() {
+ stopAnimating()
+ frameStore = nil
+ }
+
+ /// Gets the current image from the frame store.
+ ///
+ /// - returns: An optional frame image to display.
+ public func imageToDisplay() -> UIImage? {
+ return frameStore?.currentFrameImage
+ }
}
-private extension Animator {
- /// Whether preloading is needed or not.
- var preloadingIsNeeded: Bool {
- return preloadFrameCount < frameCount - 1
- }
+/// A proxy class to avoid a retain cycyle with the display link.
+fileprivate class DisplayLinkProxy {
- /// Optionally loads a single frame from an image source, resizes it if requierd, then returns an `UIImage`.
+ /// The target animator.
+ private weak var target: Animator?
+
+ /// Init with target animator.
///
- /// - parameter index: The index of the frame to load.
- /// - returns: An optional `UIImage` instance.
- func loadFrame(at index: Int) -> UIImage? {
- guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { return .none }
- let image = UIImage(cgImage: imageRef)
- let scaledImage: UIImage?
-
- if needsPrescaling {
- switch self.contentMode {
- case .scaleAspectFit: scaledImage = image.constrained(by: size)
- case .scaleAspectFill: scaledImage = image.filling(size: size)
- default: scaledImage = image.resized(to: size)
- }
- } else {
- scaledImage = image
- }
-
- return scaledImage
- }
-
- /// 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
-
- preloadIndexes(withStartingIndex: currentFrameIndex).forEach { index in
- let currentAnimatedFrame = animatedFrames[index]
- if !currentAnimatedFrame.isPlaceholder { return }
- animatedFrames[index] = currentAnimatedFrame.animatedFrame(with: loadFrame(at: index))
- }
- }
-
- /// Increments the `timeSinceLastFrameChange` property with a given duration.
+ /// - parameter target: An animator instance.
///
- /// - parameter duration: An `NSTimeInterval` value to increment the `timeSinceLastFrameChange` property with.
- func incrementTimeSinceLastFrameChange(with duration: TimeInterval) {
- timeSinceLastFrameChange += min(maxTimeStep, duration)
- }
+ /// - returns: A new proxy instance.
+ init(target: Animator) { self.target = target }
- /// Ensures that `timeSinceLastFrameChange` remains accurate after each frame change by substracting the `currentFrameDuration`.
- func resetTimeSinceLastFrameChange() {
- timeSinceLastFrameChange -= currentFrameDuration
- }
-
- /// Increments the `currentFrameIndex` property.
- func incrementCurrentFrameIndex() {
- currentFrameIndex = increment(index: 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 increment(index: Int, by 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 preloadIndexes(withStartingIndex index: Int) -> [Int] {
- let nextIndex = increment(index: index)
- let lastIndex = increment(index: index, by: preloadFrameCount)
-
- if lastIndex >= nextIndex {
- return [Int](nextIndex...lastIndex)
- } else {
- return [Int](nextIndex.. preloadFrameCount { return }
- animatedFrames[index] = animatedFrames[index].animatedFrame(with: loadFrame(at: index))
- }
- }
-
- /// Reset animated frames.
- func resetAnimatedFrames() {
- animatedFrames = []
- }
+ /// Lets the target update the frame if needed.
+ @objc func onScreenUpdate() { target?.updateFrameIfNeeded() }
}
diff --git a/Source/AnimatorDelegate.swift b/Source/AnimatorDelegate.swift
new file mode 100644
index 0000000..f05d294
--- /dev/null
+++ b/Source/AnimatorDelegate.swift
@@ -0,0 +1,4 @@
+/// The protocol that an animator delegate needs to conform to.
+public protocol AnimatorDelegate: class {
+ func animatorHasNewFrame()
+}
diff --git a/Source/FrameStore.swift b/Source/FrameStore.swift
new file mode 100644
index 0000000..99653de
--- /dev/null
+++ b/Source/FrameStore.swift
@@ -0,0 +1,224 @@
+import UIKit
+import ImageIO
+
+/// Responsible for storing and updating the frames of a single GIF.
+class FrameStore {
+
+ /// Maximum duration to increment the frame timer with.
+ let maxTimeStep = 1.0
+
+ /// An array of animated frames from a single GIF image.
+ var animatedFrames = [AnimatedFrame]()
+
+ /// The target size for all frames.
+ let size: CGSize
+
+ /// The content mode to use when resizing.
+ let contentMode: UIViewContentMode
+
+ /// Maximum number of frames to load at once
+ let bufferFrameCount: Int
+
+ /// The total number of frames in the GIF.
+ var frameCount = 0
+
+ /// A reference to the original image source.
+ var imageSource: CGImageSource
+
+ /// The index of the current GIF frame.
+ var currentFrameIndex = 0 {
+ didSet {
+ previousFrameIndex = oldValue
+ }
+ }
+
+ /// The index of the previous GIF frame.
+ var previousFrameIndex = 0 {
+ didSet {
+ preloadFrameQueue.async {
+ self.updatePreloadedFrames()
+ }
+ }
+ }
+
+ /// Time elapsed since the last frame change. Used to determine when the frame should be updated.
+ var timeSinceLastFrameChange: TimeInterval = 0.0
+
+ /// Specifies whether GIF frames should be resized.
+ var shouldResizeFrames = true
+
+ /// Dispatch queue used for preloading images.
+ private lazy var preloadFrameQueue: DispatchQueue = {
+ return DispatchQueue(label: "co.kaishin.Gifu.preloadQueue")
+ }()
+
+ /// The current image frame to show.
+ var currentFrameImage: UIImage? {
+ return frame(at: currentFrameIndex)
+ }
+
+ /// The current frame duration
+ var currentFrameDuration: TimeInterval {
+ return duration(at: currentFrameIndex)
+ }
+
+ /// Is this image animatable?
+ var isAnimatable: Bool {
+ return imageSource.isAnimatedGIF
+ }
+
+ /// Initializes an animator instance from raw GIF image data and an `Animatable` delegate.
+ ///
+ /// - parameter data: The raw GIF image data.
+ /// - parameter delegate: An `Animatable` delegate.
+ init(data: Data, size: CGSize, contentMode: UIViewContentMode, framePreloadCount: Int) {
+ let options = [String(kCGImageSourceShouldCache): kCFBooleanFalse] as CFDictionary
+ self.imageSource = CGImageSourceCreateWithData(data as CFData, options) ?? CGImageSourceCreateIncremental(options)
+ self.size = size
+ self.contentMode = contentMode
+ self.bufferFrameCount = framePreloadCount
+ }
+
+ // MARK: - Frames
+ /// Loads the frames from an image source, resizes them, then caches them in `animatedFrames`.
+ func prepareFrames(_ completionHandler: ((Void) -> Void)? = .none) {
+ frameCount = Int(CGImageSourceGetCount(imageSource))
+ animatedFrames.reserveCapacity(frameCount)
+ preloadFrameQueue.async {
+ self.setupAnimatedFrames()
+ if let handler = completionHandler { handler() }
+ }
+ }
+
+ /// Returns the frame at a particular index.
+ ///
+ /// - parameter index: The index of the frame.
+ /// - returns: An optional image at a given frame.
+ func frame(at index: Int) -> UIImage? {
+ return animatedFrames[safe: index]?.image
+ }
+
+ /// Returns the duration at a particular index.
+ ///
+ /// - parameter index: The index of the duration.
+ /// - returns: The duration of the given frame.
+ func duration(at index: Int) -> TimeInterval {
+ return animatedFrames[safe: index]?.duration ?? TimeInterval.infinity
+ }
+
+ /// 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(with duration: CFTimeInterval, handler: (Bool) -> Void) {
+ incrementTimeSinceLastFrameChange(with: duration)
+
+ if currentFrameDuration > timeSinceLastFrameChange {
+ handler(false)
+ } else {
+ resetTimeSinceLastFrameChange()
+ incrementCurrentFrameIndex()
+ handler(true)
+ }
+ }
+}
+
+private extension FrameStore {
+ /// Whether preloading is needed or not.
+ var preloadingIsNeeded: Bool {
+ return bufferFrameCount < frameCount - 1
+ }
+
+ /// Optionally loads a single frame from an image source, resizes it if required, then returns an `UIImage`.
+ ///
+ /// - parameter index: The index of the frame to load.
+ /// - returns: An optional `UIImage` instance.
+ func loadFrame(at index: Int) -> UIImage? {
+ guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { return .none }
+ let image = UIImage(cgImage: imageRef)
+ let scaledImage: UIImage?
+
+ if shouldResizeFrames {
+ switch self.contentMode {
+ case .scaleAspectFit: scaledImage = image.constrained(by: size)
+ case .scaleAspectFill: scaledImage = image.filling(size: size)
+ default: scaledImage = image.resized(to: size)
+ }
+ } else {
+ scaledImage = image
+ }
+
+ return scaledImage
+ }
+
+ /// 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
+
+ preloadIndexes(withStartingIndex: currentFrameIndex).forEach { index in
+ let currentAnimatedFrame = animatedFrames[index]
+ if !currentAnimatedFrame.isPlaceholder { return }
+ animatedFrames[index] = currentAnimatedFrame.animatedFrame(with: loadFrame(at: index))
+ }
+ }
+
+ /// Increments the `timeSinceLastFrameChange` property with a given duration.
+ ///
+ /// - parameter duration: An `NSTimeInterval` value to increment the `timeSinceLastFrameChange` property with.
+ func incrementTimeSinceLastFrameChange(with duration: TimeInterval) {
+ 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 = increment(index: 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 increment(index: Int, by 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 preloadIndexes(withStartingIndex index: Int) -> [Int] {
+ let nextIndex = increment(index: index)
+ let lastIndex = increment(index: index, by: bufferFrameCount)
+
+ if lastIndex >= nextIndex {
+ return [Int](nextIndex...lastIndex)
+ } else {
+ return [Int](nextIndex.. bufferFrameCount { return }
+ animatedFrames[index] = animatedFrames[index].animatedFrame(with: loadFrame(at: index))
+ }
+ }
+
+ /// Reset animated frames.
+ func resetAnimatedFrames() {
+ animatedFrames = []
+ }
+}
diff --git a/Source/GIFAnimatable.swift b/Source/GIFAnimatable.swift
new file mode 100644
index 0000000..acf0e37
--- /dev/null
+++ b/Source/GIFAnimatable.swift
@@ -0,0 +1,91 @@
+/// The protocol that view classes need to conform to to enable animated GIF support.
+public protocol GIFAnimatable: class, AnimatorDelegate, CALayerDelegate {
+
+ /// Responsible for managing the animation frames.
+ var animator: Animator? { get set }
+
+ /// Used for displaying the animation frames.
+ var image: UIImage? { get set }
+
+ /// Notifies the instance that it needs display.
+ var layer: CALayer { get }
+
+ /// View frame used for resizing the frames.
+ var frame: CGRect { get set }
+
+ /// Content mode used for resizing the frames.
+ var contentMode: UIViewContentMode { get set }
+
+ /// Implement this method and call `updateImageIfNeeded` from within it in your conforming class.
+ ///
+ /// - parameter layer:
+ func display(_ layer: CALayer)
+
+ /// Needs to be called whenever the conforming class needs to check if it needs to update the current frame being displayed.
+ func updateImageIfNeeded()
+}
+extension GIFAnimatable {
+ /// Returns the intrinsic content size based on the size of the image.
+ public var intrinsicContentSize: CGSize {
+ return image?.size ?? CGSize.zero
+ }
+
+ /// Prepare for animation and start animating immediately.
+ ///
+ /// - parameter imageName: The file name of the GIF in the main bundle.
+ public func animate(withGIFNamed imageName: String) {
+ animator?.animate(withGIFNamed: imageName, size: frame.size, contentMode: contentMode)
+ }
+
+ /// Introspect whether the instance is animating.
+ public var isAnimatingGIF: Bool {
+ return animator?.isAnimating ?? false
+ }
+
+ /// Prepares the animator instance for animation.
+ ///
+ /// - parameter imageName: The file name of the GIF in the main bundle.
+ public func prepareForAnimation(withGIFNamed imageName: String) {
+ animator?.prepareForAnimation(withGIFNamed: imageName, size: frame.size, contentMode: contentMode)
+ }
+
+ /// Prepare for animation and start animating immediately.
+ ///
+ /// - parameter imageData: GIF image data.
+ public func prepareForAnimation(withGIFData imageData: Data) {
+ image = UIImage(data: imageData)
+ animator?.prepareForAnimation(withGIFData: imageData, size: frame.size, contentMode: contentMode)
+ }
+
+ /// Total frame count of the GIF.
+ public var frameCount: Int {
+ return animator?.frameCount ?? 0
+ }
+
+ /// Stop animating and free up GIF data from memory.
+ public func prepareForReuse() {
+ animator?.prepareForReuse()
+ }
+
+ /// Start animating GIF.
+ public func startAnimatingGIF() {
+ animator?.startAnimating()
+ }
+
+ /// Stop animating GIF.
+ public func stopAnimatingGIF() {
+ animator?.stopAnimating()
+ }
+
+ /// Updates the image with a new frame if necessary.
+ public func updateImageIfNeeded() {
+ image = animator?.imageToDisplay() ?? image
+ }
+}
+
+extension GIFAnimatable {
+ /// Calls setNeedsDisplay on the layer whenever the animator has a new frame. Should *not* be called directly.
+ public func animatorHasNewFrame() {
+ layer.setNeedsDisplay()
+ }
+}
diff --git a/Source/GIFImageView.swift b/Source/GIFImageView.swift
new file mode 100644
index 0000000..20995fd
--- /dev/null
+++ b/Source/GIFImageView.swift
@@ -0,0 +1,16 @@
+import UIKit
+
+
+/// Example class that conforms to `GIFAnimatable`. Uses default values for the animator frame buffer count and resize behavior.
+public class GIFImageView: UIImageView, GIFAnimatable {
+ public var animator: Animator?
+
+ required public init?(coder aDecoder: NSCoder) {
+ super.init(coder: aDecoder)
+ animator = Animator(withDelegate: self)
+ }
+
+ override public func display(_ layer: CALayer) {
+ updateImageIfNeeded()
+ }
+}
diff --git a/Source/Info.plist b/Source/Info.plist
index bfdd68e..f1d9635 100644
--- a/Source/Info.plist
+++ b/Source/Info.plist
@@ -19,7 +19,7 @@
CFBundleSignature
????
CFBundleVersion
- 102
+ 118
NSPrincipalClass