847 lines
29 KiB
Swift
847 lines
29 KiB
Swift
//
|
|
// 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) 2019 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.
|
|
|
|
#if !os(watchOS)
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
import ImageIO
|
|
typealias KFCrossPlatformContentMode = UIView.ContentMode
|
|
#elseif canImport(AppKit)
|
|
import AppKit
|
|
typealias KFCrossPlatformContentMode = NSImageScaling
|
|
#endif
|
|
|
|
/// Protocol of `AnimatedImageView`.
|
|
public protocol AnimatedImageViewDelegate: AnyObject {
|
|
|
|
/// Called after the animatedImageView has finished each animation loop.
|
|
///
|
|
/// - Parameters:
|
|
/// - imageView: The `AnimatedImageView` that is being animated.
|
|
/// - 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) {}
|
|
}
|
|
|
|
let KFRunLoopModeCommon = RunLoop.Mode.common
|
|
|
|
/// Represents a subclass of `UIImageView` for displaying animated image.
|
|
/// Different from showing animated image in a normal `UIImageView` (which load all frames at one time),
|
|
/// `AnimatedImageView` only tries to load several frames (defined by `framePreloadCount`) to reduce memory usage.
|
|
/// It provides a tradeoff between memory usage and CPU time. If you have a memory issue when using a normal image
|
|
/// view to load GIF data, you could give this class a try.
|
|
///
|
|
/// Kingfisher supports setting GIF animated data to either `UIImageView` and `AnimatedImageView` out of box. So
|
|
/// it would be fairly easy to switch between them.
|
|
open class AnimatedImageView: KFCrossPlatformImageView {
|
|
/// Proxy object for preventing 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?.updateFrameIfNeeded()
|
|
}
|
|
}
|
|
|
|
/// 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 count of the frames should be preloaded before shown.
|
|
public var framePreloadCount = 10
|
|
|
|
/// Specifies whether the GIF frames should be pre-scaled to the image view's size or not.
|
|
/// If the downloaded image is larger than the image view's size, it will help to reduce some memory use.
|
|
/// Default is `true`.
|
|
public var needsPrescaling = true
|
|
|
|
/// Decode the GIF frames in background thread before using. It will decode frames data and do a off-screen
|
|
/// rendering to extract pixel information in background. This can reduce the main thread CPU usage.
|
|
///
|
|
@available(*, deprecated, message: """
|
|
This property does not perform as declared and may lead to performance degradation.
|
|
It is currently obsolete and scheduled for removal in a future version.
|
|
""")
|
|
public var backgroundDecode = true
|
|
|
|
/// The animation timer's run loop mode. Default is `RunLoop.Mode.common`.
|
|
/// Set this property to `RunLoop.Mode.default` will make the animation pause during UIScrollView scrolling.
|
|
public var runLoopMode = KFRunLoopModeCommon {
|
|
willSet {
|
|
guard runLoopMode != newValue else { return }
|
|
stopAnimating()
|
|
displayLink.remove(from: .main, forMode: runLoopMode)
|
|
displayLink.add(to: .main, forMode: newValue)
|
|
startAnimating()
|
|
}
|
|
}
|
|
|
|
/// The repeat count. The animated image will keep animate until it the loop count reaches this value.
|
|
/// Setting this value to another one will reset current animation.
|
|
///
|
|
/// Default is `.infinite`, which means the animation will last forever.
|
|
public var repeatCount = RepeatCount.infinite {
|
|
didSet {
|
|
if oldValue != repeatCount {
|
|
reset()
|
|
#if os(macOS)
|
|
needsDisplay = true
|
|
layer?.setNeedsDisplay()
|
|
#else
|
|
setNeedsDisplay()
|
|
layer.setNeedsDisplay()
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delegate of this `AnimatedImageView` object. See `AnimatedImageViewDelegate` protocol for more.
|
|
public weak var delegate: AnimatedImageViewDelegate?
|
|
|
|
/// The `Animator` instance that holds the frames of a specific image in memory.
|
|
public private(set) var animator: Animator?
|
|
|
|
// MARK: - Private property
|
|
// Dispatch queue used for preloading images.
|
|
private lazy var preloadQueue: DispatchQueue = {
|
|
return DispatchQueue(label: "com.onevcat.Kingfisher.Animator.preloadQueue")
|
|
}()
|
|
|
|
// A flag to avoid invalidating the displayLink on deinit if it was never created, because displayLink is so lazy.
|
|
private var isDisplayLinkInitialized: Bool = false
|
|
|
|
// A display link that keeps calling the `updateFrame` method on every screen refresh.
|
|
private lazy var displayLink: DisplayLinkCompatible = {
|
|
isDisplayLinkInitialized = true
|
|
let displayLink = self.compatibleDisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate))
|
|
displayLink.add(to: .main, forMode: runLoopMode)
|
|
displayLink.isPaused = true
|
|
return displayLink
|
|
}()
|
|
|
|
// MARK: - Override
|
|
override open var image: KFCrossPlatformImage? {
|
|
didSet {
|
|
if image != oldValue {
|
|
reset()
|
|
}
|
|
#if os(macOS)
|
|
needsDisplay = true
|
|
layer?.setNeedsDisplay()
|
|
#else
|
|
setNeedsDisplay()
|
|
layer.setNeedsDisplay()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
open override var isHighlighted: Bool {
|
|
get {
|
|
super.isHighlighted
|
|
}
|
|
set {
|
|
// Highlighted image is unsupported for animated images.
|
|
// See https://github.com/onevcat/Kingfisher/issues/1679
|
|
if displayLink.isPaused {
|
|
super.isHighlighted = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
// Workaround for Apple xcframework creating issue on Apple TV in Swift 5.8.
|
|
// https://github.com/apple/swift/issues/66015
|
|
#if os(tvOS)
|
|
public override init(image: UIImage?, highlightedImage: UIImage?) {
|
|
super.init(image: image, highlightedImage: highlightedImage)
|
|
}
|
|
|
|
required public init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
}
|
|
|
|
init() {
|
|
super.init(frame: .zero)
|
|
}
|
|
#endif
|
|
|
|
deinit {
|
|
if isDisplayLinkInitialized {
|
|
displayLink.invalidate()
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
public override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
commonInit()
|
|
}
|
|
|
|
public required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
commonInit()
|
|
}
|
|
|
|
private func commonInit() {
|
|
super.animates = false
|
|
wantsLayer = true
|
|
}
|
|
|
|
open override var animates: Bool {
|
|
get {
|
|
if isDisplayLinkInitialized {
|
|
return !displayLink.isPaused
|
|
} else {
|
|
return super.animates
|
|
}
|
|
}
|
|
set {
|
|
if newValue {
|
|
startAnimating()
|
|
} else {
|
|
stopAnimating()
|
|
}
|
|
}
|
|
}
|
|
|
|
open func startAnimating() {
|
|
guard let animator = animator else { return }
|
|
guard !animator.isReachMaxRepeatCount else { return }
|
|
|
|
displayLink.isPaused = false
|
|
}
|
|
|
|
open func stopAnimating() {
|
|
if isDisplayLinkInitialized {
|
|
displayLink.isPaused = true
|
|
}
|
|
}
|
|
|
|
open override var wantsUpdateLayer: Bool {
|
|
return true
|
|
}
|
|
|
|
open override func updateLayer() {
|
|
if let frame = animator?.currentFrameImage ?? currentFrame, let layer = layer {
|
|
layer.contents = frame.kf.cgImage
|
|
layer.contentsScale = frame.kf.scale
|
|
layer.contentsGravity = determineContentsGravity(for: frame)
|
|
currentFrame = frame
|
|
}
|
|
}
|
|
|
|
private func determineContentsGravity(for image: NSImage) -> CALayerContentsGravity {
|
|
switch imageScaling {
|
|
case .scaleProportionallyDown:
|
|
if image.size.width > bounds.width || image.size.height > bounds.height {
|
|
return .resizeAspect
|
|
} else {
|
|
return .center
|
|
}
|
|
case .scaleProportionallyUpOrDown:
|
|
return .resizeAspect
|
|
case .scaleAxesIndependently:
|
|
return .resize
|
|
case .scaleNone:
|
|
return .center
|
|
default:
|
|
return .resizeAspect
|
|
}
|
|
}
|
|
|
|
open override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
didMove()
|
|
}
|
|
|
|
open override func viewDidMoveToSuperview() {
|
|
super.viewDidMoveToSuperview()
|
|
didMove()
|
|
}
|
|
#else
|
|
override open var isAnimating: Bool {
|
|
if isDisplayLinkInitialized {
|
|
return !displayLink.isPaused
|
|
} else {
|
|
return super.isAnimating
|
|
}
|
|
}
|
|
|
|
/// Starts the animation.
|
|
override open func startAnimating() {
|
|
guard !isAnimating else { return }
|
|
guard let animator = animator else { return }
|
|
guard !animator.isReachMaxRepeatCount else { return }
|
|
|
|
displayLink.isPaused = false
|
|
}
|
|
|
|
/// Stops the animation.
|
|
override open func stopAnimating() {
|
|
super.stopAnimating()
|
|
if isDisplayLinkInitialized {
|
|
displayLink.isPaused = true
|
|
}
|
|
}
|
|
|
|
override open func display(_ layer: CALayer) {
|
|
layer.contents = animator?.currentFrameImage?.cgImage ?? image?.cgImage
|
|
}
|
|
|
|
override open func didMoveToWindow() {
|
|
super.didMoveToWindow()
|
|
didMove()
|
|
}
|
|
|
|
override open func didMoveToSuperview() {
|
|
super.didMoveToSuperview()
|
|
didMove()
|
|
}
|
|
#endif
|
|
|
|
// This is for back compatibility that using regular `UIImageView` to show animated image.
|
|
override func shouldPreloadAllAnimation() -> Bool {
|
|
return false
|
|
}
|
|
|
|
// Reset the animator.
|
|
private func reset() {
|
|
animator = nil
|
|
currentFrame = nil
|
|
if let image = image, let frameSource = image.kf.frameSource {
|
|
#if os(visionOS)
|
|
let scale = UITraitCollection.current.displayScale
|
|
#elseif os(macOS)
|
|
let scale = image.recommendedLayerContentsScale(window?.backingScaleFactor ?? 0.0)
|
|
let contentMode = imageScaling
|
|
#else
|
|
var scale: CGFloat = 0
|
|
if #available(iOS 13.0, tvOS 13.0, *) {
|
|
scale = UITraitCollection.current.displayScale
|
|
} else {
|
|
scale = UIScreen.main.scale
|
|
}
|
|
#endif
|
|
currentFrame = image
|
|
let targetSize = bounds.scaled(scale).size
|
|
let animator = Animator(
|
|
frameSource: frameSource,
|
|
contentMode: contentMode,
|
|
size: targetSize,
|
|
imageSize: image.kf.size,
|
|
imageScale: image.kf.scale,
|
|
framePreloadCount: framePreloadCount,
|
|
repeatCount: repeatCount,
|
|
preloadQueue: preloadQueue)
|
|
animator.delegate = self
|
|
animator.needsPrescaling = needsPrescaling
|
|
animator.prepareFramesAsynchronously()
|
|
self.animator = animator
|
|
}
|
|
didMove()
|
|
}
|
|
|
|
private func didMove() {
|
|
if autoPlayAnimatedImage && animator != nil {
|
|
if let _ = superview, let _ = window {
|
|
startAnimating()
|
|
} else {
|
|
stopAnimating()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// If the Animator cannot prepare the next frame in time, `animator.currentFrameImage` will return nil.
|
|
/// To prevent unexpected blinking in the ImageView, we maintain a cache of the currently displayed frame
|
|
/// to use as a fallback in such scenarios.
|
|
private var currentFrame: KFCrossPlatformImage?
|
|
|
|
/// Update the current frame with the displayLink duration.
|
|
private func updateFrameIfNeeded() {
|
|
guard let animator = animator else {
|
|
return
|
|
}
|
|
|
|
guard !animator.isFinished else {
|
|
stopAnimating()
|
|
delegate?.animatedImageViewDidFinishAnimating(self)
|
|
return
|
|
}
|
|
|
|
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)
|
|
// By setting CADisableMinimumFrameDuration to YES in Info.plist may
|
|
// cause the preferredFramesPerSecond being 0
|
|
let preferredFramesPerSecond = displayLink.preferredFramesPerSecond
|
|
if preferredFramesPerSecond == 0 {
|
|
duration = displayLink.duration
|
|
} else {
|
|
// Some devices (like iPad Pro 10.5) will have a different FPS.
|
|
duration = 1.0 / TimeInterval(preferredFramesPerSecond)
|
|
}
|
|
|
|
animator.shouldChangeFrame(with: duration) { [weak self] hasNewFrame in
|
|
if hasNewFrame {
|
|
#if os(macOS)
|
|
self?.layer?.setNeedsDisplay()
|
|
#else
|
|
self?.layer.setNeedsDisplay()
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protocol AnimatorDelegate: AnyObject {
|
|
func animator(_ animator: AnimatedImageView.Animator, didPlayAnimationLoops count: UInt)
|
|
}
|
|
|
|
extension AnimatedImageView: AnimatorDelegate {
|
|
func animator(_ animator: Animator, didPlayAnimationLoops count: UInt) {
|
|
delegate?.animatedImageView(self, didPlayAnimationLoops: count)
|
|
}
|
|
}
|
|
|
|
extension AnimatedImageView {
|
|
|
|
// Represents a single frame in a GIF.
|
|
struct AnimatedFrame {
|
|
|
|
// The image to display for this frame. Its value is nil when the frame is removed from the buffer.
|
|
let image: KFCrossPlatformImage?
|
|
|
|
// The duration that this frame should remain active.
|
|
let duration: TimeInterval
|
|
|
|
// A placeholder frame with no image assigned.
|
|
// Used to replace frames that are no longer needed in the animation.
|
|
var placeholderFrame: AnimatedFrame {
|
|
return AnimatedFrame(image: nil, duration: duration)
|
|
}
|
|
|
|
// Whether this frame instance contains an image or not.
|
|
var isPlaceholder: Bool {
|
|
return image == nil
|
|
}
|
|
|
|
// Returns a new instance from an optional image.
|
|
//
|
|
// - parameter image: An optional `UIImage` instance to be assigned to the new frame.
|
|
// - returns: An `AnimatedFrame` instance.
|
|
func makeAnimatedFrame(image: KFCrossPlatformImage?) -> AnimatedFrame {
|
|
return AnimatedFrame(image: image, duration: duration)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AnimatedImageView {
|
|
|
|
// MARK: - Animator
|
|
|
|
/// An animator which used to drive the data behind `AnimatedImageView`.
|
|
public class Animator {
|
|
private let size: CGSize
|
|
|
|
private let imageSize: CGSize
|
|
private let imageScale: CGFloat
|
|
|
|
/// The maximum count of image frames that needs preload.
|
|
public let maxFrameCount: Int
|
|
|
|
private let frameSource: ImageFrameSource
|
|
private let maxRepeatCount: RepeatCount
|
|
|
|
private let maxTimeStep: TimeInterval = 1.0
|
|
private let animatedFrames = SafeArray<AnimatedFrame>()
|
|
private var frameCount = 0
|
|
private var timeSinceLastFrameChange: TimeInterval = 0.0
|
|
private var currentRepeatCount: UInt = 0
|
|
|
|
var isFinished: Bool = false
|
|
|
|
var needsPrescaling = true
|
|
|
|
weak var delegate: AnimatorDelegate?
|
|
|
|
// Total duration of one animation loop
|
|
var loopDuration: TimeInterval = 0
|
|
|
|
/// The image of the current frame.
|
|
public var currentFrameImage: KFCrossPlatformImage? {
|
|
return frame(at: currentFrameIndex)
|
|
}
|
|
|
|
/// The duration of the current active frame duration.
|
|
public var currentFrameDuration: TimeInterval {
|
|
return duration(at: currentFrameIndex)
|
|
}
|
|
|
|
/// The index of the current animation frame.
|
|
public internal(set) var currentFrameIndex = 0 {
|
|
didSet {
|
|
previousFrameIndex = oldValue
|
|
}
|
|
}
|
|
|
|
var previousFrameIndex = 0 {
|
|
didSet {
|
|
preloadQueue.async {
|
|
self.updatePreloadedFrames()
|
|
}
|
|
}
|
|
}
|
|
|
|
var isReachMaxRepeatCount: Bool {
|
|
switch maxRepeatCount {
|
|
case .once:
|
|
return currentRepeatCount >= 1
|
|
case .finite(let maxCount):
|
|
return currentRepeatCount >= maxCount
|
|
case .infinite:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Whether the current frame is the last frame or not in the animation sequence.
|
|
public var isLastFrame: Bool {
|
|
return currentFrameIndex == frameCount - 1
|
|
}
|
|
|
|
var preloadingIsNeeded: Bool {
|
|
return maxFrameCount < frameCount - 1
|
|
}
|
|
|
|
#if os(macOS)
|
|
var contentMode = NSImageScaling.scaleAxesIndependently
|
|
#else
|
|
var contentMode = UIView.ContentMode.scaleToFill
|
|
#endif
|
|
|
|
private lazy var preloadQueue: DispatchQueue = {
|
|
return DispatchQueue(label: "com.onevcat.Kingfisher.Animator.preloadQueue")
|
|
}()
|
|
|
|
/// Creates an animator with image source reference.
|
|
///
|
|
/// - Parameters:
|
|
/// - source: The reference of animated image.
|
|
/// - mode: Content mode of the `AnimatedImageView`.
|
|
/// - size: Size of the `AnimatedImageView`.
|
|
/// - imageSize: Size of the `KingfisherWrapper`.
|
|
/// - imageScale: Scale of the `KingfisherWrapper`.
|
|
/// - count: Count of frames needed to be preloaded.
|
|
/// - repeatCount: The repeat count should this animator uses.
|
|
/// - preloadQueue: Dispatch queue used for preloading images.
|
|
convenience init(imageSource source: CGImageSource,
|
|
contentMode mode: KFCrossPlatformContentMode,
|
|
size: CGSize,
|
|
imageSize: CGSize,
|
|
imageScale: CGFloat,
|
|
framePreloadCount count: Int,
|
|
repeatCount: RepeatCount,
|
|
preloadQueue: DispatchQueue) {
|
|
let frameSource = CGImageFrameSource(data: nil, imageSource: source, options: nil)
|
|
self.init(frameSource: frameSource,
|
|
contentMode: mode,
|
|
size: size,
|
|
imageSize: imageSize,
|
|
imageScale: imageScale,
|
|
framePreloadCount: count,
|
|
repeatCount: repeatCount,
|
|
preloadQueue: preloadQueue)
|
|
}
|
|
|
|
/// Creates an animator with a custom image frame source.
|
|
///
|
|
/// - Parameters:
|
|
/// - frameSource: The reference of animated image.
|
|
/// - mode: Content mode of the `AnimatedImageView`.
|
|
/// - size: Size of the `AnimatedImageView`.
|
|
/// - imageSize: Size of the `KingfisherWrapper`.
|
|
/// - imageScale: Scale of the `KingfisherWrapper`.
|
|
/// - count: Count of frames needed to be preloaded.
|
|
/// - repeatCount: The repeat count should this animator uses.
|
|
/// - preloadQueue: Dispatch queue used for preloading images.
|
|
init(frameSource source: ImageFrameSource,
|
|
contentMode mode: KFCrossPlatformContentMode,
|
|
size: CGSize,
|
|
imageSize: CGSize,
|
|
imageScale: CGFloat,
|
|
framePreloadCount count: Int,
|
|
repeatCount: RepeatCount,
|
|
preloadQueue: DispatchQueue) {
|
|
self.frameSource = source
|
|
self.contentMode = mode
|
|
self.size = size
|
|
self.imageSize = imageSize
|
|
self.imageScale = imageScale
|
|
self.maxFrameCount = count
|
|
self.maxRepeatCount = repeatCount
|
|
self.preloadQueue = preloadQueue
|
|
}
|
|
|
|
deinit {
|
|
resetAnimatedFrames()
|
|
}
|
|
|
|
/// Gets the image frame of a given index.
|
|
/// - Parameter index: The index of desired image.
|
|
/// - Returns: The decoded image at the frame. `nil` if the index is out of bound or the image is not yet loaded.
|
|
public func frame(at index: Int) -> KFCrossPlatformImage? {
|
|
return animatedFrames[index]?.image
|
|
}
|
|
|
|
public func duration(at index: Int) -> TimeInterval {
|
|
return animatedFrames[index]?.duration ?? .infinity
|
|
}
|
|
|
|
func prepareFramesAsynchronously() {
|
|
frameCount = frameSource.frameCount
|
|
animatedFrames.reserveCapacity(frameCount)
|
|
preloadQueue.async { [weak self] in
|
|
self?.setupAnimatedFrames()
|
|
}
|
|
}
|
|
|
|
func shouldChangeFrame(with duration: CFTimeInterval, handler: (Bool) -> Void) {
|
|
incrementTimeSinceLastFrameChange(with: duration)
|
|
|
|
if currentFrameDuration > timeSinceLastFrameChange {
|
|
handler(false)
|
|
} else {
|
|
resetTimeSinceLastFrameChange()
|
|
incrementCurrentFrameIndex()
|
|
handler(true)
|
|
}
|
|
}
|
|
|
|
private func setupAnimatedFrames() {
|
|
resetAnimatedFrames()
|
|
|
|
var duration: TimeInterval = 0
|
|
|
|
(0..<frameCount).forEach { index in
|
|
let frameDuration = frameSource.duration(at: index)
|
|
duration += min(frameDuration, maxTimeStep)
|
|
animatedFrames.append(AnimatedFrame(image: nil, duration: frameDuration))
|
|
|
|
if index > maxFrameCount { return }
|
|
animatedFrames[index] = animatedFrames[index]?.makeAnimatedFrame(image: loadFrame(at: index))
|
|
}
|
|
|
|
self.loopDuration = duration
|
|
}
|
|
|
|
private func resetAnimatedFrames() {
|
|
animatedFrames.removeAll()
|
|
}
|
|
|
|
private func loadFrame(at index: Int) -> KFCrossPlatformImage? {
|
|
let resize = needsPrescaling && size != .zero
|
|
let maxSize = resize ? size : nil
|
|
guard let cgImage = frameSource.frame(at: index, maxSize: maxSize) else {
|
|
return nil
|
|
}
|
|
|
|
#if os(macOS)
|
|
return KFCrossPlatformImage(cgImage: cgImage, size: .zero)
|
|
#else
|
|
if #available(iOS 15, tvOS 15, *) {
|
|
// From iOS 15, a plain image loading causes iOS calling `-[_UIImageCGImageContent initWithCGImage:scale:]`
|
|
// in ImageIO, which holds the image ref on the creating thread.
|
|
// To get a workaround, create another image ref and use that to create the final image. This leads to
|
|
// some performance loss, but there is little we can do.
|
|
// https://github.com/onevcat/Kingfisher/issues/1844
|
|
// https://github.com/onevcat/Kingfisher/pulls/2194
|
|
guard let unretainedImage = CGImage.create(ref: cgImage) else {
|
|
return KFCrossPlatformImage(cgImage: cgImage)
|
|
}
|
|
|
|
return KFCrossPlatformImage(cgImage: unretainedImage)
|
|
} else {
|
|
return KFCrossPlatformImage(cgImage: cgImage)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func updatePreloadedFrames() {
|
|
guard preloadingIsNeeded else {
|
|
return
|
|
}
|
|
|
|
let previousFrame = animatedFrames[previousFrameIndex]
|
|
animatedFrames[previousFrameIndex] = previousFrame?.placeholderFrame
|
|
// ensure the image dealloc in main thread
|
|
defer {
|
|
if let image = previousFrame?.image {
|
|
DispatchQueue.main.async {
|
|
_ = image
|
|
}
|
|
}
|
|
}
|
|
|
|
preloadIndexes(start: currentFrameIndex).forEach { index in
|
|
guard let currentAnimatedFrame = animatedFrames[index] else { return }
|
|
if !currentAnimatedFrame.isPlaceholder { return }
|
|
animatedFrames[index] = currentAnimatedFrame.makeAnimatedFrame(image: loadFrame(at: index))
|
|
}
|
|
}
|
|
|
|
private func incrementCurrentFrameIndex() {
|
|
let wasLastFrame = isLastFrame
|
|
currentFrameIndex = increment(frameIndex: currentFrameIndex)
|
|
if isLastFrame {
|
|
currentRepeatCount += 1
|
|
if isReachMaxRepeatCount {
|
|
isFinished = true
|
|
|
|
// Notify the delegate here because the animation is stopping.
|
|
delegate?.animator(self, didPlayAnimationLoops: currentRepeatCount)
|
|
}
|
|
} else if wasLastFrame {
|
|
|
|
// Notify the delegate that the loop completed
|
|
delegate?.animator(self, didPlayAnimationLoops: currentRepeatCount)
|
|
}
|
|
}
|
|
|
|
private func incrementTimeSinceLastFrameChange(with duration: TimeInterval) {
|
|
timeSinceLastFrameChange += min(maxTimeStep, duration)
|
|
}
|
|
|
|
private func resetTimeSinceLastFrameChange() {
|
|
timeSinceLastFrameChange -= currentFrameDuration
|
|
}
|
|
|
|
private func increment(frameIndex: Int, by value: Int = 1) -> Int {
|
|
return (frameIndex + value) % frameCount
|
|
}
|
|
|
|
private func preloadIndexes(start index: Int) -> [Int] {
|
|
let nextIndex = increment(frameIndex: index)
|
|
let lastIndex = increment(frameIndex: index, by: maxFrameCount)
|
|
|
|
if lastIndex >= nextIndex {
|
|
return [Int](nextIndex...lastIndex)
|
|
} else {
|
|
return [Int](nextIndex..<frameCount) + [Int](0...lastIndex)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class SafeArray<Element> {
|
|
private var array: Array<Element> = []
|
|
private let lock = NSLock()
|
|
|
|
subscript(index: Int) -> Element? {
|
|
get {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return array.indices ~= index ? array[index] : nil
|
|
}
|
|
|
|
set {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
if let newValue = newValue, array.indices ~= index {
|
|
array[index] = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
var count : Int {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return array.count
|
|
}
|
|
|
|
func reserveCapacity(_ count: Int) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
array.reserveCapacity(count)
|
|
}
|
|
|
|
func append(_ element: Element) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
array += [element]
|
|
}
|
|
|
|
func removeAll() {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
array = []
|
|
}
|
|
}
|
|
#endif
|