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

//
// 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]
}