From c8c8cd8e98eb73b8353b612c2925461c956fddc8 Mon Sep 17 00:00:00 2001 From: Robin Stocker Date: Fri, 3 Feb 2023 20:02:26 +1100 Subject: [PATCH] Add `loopBlock` callback for when a loop is finished (#183) 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. --- Demo/Demo-iOS/ViewController.swift | 2 + Sources/Gifu/Classes/Animator.swift | 27 +++++++++--- Sources/Gifu/Classes/FrameStore.swift | 17 ++++++-- Sources/Gifu/Classes/GIFAnimatable.swift | 33 +++++++++----- Tests/GifuTests.swift | 55 ++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 21 deletions(-) diff --git a/Demo/Demo-iOS/ViewController.swift b/Demo/Demo-iOS/ViewController.swift index 5eb0978..8e68a46 100755 --- a/Demo/Demo-iOS/ViewController.swift +++ b/Demo/Demo-iOS/ViewController.swift @@ -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") }) } } diff --git a/Sources/Gifu/Classes/Animator.swift b/Sources/Gifu/Classes/Animator.swift index d386444..e3d70ed 100644 --- a/Sources/Gifu/Classes/Animator.swift +++ b/Sources/Gifu/Classes/Animator.swift @@ -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 @@ -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() + } + } } } @@ -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, @@ -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, diff --git a/Sources/Gifu/Classes/FrameStore.swift b/Sources/Gifu/Classes/FrameStore.swift index fec63ac..2585fd4 100644 --- a/Sources/Gifu/Classes/FrameStore.swift +++ b/Sources/Gifu/Classes/FrameStore.swift @@ -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 @@ -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 + } } } diff --git a/Sources/Gifu/Classes/GIFAnimatable.swift b/Sources/Gifu/Classes/GIFAnimatable.swift index c380a28..8a95ed2 100644 --- a/Sources/Gifu/Classes/GIFAnimatable.swift +++ b/Sources/Gifu/Classes/GIFAnimatable.swift @@ -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 @@ -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: () } @@ -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) { @@ -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) { @@ -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) { diff --git a/Tests/GifuTests.swift b/Tests/GifuTests.swift index ee56241..06272f0 100644 --- a/Tests/GifuTests.swift +++ b/Tests/GifuTests.swift @@ -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.. Data {