From 4f4388e364fc1c718bb29ae370a998caffee7bbc Mon Sep 17 00:00:00 2001 From: Reda Lemeden Date: Mon, 26 Sep 2016 23:01:27 +0200 Subject: [PATCH] Rewrite the API to use protocols - Closes #69 - Closes #61 - Closes #22 --- Demo/demo/Main.storyboard | 2 +- Demo/demo/classes/ViewController.swift | 3 +- Gifu.xcodeproj/project.pbxproj | 28 ++- GifuTests/GifuTests.swift | 46 ++-- Source/AnimatableImageView.swift | 134 ----------- Source/AnimatedFrame.swift | 14 +- Source/Animator.swift | 308 ++++++++++--------------- Source/AnimatorDelegate.swift | 4 + Source/FrameStore.swift | 224 ++++++++++++++++++ Source/GIFAnimatable.swift | 91 ++++++++ Source/GIFImageView.swift | 16 ++ Source/Info.plist | 2 +- 12 files changed, 520 insertions(+), 352 deletions(-) delete mode 100644 Source/AnimatableImageView.swift create mode 100644 Source/AnimatorDelegate.swift create mode 100644 Source/FrameStore.swift create mode 100644 Source/GIFAnimatable.swift create mode 100644 Source/GIFImageView.swift 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