Skip to content

Commit

Permalink
Add loopBlock callback for when a loop is finished (#183)
Browse files Browse the repository at this point in the history
It's called every time the last frame is shown, on every loop. This is
different to `animationBlock` which is only called after all the loops
have finished (and never for infinite loops).

Why? I have a use case where I'm playing a GIF in an infinite loop but I
want to know when it has been shown at least once (first loop has been
completed).

Currently, there's no good way to satisfy this use case. One way is to
read `loopDuration` and then schedule a timer to fire when that time has
elapsed, which is roughly when one loop has happened. But in addition to
being inaccurate, it's very fiddly because the timer has to be
paused/unpaused if the GIF is paused/unpaused.

Another way you might think would work is to use `loopCount: 1` instead
and then use `animationBlock`. Then there just start playback again for
the rest of the loops. The problem with that is that there's no way to
restart without preparing the frames again AFAICT.

With this new callback, it's very straightforward.
  • Loading branch information
robinst authored Feb 3, 2023
1 parent 74cae1b commit c8c8cd8
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 21 deletions.
2 changes: 2 additions & 0 deletions Demo/Demo-iOS/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class ViewController: UIViewController {
DispatchQueue.main.async {
self.imageDataLabel.text = self.currentGIFName.capitalized + " (\(self.imageView.frameCount) frames / \(String(format: "%.2f", self.imageView.gifLoopDuration))s)"
}
}, loopBlock: {
print("Loop finished")
})
}
}
27 changes: 21 additions & 6 deletions Sources/Gifu/Classes/Animator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ public class Animator {

/// A delegate responsible for displaying the GIF frames.
private weak var delegate: GIFAnimatable!


/// Callback for when all the loops of the animation are done (never called for infinite loops)
private var animationBlock: (() -> Void)? = nil

/// Callback for when a loop is done (at the end of each loop)
private var loopBlock: (() -> Void)? = nil

/// Responsible for starting and stopping the animation.
private lazy var displayLink: CADisplayLink = { [unowned self] in
self.displayLinkInitialized = true
Expand Down Expand Up @@ -65,7 +69,12 @@ public class Animator {
}

store.shouldChangeFrame(with: displayLink.duration) {
if $0 { delegate.animatorHasNewFrame() }
if $0 {
delegate.animatorHasNewFrame()
if store.isLoopFinished, let loopBlock = loopBlock {
loopBlock()
}
}
}
}

Expand Down Expand Up @@ -136,9 +145,12 @@ public class Animator {
/// - parameter size: The target size of the individual frames.
/// - parameter contentMode: The view content mode to use for the individual frames.
/// - parameter loopCount: Desired number of loops, <= 0 for infinite loop.
/// - parameter completionHandler: Completion callback function
func animate(withGIFNamed imageName: String, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) {
/// - parameter preparationBlock: Callback for when preparation is done
/// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops)
/// - parameter loopBlock: Callback for when a loop is done (at the end of each loop)
func animate(withGIFNamed imageName: String, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, loopBlock: (() -> Void)? = nil) {
self.animationBlock = animationBlock
self.loopBlock = loopBlock
prepareForAnimation(withGIFNamed: imageName,
size: size,
contentMode: contentMode,
Expand All @@ -153,9 +165,12 @@ public class Animator {
/// - parameter size: The target size of the individual frames.
/// - parameter contentMode: The view content mode to use for the individual frames.
/// - parameter loopCount: Desired number of loops, <= 0 for infinite loop.
/// - parameter completionHandler: Completion callback function
func animate(withGIFData imageData: Data, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) {
/// - parameter preparationBlock: Callback for when preparation is done
/// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops)
/// - parameter loopBlock: Callback for when a loop is done (at the end of each loop)
func animate(withGIFData imageData: Data, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, loopBlock: (() -> Void)? = nil) {
self.animationBlock = animationBlock
self.loopBlock = loopBlock
prepareForAnimation(withGIFData: imageData,
size: size,
contentMode: contentMode,
Expand Down
17 changes: 13 additions & 4 deletions Sources/Gifu/Classes/FrameStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ class FrameStore {

/// Total duration of one animation loop
var loopDuration: TimeInterval = 0

/// Flag indicating if number of loops has been reached

/// Flag indicating that a single loop has finished
var isLoopFinished: Bool = false

/// Flag indicating if number of loops has been reached (never true for infinite loop)
var isFinished: Bool = false

/// Desired number of loops, <= 0 for infinite loop
Expand Down Expand Up @@ -212,10 +215,16 @@ private extension FrameStore {
/// Increments the `currentFrameIndex` property.
func incrementCurrentFrameIndex() {
currentFrameIndex = increment(frameIndex: currentFrameIndex)
if isLastLoop(loopIndex: currentLoop) && isLastFrame(frameIndex: currentFrameIndex) {
if isLastFrame(frameIndex: currentFrameIndex) {
isLoopFinished = true
if isLastLoop(loopIndex: currentLoop) {
isFinished = true
} else if currentFrameIndex == 0 {
}
} else {
isLoopFinished = false
if currentFrameIndex == 0 {
currentLoop = currentLoop + 1
}
}
}

Expand Down
33 changes: 22 additions & 11 deletions Sources/Gifu/Classes/GIFAnimatable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,36 +56,44 @@ extension GIFAnimatable {
///
/// - parameter imageName: The file name of the GIF in the main bundle.
/// - parameter loopCount: Desired number of loops, <= 0 for infinite loop.
/// - parameter completionHandler: Completion callback function
public func animate(withGIFNamed imageName: String, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) {
/// - parameter preparationBlock: Callback for when preparation is done
/// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops)
/// - parameter loopBlock: Callback for when a loop is done (at the end of each loop)
public func animate(withGIFNamed imageName: String, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, loopBlock: (() -> Void)? = nil) {
animator?.animate(withGIFNamed: imageName,
size: frame.size,
contentMode: contentMode,
loopCount: loopCount,
preparationBlock: preparationBlock,
animationBlock: animationBlock)
animationBlock: animationBlock,
loopBlock: loopBlock)
}

/// Prepare for animation and start animating immediately.
///
/// - parameter imageData: GIF image data.
/// - parameter loopCount: Desired number of loops, <= 0 for infinite loop.
/// - parameter completionHandler: Completion callback function
public func animate(withGIFData imageData: Data, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) {
/// - parameter preparationBlock: Callback for when preparation is done
/// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops)
/// - parameter loopBlock: Callback for when a loop is done (at the end of each loop)
public func animate(withGIFData imageData: Data, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, loopBlock: (() -> Void)? = nil) {
animator?.animate(withGIFData: imageData,
size: frame.size,
contentMode: contentMode,
loopCount: loopCount,
preparationBlock: preparationBlock,
animationBlock: animationBlock)
animationBlock: animationBlock,
loopBlock: loopBlock)
}

/// Prepare for animation and start animating immediately.
///
/// - parameter imageURL: GIF image url.
/// - parameter loopCount: Desired number of loops, <= 0 for infinite loop.
/// - parameter completionHandler: Completion callback function
public func animate(withGIFURL imageURL: URL, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil) {
/// - parameter preparationBlock: Callback for when preparation is done
/// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops)
/// - parameter loopBlock: Callback for when a loop is done (at the end of each loop)
public func animate(withGIFURL imageURL: URL, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, loopBlock: (() -> Void)? = nil) {
let session = URLSession.shared

let task = session.dataTask(with: imageURL) { (data, response, error) in
Expand All @@ -94,7 +102,7 @@ extension GIFAnimatable {
print("Error downloading gif:", error.localizedDescription, "at url:", imageURL.absoluteString)
case (let data?, _, _):
DispatchQueue.main.async {
self.animate(withGIFData: data, loopCount: loopCount, preparationBlock: preparationBlock, animationBlock: animationBlock)
self.animate(withGIFData: data, loopCount: loopCount, preparationBlock: preparationBlock, animationBlock: animationBlock, loopBlock: loopBlock)
}
default: ()
}
Expand All @@ -107,6 +115,7 @@ extension GIFAnimatable {
///
/// - parameter imageName: The file name of the GIF in the main bundle.
/// - parameter loopCount: Desired number of loops, <= 0 for infinite loop.
/// - parameter completionHandler: Callback for when preparation is done
public func prepareForAnimation(withGIFNamed imageName: String,
loopCount: Int = 0,
completionHandler: (() -> Void)? = nil) {
Expand All @@ -117,10 +126,11 @@ extension GIFAnimatable {
completionHandler: completionHandler)
}

/// Prepare for animation and start animating immediately.
/// Prepares the animator instance for animation.
///
/// - parameter imageData: GIF image data.
/// - parameter loopCount: Desired number of loops, <= 0 for infinite loop.
/// - parameter completionHandler: Callback for when preparation is done
public func prepareForAnimation(withGIFData imageData: Data,
loopCount: Int = 0,
completionHandler: (() -> Void)? = nil) {
Expand All @@ -135,10 +145,11 @@ extension GIFAnimatable {
completionHandler: completionHandler)
}

/// Prepare for animation and start animating immediately.
/// Prepares the animator instance for animation.
///
/// - parameter imageURL: GIF image url.
/// - parameter loopCount: Desired number of loops, <= 0 for infinite loop.
/// - parameter completionHandler: Callback for when preparation is done
public func prepareForAnimation(withGIFURL imageURL: URL,
loopCount: Int = 0,
completionHandler: (() -> Void)? = nil) {
Expand Down
55 changes: 55 additions & 0 deletions Tests/GifuTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,61 @@ class GifuTests: XCTestCase {
}
}
}

func testFinishedStates() {
animator = Animator(withDelegate: delegate)
animator.prepareForAnimation(withGIFData: imageData, size: staticImage.size, contentMode: .scaleToFill, loopCount: 2)

XCTAssertNotNil(animator.frameStore)
guard let store = animator.frameStore else { return }

let expectation = self.expectation(description: "testFinishedStatesAreSetCorrectly")

store.prepareFrames {
let animatedFrameCount = store.animatedFrames.count
XCTAssertEqual(store.currentFrameIndex, 0)

// Animate through all the frames (first loop)
for frame in 1..<animatedFrameCount {
XCTAssertFalse(store.isLoopFinished)
XCTAssertFalse(store.isFinished)
store.shouldChangeFrame(with: 1.0) { hasNewFrame in
XCTAssertTrue(hasNewFrame)
XCTAssertEqual(store.currentFrameIndex, frame)
}
}

XCTAssertTrue(store.isLoopFinished, "First loop should be finished")
XCTAssertFalse(store.isFinished, "Animation should not be finished yet")

store.shouldChangeFrame(with: 1.0) { hasNewFrame in
XCTAssertTrue(hasNewFrame)
}

XCTAssertEqual(store.currentFrameIndex, 0)

// Animate through all the frames (second loop)
for frame in 1..<animatedFrameCount {
XCTAssertFalse(store.isLoopFinished)
XCTAssertFalse(store.isFinished)
store.shouldChangeFrame(with: 1.0) { hasNewFrame in
XCTAssertTrue(hasNewFrame)
XCTAssertEqual(store.currentFrameIndex, frame)
}
}

XCTAssertTrue(store.isLoopFinished, "Second loop should be finished")
XCTAssertTrue(store.isFinished, "Animation should be finished (loopCount: 2)")

expectation.fulfill()
}

waitForExpectations(timeout: 1.0) { error in
if let error = error {
print("Error: \(error.localizedDescription)")
}
}
}
}

private func testImageDataNamed(_ name: String) -> Data {
Expand Down

0 comments on commit c8c8cd8

Please sign in to comment.