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.
504 lines
17 KiB
504 lines
17 KiB
![]()
2 years ago
|
//
|
||
|
// 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]
|
||
|
}
|