Rewrite the API to use protocols
- Closes #69 - Closes #61 - Closes #22
This commit is contained in:
parent
d2e34a067d
commit
4f4388e364
|
@ -23,7 +23,7 @@
|
||||||
<color key="textColor" red="0.99144423007965088" green="0.56549066305160522" blue="0.033751130104064941" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="textColor" red="0.99144423007965088" green="0.56549066305160522" blue="0.033751130104064941" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" placeholderIntrinsicWidth="600" placeholderIntrinsicHeight="300" translatesAutoresizingMaskIntoConstraints="NO" id="FSz-xF-Xds" customClass="AnimatableImageView" customModule="Gifu">
|
<imageView clipsSubviews="YES" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" placeholderIntrinsicWidth="600" placeholderIntrinsicHeight="300" translatesAutoresizingMaskIntoConstraints="NO" id="FSz-xF-Xds" customClass="GIFImageView" customModule="Gifu">
|
||||||
<gestureRecognizers/>
|
<gestureRecognizers/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="width" secondItem="FSz-xF-Xds" secondAttribute="height" multiplier="5:4" id="EOH-hn-KxM"/>
|
<constraint firstAttribute="width" secondItem="FSz-xF-Xds" secondAttribute="height" multiplier="5:4" id="EOH-hn-KxM"/>
|
||||||
|
|
|
@ -2,7 +2,7 @@ import UIKit
|
||||||
import Gifu
|
import Gifu
|
||||||
|
|
||||||
class ViewController: UIViewController {
|
class ViewController: UIViewController {
|
||||||
@IBOutlet weak var imageView: AnimatableImageView!
|
@IBOutlet weak var imageView: GIFImageView!
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
@ -17,4 +17,3 @@ class ViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,14 +9,17 @@
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0036ABB71BBD1D0B00C6CC3D /* mugen.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0036ABB61BBD1D0B00C6CC3D /* mugen.gif */; };
|
0036ABB71BBD1D0B00C6CC3D /* mugen.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0036ABB61BBD1D0B00C6CC3D /* mugen.gif */; };
|
||||||
0036ABB91BBD1D1400C6CC3D /* nailed.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0036ABB81BBD1D1400C6CC3D /* nailed.gif */; };
|
0036ABB91BBD1D1400C6CC3D /* nailed.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0036ABB81BBD1D1400C6CC3D /* nailed.gif */; };
|
||||||
005656ED1A6F14D6008A0ED1 /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005656EC1A6F14D6008A0ED1 /* Animator.swift */; };
|
005656ED1A6F14D6008A0ED1 /* FrameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005656EC1A6F14D6008A0ED1 /* FrameStore.swift */; };
|
||||||
005656EF1A6F1C26008A0ED1 /* AnimatableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005656EE1A6F1C26008A0ED1 /* AnimatableImageView.swift */; };
|
005656EF1A6F1C26008A0ED1 /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005656EE1A6F1C26008A0ED1 /* GIFImageView.swift */; };
|
||||||
007E08441BD95E6200883D0C /* ArrayExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007E08431BD95E6200883D0C /* ArrayExtension.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 */; };
|
009BD1391BBC7F6500FC982B /* GifuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009BD1381BBC7F6500FC982B /* GifuTests.swift */; };
|
||||||
009BD13B1BBC7F6500FC982B /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 00B8C73E1A364DA400C188E7 /* Gifu.framework */; };
|
009BD13B1BBC7F6500FC982B /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 00B8C73E1A364DA400C188E7 /* Gifu.framework */; };
|
||||||
009BD1441BBC93C800FC982B /* CGSizeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009BD1431BBC93C800FC982B /* CGSizeExtension.swift */; };
|
009BD1441BBC93C800FC982B /* CGSizeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 009BD1431BBC93C800FC982B /* CGSizeExtension.swift */; };
|
||||||
00B8C75F1A364DCE00C188E7 /* ImageSourceHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B8C75C1A364DCE00C188E7 /* ImageSourceHelpers.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, ); }; };
|
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 */; };
|
EAF49C7F1A3A4DE000B395DF /* UIImageExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF49C7E1A3A4DE000B395DF /* UIImageExtension.swift */; };
|
||||||
EAF49CB11A3B6EEB00B395DF /* AnimatedFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */; };
|
EAF49CB11A3B6EEB00B395DF /* AnimatedFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
@ -34,9 +37,11 @@
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
0036ABB61BBD1D0B00C6CC3D /* mugen.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; name = mugen.gif; path = Demo/GIFs/mugen.gif; sourceTree = SOURCE_ROOT; };
|
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; };
|
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 = "<group>"; };
|
005656EC1A6F14D6008A0ED1 /* FrameStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameStore.swift; sourceTree = "<group>"; };
|
||||||
005656EE1A6F1C26008A0ED1 /* AnimatableImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatableImageView.swift; sourceTree = "<group>"; };
|
005656EE1A6F1C26008A0ED1 /* GIFImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
|
||||||
007E08431BD95E6200883D0C /* ArrayExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayExtension.swift; sourceTree = "<group>"; };
|
007E08431BD95E6200883D0C /* ArrayExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayExtension.swift; sourceTree = "<group>"; };
|
||||||
|
00978B6B1D9C6D2A00A6575F /* Animator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animator.swift; sourceTree = "<group>"; };
|
||||||
|
00978B6D1D9FA99D00A6575F /* AnimatorDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatorDelegate.swift; sourceTree = "<group>"; };
|
||||||
009BD1361BBC7F6500FC982B /* GifuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GifuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
009BD1381BBC7F6500FC982B /* GifuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifuTests.swift; sourceTree = "<group>"; };
|
||||||
009BD13A1BBC7F6500FC982B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
009BD13A1BBC7F6500FC982B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
@ -45,6 +50,7 @@
|
||||||
00B8C7421A364DA400C188E7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../Source/Info.plist; sourceTree = "<group>"; };
|
00B8C7421A364DA400C188E7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../Source/Info.plist; sourceTree = "<group>"; };
|
||||||
00B8C75C1A364DCE00C188E7 /* ImageSourceHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageSourceHelpers.swift; sourceTree = "<group>"; };
|
00B8C75C1A364DCE00C188E7 /* ImageSourceHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageSourceHelpers.swift; sourceTree = "<group>"; };
|
||||||
00B8C7951A3650EE00C188E7 /* Gifu.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Gifu.h; sourceTree = "<group>"; };
|
00B8C7951A3650EE00C188E7 /* Gifu.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Gifu.h; sourceTree = "<group>"; };
|
||||||
|
00BF42CB1D99A1DC00C6F28D /* GIFAnimatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GIFAnimatable.swift; sourceTree = "<group>"; };
|
||||||
EAF49C7E1A3A4DE000B395DF /* UIImageExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIImageExtension.swift; sourceTree = "<group>"; };
|
EAF49C7E1A3A4DE000B395DF /* UIImageExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIImageExtension.swift; sourceTree = "<group>"; };
|
||||||
EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedFrame.swift; sourceTree = "<group>"; };
|
EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedFrame.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
@ -98,9 +104,12 @@
|
||||||
007E08421BD95C6100883D0C /* Classes */ = {
|
007E08421BD95C6100883D0C /* Classes */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
005656EE1A6F1C26008A0ED1 /* AnimatableImageView.swift */,
|
|
||||||
EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */,
|
EAF49CB01A3B6EEB00B395DF /* AnimatedFrame.swift */,
|
||||||
005656EC1A6F14D6008A0ED1 /* Animator.swift */,
|
00978B6B1D9C6D2A00A6575F /* Animator.swift */,
|
||||||
|
005656EC1A6F14D6008A0ED1 /* FrameStore.swift */,
|
||||||
|
00BF42CB1D99A1DC00C6F28D /* GIFAnimatable.swift */,
|
||||||
|
005656EE1A6F1C26008A0ED1 /* GIFImageView.swift */,
|
||||||
|
00978B6D1D9FA99D00A6575F /* AnimatorDelegate.swift */,
|
||||||
);
|
);
|
||||||
name = Classes;
|
name = Classes;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -293,11 +302,14 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
005656EF1A6F1C26008A0ED1 /* AnimatableImageView.swift in Sources */,
|
005656EF1A6F1C26008A0ED1 /* GIFImageView.swift in Sources */,
|
||||||
005656ED1A6F14D6008A0ED1 /* Animator.swift in Sources */,
|
00978B6E1D9FA99D00A6575F /* AnimatorDelegate.swift in Sources */,
|
||||||
|
00BF42CC1D99A1DC00C6F28D /* GIFAnimatable.swift in Sources */,
|
||||||
|
005656ED1A6F14D6008A0ED1 /* FrameStore.swift in Sources */,
|
||||||
009BD1441BBC93C800FC982B /* CGSizeExtension.swift in Sources */,
|
009BD1441BBC93C800FC982B /* CGSizeExtension.swift in Sources */,
|
||||||
EAF49CB11A3B6EEB00B395DF /* AnimatedFrame.swift in Sources */,
|
EAF49CB11A3B6EEB00B395DF /* AnimatedFrame.swift in Sources */,
|
||||||
00B8C75F1A364DCE00C188E7 /* ImageSourceHelpers.swift in Sources */,
|
00B8C75F1A364DCE00C188E7 /* ImageSourceHelpers.swift in Sources */,
|
||||||
|
00978B6C1D9C6D2A00A6575F /* Animator.swift in Sources */,
|
||||||
007E08441BD95E6200883D0C /* ArrayExtension.swift in Sources */,
|
007E08441BD95E6200883D0C /* ArrayExtension.swift in Sources */,
|
||||||
EAF49C7F1A3A4DE000B395DF /* UIImageExtension.swift in Sources */,
|
EAF49C7F1A3A4DE000B395DF /* UIImageExtension.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,39 +6,50 @@ private let imageData = testImageDataNamed("mugen.gif")
|
||||||
private let staticImage = UIImage(data: imageData)!
|
private let staticImage = UIImage(data: imageData)!
|
||||||
private let preloadFrameCount = 20
|
private let preloadFrameCount = 20
|
||||||
|
|
||||||
|
class DummyAnimatorDelegate: AnimatorDelegate {
|
||||||
|
func animatorHasNewFrame() { }
|
||||||
|
}
|
||||||
|
|
||||||
class GifuTests: XCTestCase {
|
class GifuTests: XCTestCase {
|
||||||
var animator: Animator!
|
var animator: Animator!
|
||||||
var originalFrameCount: Int!
|
var originalFrameCount: Int!
|
||||||
|
let delegate = DummyAnimatorDelegate()
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
animator = Animator(data: imageData, size: CGSize.zero, contentMode: .scaleToFill, framePreloadCount: preloadFrameCount)
|
animator = Animator(withDelegate: delegate)
|
||||||
originalFrameCount = Int(CGImageSourceGetCount(animator.imageSource))
|
animator.prepareForAnimation(withGIFData: imageData, size: staticImage.size, contentMode: .scaleToFill)
|
||||||
|
originalFrameCount = 44
|
||||||
}
|
}
|
||||||
|
|
||||||
func testIsAnimatable() {
|
func testIsAnimatable() {
|
||||||
XCTAssertTrue(animator.isAnimatable)
|
XCTAssertNotNil(animator.frameStore)
|
||||||
|
guard let store = animator.frameStore else { return }
|
||||||
|
XCTAssertTrue(store.isAnimatable)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCurrentFrame() {
|
func testCurrentFrame() {
|
||||||
XCTAssertEqual(animator.currentFrameIndex, 0)
|
XCTAssertNotNil(animator.frameStore)
|
||||||
XCTAssertEqual(animator.currentFrameDuration, TimeInterval.infinity)
|
guard let store = animator.frameStore else { return }
|
||||||
XCTAssertNil(animator.currentFrameImage)
|
XCTAssertEqual(store.currentFrameIndex, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFramePreload() {
|
func testFramePreload() {
|
||||||
|
XCTAssertNotNil(animator.frameStore)
|
||||||
|
guard let store = animator.frameStore else { return }
|
||||||
|
|
||||||
let expectation = self.expectation(description: "frameDuration")
|
let expectation = self.expectation(description: "frameDuration")
|
||||||
|
|
||||||
animator.prepareFrames {
|
store.prepareFrames {
|
||||||
let animatedFrameCount = self.animator.animatedFrames.count
|
let animatedFrameCount = store.animatedFrames.count
|
||||||
XCTAssertEqual(animatedFrameCount, self.originalFrameCount)
|
XCTAssertEqual(animatedFrameCount, self.originalFrameCount)
|
||||||
XCTAssertNotNil(self.animator.frame(at: preloadFrameCount - 1))
|
XCTAssertNotNil(store.frame(at: preloadFrameCount - 1))
|
||||||
XCTAssertNil(self.animator.frame(at: preloadFrameCount + 1)?.images)
|
XCTAssertNil(store.frame(at: preloadFrameCount + 1)?.images)
|
||||||
XCTAssertEqual(self.animator.currentFrameIndex, 0)
|
XCTAssertEqual(store.currentFrameIndex, 0)
|
||||||
|
|
||||||
self.animator.shouldChangeFrame(with: 1.0) { hasNewFrame in
|
store.shouldChangeFrame(with: 1.0) { hasNewFrame in
|
||||||
XCTAssertTrue(hasNewFrame)
|
XCTAssertTrue(hasNewFrame)
|
||||||
XCTAssertEqual(self.animator.currentFrameIndex, 1)
|
XCTAssertEqual(store.currentFrameIndex, 1)
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -51,13 +62,16 @@ class GifuTests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFrameInfo() {
|
func testFrameInfo() {
|
||||||
|
XCTAssertNotNil(animator.frameStore)
|
||||||
|
guard let store = animator.frameStore else { return }
|
||||||
|
|
||||||
let expectation = self.expectation(description: "testFrameInfoIsAccurate")
|
let expectation = self.expectation(description: "testFrameInfoIsAccurate")
|
||||||
|
|
||||||
animator.prepareFrames {
|
store.prepareFrames {
|
||||||
let frameDuration = self.animator.frame(at: 5)?.duration ?? 0
|
let frameDuration = store.frame(at: 5)?.duration ?? 0
|
||||||
XCTAssertTrue((frameDuration - 0.05) < 0.00001)
|
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)
|
XCTAssertEqual(imageSize, staticImage.size)
|
||||||
|
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 {
|
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?
|
let image: UIImage?
|
||||||
/// The duration that the frame image should be displayed.
|
|
||||||
|
/// The duration that this frame should remain active.
|
||||||
let duration: TimeInterval
|
let duration: TimeInterval
|
||||||
|
|
||||||
/// A placeholder frame with no image assigned.
|
/// A placeholder frame with no image assigned.
|
||||||
|
@ -11,15 +13,15 @@ struct AnimatedFrame {
|
||||||
return AnimatedFrame(image: nil, duration: duration)
|
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 {
|
var isPlaceholder: Bool {
|
||||||
return image == .none
|
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.
|
/// - 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 {
|
func animatedFrame(with newImage: UIImage?) -> AnimatedFrame {
|
||||||
return AnimatedFrame(image: newImage, duration: duration)
|
return AnimatedFrame(image: newImage, duration: duration)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,214 +1,154 @@
|
||||||
import UIKit
|
|
||||||
import ImageIO
|
|
||||||
|
|
||||||
/// Responsible for storing and updating the frames of a `AnimatableImageView` instance via delegation.
|
/// Handles the GIF animation logic.
|
||||||
class Animator {
|
public 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
|
|
||||||
|
|
||||||
/// The index of the current GIF frame.
|
/// Number of frame to buffer.
|
||||||
var currentFrameIndex = 0 {
|
public var frameBufferCount = 50
|
||||||
didSet {
|
|
||||||
previousFrameIndex = oldValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The index of the previous GIF frame.
|
/// Specifies whether GIF frames should be resized.
|
||||||
var previousFrameIndex = 0 {
|
public var shouldResizeFrames = false
|
||||||
didSet {
|
|
||||||
preloadFrameQueue.async {
|
/// Responsible for loading individual frames and resizing them if necessary.
|
||||||
self.updatePreloadedFrames()
|
var frameStore: FrameStore?
|
||||||
}
|
|
||||||
}
|
/// Tracks whether the display link is initialized.
|
||||||
}
|
private var displayLinkInitialized: Bool = false
|
||||||
/// Time elapsed since the last frame change. Used to determine when the frame should be updated.
|
|
||||||
var timeSinceLastFrameChange: TimeInterval = 0.0
|
/// A delegate responsible for displaying the GIF frames.
|
||||||
/// Specifies whether GIF frames should be pre-scaled.
|
private weak var delegate: AnimatorDelegate!
|
||||||
/// - seealso: `needsPrescaling` in AnimatableImageView.
|
|
||||||
var needsPrescaling = true
|
/// Responsible for starting and stopping the animation.
|
||||||
/// Dispatch queue used for preloading images.
|
private lazy var displayLink: CADisplayLink = { [unowned self] in
|
||||||
private lazy var preloadFrameQueue: DispatchQueue = {
|
self.displayLinkInitialized = true
|
||||||
return DispatchQueue(label: "co.kaishin.Gifu.preloadQueue")
|
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? {
|
/// Introspect whether the `displayLink` is paused.
|
||||||
return frame(at: currentFrameIndex)
|
var isAnimating: Bool {
|
||||||
|
return !displayLink.isPaused
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current frame duration
|
/// Total frame count of the GIF.
|
||||||
var currentFrameDuration: TimeInterval {
|
var frameCount: Int {
|
||||||
return duration(at: currentFrameIndex)
|
return frameStore?.frameCount ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is this image animatable?
|
/// Instantiates a new animator object with a delegate view.
|
||||||
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 view: A view object that implements the `GIFAnimatable` protocol.
|
||||||
/// - parameter delegate: An `Animatable` delegate.
|
///
|
||||||
init(data: Data, size: CGSize, contentMode: UIViewContentMode, framePreloadCount: Int) {
|
/// - returns: A new animator instance.
|
||||||
let options = [String(kCGImageSourceShouldCache): kCFBooleanFalse] as CFDictionary
|
public init(withDelegate delegate: AnimatorDelegate) {
|
||||||
self.imageSource = CGImageSourceCreateWithData(data as CFData, options) ?? CGImageSourceCreateIncremental(options)
|
self.delegate = delegate
|
||||||
self.size = size
|
|
||||||
self.contentMode = contentMode
|
|
||||||
self.preloadFrameCount = framePreloadCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Frames
|
/// Checks if there is a new frame to display.
|
||||||
/// Loads the frames from an image source, resizes them, then caches them in `animatedFrames`.
|
fileprivate func updateFrameIfNeeded() {
|
||||||
func prepareFrames(_ completionHandler: ((Void) -> Void)? = .none) {
|
guard let store = frameStore else { return }
|
||||||
frameCount = Int(CGImageSourceGetCount(imageSource))
|
store.shouldChangeFrame(with: displayLink.duration) {
|
||||||
animatedFrames.reserveCapacity(frameCount)
|
if $0 { delegate.animatorHasNewFrame() }
|
||||||
preloadFrameQueue.async {
|
|
||||||
self.setupAnimatedFrames()
|
|
||||||
if let handler = completionHandler { handler() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the frame at a particular index.
|
|
||||||
|
/// Prepares the animator instance for animation.
|
||||||
///
|
///
|
||||||
/// - parameter index: The index of the frame.
|
/// - parameter imageName: The file name of the GIF in the main bundle.
|
||||||
/// - returns: An optional image at a given frame.
|
/// - parameter size: The target size of the individual frames.
|
||||||
func frame(at index: Int) -> UIImage? {
|
/// - parameter contentMode: The view content mode to use for the individual frames.
|
||||||
return animatedFrames[safe: index]?.image
|
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.
|
/// - parameter imageData: GIF image data.
|
||||||
/// - returns: The duration of the given frame.
|
/// - parameter size: The target size of the individual frames.
|
||||||
func duration(at index: Int) -> TimeInterval {
|
/// - parameter contentMode: The view content mode to use for the individual frames.
|
||||||
return animatedFrames[safe: index]?.duration ?? TimeInterval.infinity
|
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 {
|
/// Add the display link to the main run loop.
|
||||||
handler(false)
|
private func attachDisplayLink() {
|
||||||
} else {
|
displayLink.add(to: .main, forMode: RunLoopMode.commonModes)
|
||||||
resetTimeSinceLastFrameChange()
|
}
|
||||||
incrementCurrentFrameIndex()
|
|
||||||
handler(true)
|
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 {
|
/// A proxy class to avoid a retain cycyle with the display link.
|
||||||
/// Whether preloading is needed or not.
|
fileprivate class DisplayLinkProxy {
|
||||||
var preloadingIsNeeded: Bool {
|
|
||||||
return preloadFrameCount < frameCount - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
/// - parameter target: An animator instance.
|
||||||
/// - 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 duration: An `NSTimeInterval` value to increment the `timeSinceLastFrameChange` property with.
|
/// - returns: A new proxy instance.
|
||||||
func incrementTimeSinceLastFrameChange(with duration: TimeInterval) {
|
init(target: Animator) { self.target = target }
|
||||||
timeSinceLastFrameChange += min(maxTimeStep, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensures that `timeSinceLastFrameChange` remains accurate after each frame change by substracting the `currentFrameDuration`.
|
/// Lets the target update the frame if needed.
|
||||||
func resetTimeSinceLastFrameChange() {
|
@objc func onScreenUpdate() { target?.updateFrameIfNeeded() }
|
||||||
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..<frameCount) + [Int](0...lastIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up animated frames after resetting them if necessary.
|
|
||||||
func setupAnimatedFrames() {
|
|
||||||
resetAnimatedFrames()
|
|
||||||
|
|
||||||
(0..<frameCount).forEach { index in
|
|
||||||
let frameDuration = CGImageFrameDuration(with: imageSource, atIndex: index)
|
|
||||||
animatedFrames += [AnimatedFrame(image: .none, duration: frameDuration)]
|
|
||||||
|
|
||||||
if index > preloadFrameCount { return }
|
|
||||||
animatedFrames[index] = animatedFrames[index].animatedFrame(with: loadFrame(at: index))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset animated frames.
|
|
||||||
func resetAnimatedFrames() {
|
|
||||||
animatedFrames = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
/// The protocol that an animator delegate needs to conform to.
|
||||||
|
public protocol AnimatorDelegate: class {
|
||||||
|
func animatorHasNewFrame()
|
||||||
|
}
|
|
@ -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..<frameCount) + [Int](0...lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up animated frames after resetting them if necessary.
|
||||||
|
func setupAnimatedFrames() {
|
||||||
|
resetAnimatedFrames()
|
||||||
|
|
||||||
|
(0..<frameCount).forEach { index in
|
||||||
|
let frameDuration = CGImageFrameDuration(with: imageSource, atIndex: index)
|
||||||
|
animatedFrames += [AnimatedFrame(image: .none, duration: frameDuration)]
|
||||||
|
|
||||||
|
if index > bufferFrameCount { return }
|
||||||
|
animatedFrames[index] = animatedFrames[index].animatedFrame(with: loadFrame(at: index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset animated frames.
|
||||||
|
func resetAnimatedFrames() {
|
||||||
|
animatedFrames = []
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,7 +19,7 @@
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>102</string>
|
<string>118</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string></string>
|
<string></string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
Loading…
Reference in New Issue