You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
503 lines
17 KiB
503 lines
17 KiB
// |
|
// AnimatableImageView.swift |
|
// Kingfisher |
|
// |
|
// Created by bl4ckra1sond3tre on 4/22/16. |
|
// |
|
// The AnimatableImageView, AnimatedFrame and Animator is a modified version of |
|
// some classes from kaishin's Gifu project (https://github.com/kaishin/Gifu) |
|
// |
|
// The MIT License (MIT) |
|
// |
|
// Copyright (c) 2018 Reda Lemeden. |
|
// |
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of |
|
// this software and associated documentation files (the "Software"), to deal in |
|
// the Software without restriction, including without limitation the rights to |
|
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
|
// the Software, and to permit persons to whom the Software is furnished to do so, |
|
// subject to the following conditions: |
|
// |
|
// The above copyright notice and this permission notice shall be included in all |
|
// copies or substantial portions of the Software. |
|
// |
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
|
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
|
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
|
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|
// |
|
// The name and characters used in the demo of this software are property of their |
|
// respective owners. |
|
|
|
import UIKit |
|
import ImageIO |
|
|
|
/// Protocol of `AnimatedImageView`. |
|
public protocol AnimatedImageViewDelegate: AnyObject { |
|
/** |
|
Called after the animatedImageView has finished each animation loop. |
|
|
|
- parameter imageView: The animatedImageView that is being animated. |
|
- parameter count: The looped count. |
|
*/ |
|
func animatedImageView(_ imageView: AnimatedImageView, didPlayAnimationLoops count: UInt) |
|
|
|
/** |
|
Called after the animatedImageView has reached the max repeat count. |
|
|
|
- parameter imageView: The animatedImageView that is being animated. |
|
*/ |
|
func animatedImageViewDidFinishAnimating(_ imageView: AnimatedImageView) |
|
} |
|
|
|
extension AnimatedImageViewDelegate { |
|
public func animatedImageView(_ imageView: AnimatedImageView, didPlayAnimationLoops count: UInt) {} |
|
public func animatedImageViewDidFinishAnimating(_ imageView: AnimatedImageView) {} |
|
} |
|
|
|
/// `AnimatedImageView` is a subclass of `UIImageView` for displaying animated image. |
|
open class AnimatedImageView: UIImageView { |
|
|
|
/// Proxy object for prevending a reference cycle between the CADDisplayLink and AnimatedImageView. |
|
class TargetProxy { |
|
private weak var target: AnimatedImageView? |
|
|
|
init(target: AnimatedImageView) { |
|
self.target = target |
|
} |
|
|
|
@objc func onScreenUpdate() { |
|
target?.updateFrame() |
|
} |
|
} |
|
|
|
/// Enumeration that specifies repeat count of GIF |
|
public enum RepeatCount: Equatable { |
|
case once |
|
case finite(count: UInt) |
|
case infinite |
|
|
|
public static func ==(lhs: RepeatCount, rhs: RepeatCount) -> Bool { |
|
switch (lhs, rhs) { |
|
case let (.finite(l), .finite(r)): |
|
return l == r |
|
case (.once, .once), |
|
(.infinite, .infinite): |
|
return true |
|
case (.once, .finite(let count)), |
|
(.finite(let count), .once): |
|
return count == 1 |
|
case (.once, _), |
|
(.infinite, _), |
|
(.finite, _): |
|
return false |
|
} |
|
} |
|
} |
|
|
|
// MARK: - Public property |
|
/// Whether automatically play the animation when the view become visible. Default is true. |
|
public var autoPlayAnimatedImage = true |
|
|
|
/// The size of the frame cache. |
|
public var framePreloadCount = 10 |
|
|
|
/// Specifies whether the GIF frames should be pre-scaled to save memory. Default is true. |
|
public var needsPrescaling = true |
|
|
|
/// The animation timer's run loop mode. Default is `NSRunLoopCommonModes`. Set this property to `NSDefaultRunLoopMode` will make the animation pause during UIScrollView scrolling. |
|
#if swift(>=4.2) |
|
public var runLoopMode = RunLoop.Mode.common { |
|
willSet { |
|
if runLoopMode == newValue { |
|
return |
|
} else { |
|
stopAnimating() |
|
displayLink.remove(from: .main, forMode: runLoopMode) |
|
displayLink.add(to: .main, forMode: newValue) |
|
startAnimating() |
|
} |
|
} |
|
} |
|
#else |
|
public var runLoopMode = RunLoopMode.commonModes { |
|
willSet { |
|
if runLoopMode == newValue { |
|
return |
|
} else { |
|
stopAnimating() |
|
displayLink.remove(from: .main, forMode: runLoopMode) |
|
displayLink.add(to: .main, forMode: newValue) |
|
startAnimating() |
|
} |
|
} |
|
} |
|
#endif |
|
|
|
/// The repeat count. |
|
public var repeatCount = RepeatCount.infinite { |
|
didSet { |
|
if oldValue != repeatCount { |
|
reset() |
|
setNeedsDisplay() |
|
layer.setNeedsDisplay() |
|
} |
|
} |
|
} |
|
|
|
/// Delegate of this `AnimatedImageView` object. See `AnimatedImageViewDelegate` protocol for more. |
|
public weak var delegate: AnimatedImageViewDelegate? |
|
|
|
// MARK: - Private property |
|
/// `Animator` instance that holds the frames of a specific image in memory. |
|
private var animator: Animator? |
|
|
|
/// A flag to avoid invalidating the displayLink on deinit if it was never created, because displayLink is so lazy. :D |
|
private var isDisplayLinkInitialized: Bool = false |
|
|
|
/// A display link that keeps calling the `updateFrame` method on every screen refresh. |
|
private lazy var displayLink: CADisplayLink = { |
|
self.isDisplayLinkInitialized = true |
|
let displayLink = CADisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate)) |
|
displayLink.add(to: .main, forMode: self.runLoopMode) |
|
displayLink.isPaused = true |
|
return displayLink |
|
}() |
|
|
|
// MARK: - Override |
|
override open var image: Image? { |
|
didSet { |
|
if image != oldValue { |
|
reset() |
|
} |
|
setNeedsDisplay() |
|
layer.setNeedsDisplay() |
|
} |
|
} |
|
|
|
deinit { |
|
if isDisplayLinkInitialized { |
|
displayLink.invalidate() |
|
} |
|
} |
|
|
|
override open var isAnimating: Bool { |
|
if isDisplayLinkInitialized { |
|
return !displayLink.isPaused |
|
} else { |
|
return super.isAnimating |
|
} |
|
} |
|
|
|
/// Starts the animation. |
|
override open func startAnimating() { |
|
if self.isAnimating { |
|
return |
|
} else { |
|
if animator?.isReachMaxRepeatCount ?? false { |
|
return |
|
} |
|
|
|
displayLink.isPaused = false |
|
} |
|
} |
|
|
|
/// Stops the animation. |
|
override open func stopAnimating() { |
|
super.stopAnimating() |
|
if isDisplayLinkInitialized { |
|
displayLink.isPaused = true |
|
} |
|
} |
|
|
|
override open func display(_ layer: CALayer) { |
|
if let currentFrame = animator?.currentFrame { |
|
layer.contents = currentFrame.cgImage |
|
} else { |
|
layer.contents = image?.cgImage |
|
} |
|
} |
|
|
|
override open func didMoveToWindow() { |
|
super.didMoveToWindow() |
|
didMove() |
|
} |
|
|
|
override open func didMoveToSuperview() { |
|
super.didMoveToSuperview() |
|
didMove() |
|
} |
|
|
|
// This is for back compatibility that using regular UIImageView to show animated image. |
|
override func shouldPreloadAllAnimation() -> Bool { |
|
return false |
|
} |
|
|
|
// MARK: - Private method |
|
/// Reset the animator. |
|
private func reset() { |
|
animator = nil |
|
if let imageSource = image?.kf.imageSource?.imageRef { |
|
animator = Animator(imageSource: imageSource, |
|
contentMode: contentMode, |
|
size: bounds.size, |
|
framePreloadCount: framePreloadCount, |
|
repeatCount: repeatCount) |
|
animator?.delegate = self |
|
animator?.needsPrescaling = needsPrescaling |
|
animator?.prepareFramesAsynchronously() |
|
} |
|
didMove() |
|
} |
|
|
|
private func didMove() { |
|
if autoPlayAnimatedImage && animator != nil { |
|
if let _ = superview, let _ = window { |
|
startAnimating() |
|
} else { |
|
stopAnimating() |
|
} |
|
} |
|
} |
|
|
|
/// Update the current frame with the displayLink duration. |
|
private func updateFrame() { |
|
let duration: CFTimeInterval |
|
|
|
// CA based display link is opt-out from ProMotion by default. |
|
// So the duration and its FPS might not match. |
|
// See [#718](https://github.com/onevcat/Kingfisher/issues/718) |
|
if #available(iOS 10.0, tvOS 10.0, *) { |
|
// By setting CADisableMinimumFrameDuration to YES in Info.plist may |
|
// cause the preferredFramesPerSecond being 0 |
|
if displayLink.preferredFramesPerSecond == 0 { |
|
duration = displayLink.duration |
|
} else { |
|
// Some devices (like iPad Pro 10.5) will have a different FPS. |
|
duration = 1.0 / Double(displayLink.preferredFramesPerSecond) |
|
} |
|
} else { |
|
duration = displayLink.duration |
|
} |
|
|
|
if animator?.updateCurrentFrame(duration: duration) ?? false { |
|
layer.setNeedsDisplay() |
|
|
|
if animator?.isReachMaxRepeatCount ?? false { |
|
stopAnimating() |
|
delegate?.animatedImageViewDidFinishAnimating(self) |
|
} |
|
} |
|
} |
|
} |
|
|
|
extension AnimatedImageView: AnimatorDelegate { |
|
func animator(_ animator: Animator, didPlayAnimationLoops count: UInt) { |
|
delegate?.animatedImageView(self, didPlayAnimationLoops: count) |
|
} |
|
} |
|
|
|
/// Keeps a reference to an `Image` instance and its duration as a GIF frame. |
|
struct AnimatedFrame { |
|
var image: Image? |
|
let duration: TimeInterval |
|
|
|
static let null: AnimatedFrame = AnimatedFrame(image: .none, duration: 0.0) |
|
} |
|
|
|
protocol AnimatorDelegate: AnyObject { |
|
func animator(_ animator: Animator, didPlayAnimationLoops count: UInt) |
|
} |
|
|
|
// MARK: - Animator |
|
class Animator { |
|
// MARK: Private property |
|
fileprivate let size: CGSize |
|
fileprivate let maxFrameCount: Int |
|
fileprivate let imageSource: CGImageSource |
|
fileprivate let maxRepeatCount: AnimatedImageView.RepeatCount |
|
|
|
fileprivate var animatedFrames = [AnimatedFrame]() |
|
fileprivate let maxTimeStep: TimeInterval = 1.0 |
|
fileprivate var frameCount = 0 |
|
fileprivate var currentFrameIndex = 0 |
|
fileprivate var currentFrameIndexInBuffer = 0 |
|
fileprivate var currentPreloadIndex = 0 |
|
fileprivate var timeSinceLastFrameChange: TimeInterval = 0.0 |
|
fileprivate var needsPrescaling = true |
|
fileprivate var currentRepeatCount: UInt = 0 |
|
fileprivate weak var delegate: AnimatorDelegate? |
|
|
|
/// Loop count of animated image. |
|
private var loopCount = 0 |
|
|
|
var currentFrame: UIImage? { |
|
return frame(at: currentFrameIndexInBuffer) |
|
} |
|
|
|
var isReachMaxRepeatCount: Bool { |
|
switch maxRepeatCount { |
|
case .once: |
|
return currentRepeatCount >= 1 |
|
case .finite(let maxCount): |
|
return currentRepeatCount >= maxCount |
|
case .infinite: |
|
return false |
|
} |
|
} |
|
|
|
var contentMode = UIView.ContentMode.scaleToFill |
|
|
|
private lazy var preloadQueue: DispatchQueue = { |
|
return DispatchQueue(label: "com.onevcat.Kingfisher.Animator.preloadQueue") |
|
}() |
|
|
|
/** |
|
Init an animator with image source reference. |
|
|
|
- parameter imageSource: The reference of animated image. |
|
- parameter contentMode: Content mode of AnimatedImageView. |
|
- parameter size: Size of AnimatedImageView. |
|
- parameter framePreloadCount: Frame cache size. |
|
|
|
- returns: The animator object. |
|
*/ |
|
init(imageSource source: CGImageSource, |
|
contentMode mode: UIView.ContentMode, |
|
size: CGSize, |
|
framePreloadCount count: Int, |
|
repeatCount: AnimatedImageView.RepeatCount) { |
|
self.imageSource = source |
|
self.contentMode = mode |
|
self.size = size |
|
self.maxFrameCount = count |
|
self.maxRepeatCount = repeatCount |
|
} |
|
|
|
func frame(at index: Int) -> Image? { |
|
return animatedFrames[safe: index]?.image |
|
} |
|
|
|
func prepareFramesAsynchronously() { |
|
preloadQueue.async { [weak self] in |
|
self?.prepareFrames() |
|
} |
|
} |
|
|
|
private func prepareFrames() { |
|
frameCount = CGImageSourceGetCount(imageSource) |
|
|
|
if let properties = CGImageSourceCopyProperties(imageSource, nil), |
|
let gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary, |
|
let loopCount = gifInfo[kCGImagePropertyGIFLoopCount as String] as? Int |
|
{ |
|
self.loopCount = loopCount |
|
} |
|
|
|
let frameToProcess = min(frameCount, maxFrameCount) |
|
animatedFrames.reserveCapacity(frameToProcess) |
|
animatedFrames = (0..<frameToProcess).reduce([]) { $0 + pure(prepareFrame(at: $1))} |
|
currentPreloadIndex = (frameToProcess + 1) % frameCount - 1 |
|
} |
|
|
|
private func prepareFrame(at index: Int) -> AnimatedFrame { |
|
|
|
guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { |
|
return AnimatedFrame.null |
|
} |
|
|
|
let defaultGIFFrameDuration = 0.100 |
|
let frameDuration = imageSource.kf.gifProperties(at: index).map { |
|
gifInfo -> Double in |
|
|
|
let unclampedDelayTime = gifInfo[kCGImagePropertyGIFUnclampedDelayTime as String] as Double? |
|
let delayTime = gifInfo[kCGImagePropertyGIFDelayTime as String] as Double? |
|
let duration = unclampedDelayTime ?? delayTime ?? 0.0 |
|
|
|
/** |
|
http://opensource.apple.com/source/WebCore/WebCore-7600.1.25/platform/graphics/cg/ImageSourceCG.cpp |
|
Many annoying ads specify a 0 duration to make an image flash as quickly as |
|
possible. We follow Safari and Firefox's behavior and use a duration of 100 ms |
|
for any frames that specify a duration of <= 10 ms. |
|
See <rdar://problem/7689300> and <http://webkit.org/b/36082> for more information. |
|
|
|
See also: http://nullsleep.tumblr.com/post/16524517190/animated-gif-minimum-frame-delay-browser. |
|
*/ |
|
return duration > 0.011 ? duration : defaultGIFFrameDuration |
|
} ?? defaultGIFFrameDuration |
|
|
|
let image = Image(cgImage: imageRef) |
|
let scaledImage: Image? |
|
|
|
if needsPrescaling { |
|
scaledImage = image.kf.resize(to: size, for: contentMode) |
|
} else { |
|
scaledImage = image |
|
} |
|
|
|
return AnimatedFrame(image: scaledImage, duration: frameDuration) |
|
} |
|
|
|
/** |
|
Updates the current frame if necessary using the frame timer and the duration of each frame in `animatedFrames`. |
|
*/ |
|
func updateCurrentFrame(duration: CFTimeInterval) -> Bool { |
|
timeSinceLastFrameChange += min(maxTimeStep, duration) |
|
guard let frameDuration = animatedFrames[safe: currentFrameIndexInBuffer]?.duration, frameDuration <= timeSinceLastFrameChange else { |
|
return false |
|
} |
|
|
|
timeSinceLastFrameChange -= frameDuration |
|
|
|
let lastFrameIndex = currentFrameIndexInBuffer |
|
currentFrameIndexInBuffer += 1 |
|
currentFrameIndexInBuffer = currentFrameIndexInBuffer % animatedFrames.count |
|
|
|
if animatedFrames.count < frameCount { |
|
preloadFrameAsynchronously(at: lastFrameIndex) |
|
} |
|
|
|
currentFrameIndex += 1 |
|
|
|
if currentFrameIndex == frameCount { |
|
currentFrameIndex = 0 |
|
currentRepeatCount += 1 |
|
|
|
delegate?.animator(self, didPlayAnimationLoops: currentRepeatCount) |
|
} |
|
|
|
return true |
|
} |
|
|
|
private func preloadFrameAsynchronously(at index: Int) { |
|
preloadQueue.async { [weak self] in |
|
self?.preloadFrame(at: index) |
|
} |
|
} |
|
|
|
private func preloadFrame(at index: Int) { |
|
animatedFrames[index] = prepareFrame(at: currentPreloadIndex) |
|
currentPreloadIndex += 1 |
|
currentPreloadIndex = currentPreloadIndex % frameCount |
|
} |
|
} |
|
|
|
extension CGImageSource: KingfisherCompatible { } |
|
extension Kingfisher where Base: CGImageSource { |
|
func gifProperties(at index: Int) -> [String: Double]? { |
|
let properties = CGImageSourceCopyPropertiesAtIndex(base, index, nil) as Dictionary? |
|
return properties?[kCGImagePropertyGIFDictionary] as? [String: Double] |
|
} |
|
} |
|
|
|
extension Array { |
|
fileprivate subscript(safe index: Int) -> Element? { |
|
return indices ~= index ? self[index] : nil |
|
} |
|
} |
|
|
|
private func pure<T>(_ value: T) -> [T] { |
|
return [value] |
|
}
|
|
|