fix gif caching
This commit is contained in:
1 parent
71b1d0a21d
commit
395cbe5e13
19 files changed
+728
-475
No files matched your search
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 06/04/2022.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public extension GIFImage {
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL string of the image. If the string cannot be parsed into URL, this constructor early aborts and returns `nil`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init?(
|
||||
url: String,
|
||||
animate: Bool,
|
||||
loop: Bool,
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
url: url,
|
||||
animate: .constant(animate),
|
||||
loop: .constant(loop),
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL string of the image. If the string cannot be parsed into URL, this constructor early aborts and returns `nil`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init?(
|
||||
url: String,
|
||||
animate: Binding<Bool> = Binding.constant(true),
|
||||
loop: Binding<Bool> = Binding.constant(true),
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
guard let resolvedURL = URL(string: url) else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
source: GIFSource.remoteURL(resolvedURL),
|
||||
animate: animate,
|
||||
loop: loop,
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL of the image. The response is cached using `URLCache`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init(
|
||||
url: URL,
|
||||
animate: Bool = true,
|
||||
loop: Bool = true,
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
url: url,
|
||||
animate: .constant(animate),
|
||||
loop: .constant(loop),
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL of the image. The response is cached using `URLCache`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init(
|
||||
url: URL,
|
||||
animate: Binding<Bool> = Binding.constant(true),
|
||||
loop: Binding<Bool> = Binding.constant(true),
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
source: GIFSource.remoteURL(url),
|
||||
animate: animate,
|
||||
loop: loop,
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: `URLRequest` of the image. The response is cached using `URLCache`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init(
|
||||
request: URLRequest,
|
||||
animate: Binding<Bool> = Binding.constant(true),
|
||||
loop: Binding<Bool> = Binding.constant(true),
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
source: GIFSource.remoteRequest(request),
|
||||
animate: animate,
|
||||
loop: loop,
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item. The view will use the `ImageLoader` from the environment
|
||||
/// to convert the fetch the `Data`
|
||||
public struct GIFImage: View {
|
||||
public let source: GIFSource
|
||||
public let placeholder: RawImage
|
||||
public let errorImage: RawImage?
|
||||
private let presentationController: PresentationController
|
||||
|
||||
@Environment(\.imageLoader) var imageLoader
|
||||
@State @MainActor private var frame: RawImage?
|
||||
@Binding public var loop: Bool
|
||||
@Binding public var animate: Bool
|
||||
@State private var presentationTask: Task<(), Never>?
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - source: Source of the image. If the source is remote, the response is cached using `URLCache`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
public init(
|
||||
source: GIFSource,
|
||||
animate: Bool,
|
||||
loop: Bool,
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
source: source,
|
||||
animate: .constant(animate),
|
||||
loop: .constant(loop),
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - source: Source of the image. If the source is remote, the response is cached using `URLCache`
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
public init(
|
||||
source: GIFSource,
|
||||
animate: Binding<Bool> = Binding.constant(true),
|
||||
loop: Binding<Bool> = Binding.constant(true),
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.source = source
|
||||
self._animate = animate
|
||||
self._loop = loop
|
||||
self.placeholder = placeholder
|
||||
self.errorImage = errorImage
|
||||
|
||||
self.presentationController = PresentationController(
|
||||
source: source,
|
||||
frameRate: frameRate,
|
||||
animate: animate,
|
||||
loop: loop,
|
||||
action: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Image.loadImage(with: frame ?? placeholder)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.onChange(of: loop) { newValue in
|
||||
handle(loop: newValue)
|
||||
}
|
||||
.onChange(of: animate) { newValue in
|
||||
handle(animate: newValue)
|
||||
}
|
||||
.task(id: source, load)
|
||||
}
|
||||
|
||||
private func handle(animate: Bool) {
|
||||
if animate {
|
||||
load()
|
||||
} else {
|
||||
presentationTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(loop: Bool) {
|
||||
if loop { load() }
|
||||
}
|
||||
|
||||
@Sendable private func load() {
|
||||
presentationTask?.cancel()
|
||||
presentationTask = Task { await presentationController.start(
|
||||
imageLoader: imageLoader,
|
||||
fallbackImage: errorImage ?? placeholder,
|
||||
frameUpdate: setFrame(_:)
|
||||
)}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Sendable private func setFrame(_ frame: RawImage) async {
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let placeholder = RawImage.create(symbol: "photo.circle.fill")!
|
||||
let error = RawImage.create(symbol: "xmark.octagon")
|
||||
let gifURL = "https://raw.githubusercontent.com/igorcferreira/GIFImage/main/Tests/test.gif"
|
||||
#Preview("Raw URL") {
|
||||
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error)
|
||||
}
|
||||
#Preview("Limited 5 FPS") {
|
||||
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error, frameRate: .limited(fps: 5))
|
||||
}
|
||||
#Preview("Limited to 30 FPS") {
|
||||
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error, frameRate: .static(fps: 30))
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
public actor CGImageSourceFrameSequence: AsyncSequence {
|
||||
public typealias Element = ImageFrame
|
||||
|
||||
public enum LoadError: Error {
|
||||
case invalidData
|
||||
case emptyData
|
||||
}
|
||||
|
||||
public let source: CGImageSource
|
||||
|
||||
public init(data: Data) throws {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
throw LoadError.invalidData
|
||||
}
|
||||
|
||||
switch CGImageSourceGetStatus(source) {
|
||||
case .statusComplete: break
|
||||
case .statusReadingHeader: break
|
||||
case .statusIncomplete: throw LoadError.emptyData
|
||||
case .statusInvalidData: throw LoadError.invalidData
|
||||
case .statusUnexpectedEOF: throw LoadError.invalidData
|
||||
case .statusUnknownType: throw LoadError.invalidData
|
||||
@unknown default: throw LoadError.invalidData
|
||||
}
|
||||
|
||||
self.init(source: source)
|
||||
}
|
||||
|
||||
public init(source: CGImageSource) {
|
||||
self.source = source
|
||||
}
|
||||
|
||||
nonisolated public func makeAsyncIterator() -> CGImageSourceIterator {
|
||||
CGImageSourceIterator(source: source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
public actor CGImageSourceIterator: AsyncIteratorProtocol {
|
||||
|
||||
public let frameCount: Int
|
||||
public let source: CGImageSource
|
||||
public private(set) var currentFrame: Int
|
||||
|
||||
public init(source: CGImageSource) {
|
||||
self.source = source
|
||||
self.frameCount = CGImageSourceGetCount(source)
|
||||
self.currentFrame = 0
|
||||
}
|
||||
|
||||
public func next() async throws -> ImageFrame? {
|
||||
guard currentFrame < frameCount else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let frame: ImageFrame? = if let image = CGImageSourceCreateImageAtIndex(source, currentFrame, nil) {
|
||||
ImageFrame(image: image, interval: source.intervalAtIndex(currentFrame))
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
currentFrame += 1
|
||||
return frame
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Object responsible to load a `GIFSource` into a `ImageFrame` stream.
|
||||
public struct ImageLoader {
|
||||
public let session: URLSession
|
||||
public let cache: URLCache
|
||||
public let fileManager: FileManager
|
||||
|
||||
public init(session: URLSession = .shared, cache: URLCache = .shared, fileManager: FileManager = .default) {
|
||||
self.session = session
|
||||
self.cache = cache
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
public func load(source: GIFSource) async throws -> CGImageSourceFrameSequence {
|
||||
let data = try await source.loadData(session: session, cache: cache, fileManager: fileManager)
|
||||
return try data.imageAsyncSequence()
|
||||
}
|
||||
}
|
||||
|
||||
private extension GIFSource {
|
||||
func loadData(session: URLSession, cache: URLCache, fileManager: FileManager) async throws -> Data {
|
||||
switch self {
|
||||
case let .static(data): data
|
||||
case .remote(let url), .remoteURL(let url): try await url.loadData(session: session, cache: cache)
|
||||
case let .remoteRequest(request): try await request.loadData(session: session, cache: cache)
|
||||
case let .local(filePath): try await filePath.loadData(fileManager: fileManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func loadData(fileManager: FileManager) async throws -> Data {
|
||||
guard fileManager.fileExists(atPath: self) else {
|
||||
throw URLError(.fileDoesNotExist)
|
||||
}
|
||||
guard let data = fileManager.contents(atPath: self) else {
|
||||
throw URLError(.cannotOpenFile)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private extension URL {
|
||||
func loadData(session: URLSession, cache: URLCache) async throws -> Data {
|
||||
let request = URLRequest(url: self)
|
||||
return try await request.loadData(session: session, cache: cache)
|
||||
}
|
||||
}
|
||||
|
||||
private extension URLRequest {
|
||||
func loadData(session: URLSession, cache: URLCache) async throws -> Data {
|
||||
if let cache = cache.cachedResponse(for: self) {
|
||||
return cache.data
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: self)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard httpResponse.isSuccess else {
|
||||
throw URLError(.init(rawValue: httpResponse.statusCode))
|
||||
}
|
||||
cache.storeCachedResponse(CachedURLResponse(response: response, data: data), for: self)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private extension HTTPURLResponse {
|
||||
var isSuccess: Bool {
|
||||
200..<300 ~= statusCode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 9/9/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let kDefaultGIFFrameInterval: TimeInterval = 1.0 / 24.0
|
||||
|
||||
struct PresentationController {
|
||||
let source: GIFSource
|
||||
let frameRate: FrameRate
|
||||
let action: (GIFSource) async throws -> Void
|
||||
@Binding var animate: Bool
|
||||
@Binding var loop: Bool
|
||||
|
||||
init(
|
||||
source: GIFSource,
|
||||
frameRate: FrameRate,
|
||||
animate: Binding<Bool>,
|
||||
loop: Binding<Bool>,
|
||||
action: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.source = source
|
||||
self.action = action
|
||||
self.frameRate = frameRate
|
||||
self._animate = animate
|
||||
self._loop = loop
|
||||
}
|
||||
|
||||
func start(imageLoader: ImageLoader, fallbackImage: RawImage, frameUpdate: (RawImage) async -> Void) async {
|
||||
do {
|
||||
repeat {
|
||||
for try await imageFrame in try await imageLoader.load(source: source) {
|
||||
try await update(imageFrame, frameUpdate: frameUpdate)
|
||||
if !animate { break }
|
||||
}
|
||||
try await action(source)
|
||||
} while(self.loop && self.animate)
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
await frameUpdate(fallbackImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func update(_ imageFrame: ImageFrame, frameUpdate: (RawImage) async -> Void) async throws {
|
||||
await frameUpdate(RawImage.create(with: imageFrame.image))
|
||||
let calculatedInterval = imageFrame.interval ?? kDefaultGIFFrameInterval
|
||||
let interval: Double
|
||||
switch frameRate {
|
||||
case .static(let fps):
|
||||
interval = (1.0 / Double(fps))
|
||||
case .limited(let fps):
|
||||
let intervalLimit = (1.0 / Double(fps))
|
||||
interval = max(calculatedInterval, intervalLimit)
|
||||
case .dynamic:
|
||||
interval = imageFrame.interval ?? kDefaultGIFFrameInterval
|
||||
}
|
||||
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000.0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
extension CFString {
|
||||
var asKey: UnsafeMutableRawPointer {
|
||||
return Unmanaged.passUnretained(self).toOpaque()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
extension CGImageSource {
|
||||
func intervalAtIndex(_ index: Int) -> TimeInterval? {
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let gifProperties = CFDictionaryGetValue(properties, kCGImagePropertyGIFDictionary.asKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let dictionary = unsafeBitCast(gifProperties, to: CFDictionary.self)
|
||||
|
||||
let pointer: UnsafeRawPointer
|
||||
if let delay = CFDictionaryGetValue(dictionary, kCGImagePropertyGIFDelayTime.asKey) {
|
||||
pointer = delay
|
||||
} else if let unclampedDelay = CFDictionaryGetValue(dictionary, kCGImagePropertyGIFUnclampedDelayTime.asKey) {
|
||||
pointer = unclampedDelay
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let interval = unsafeBitCast(pointer, to: AnyObject.self).doubleValue ?? 0.0
|
||||
if interval > 0.0 {
|
||||
return interval
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
extension Data {
|
||||
func imageAsyncSequence() throws -> CGImageSourceFrameSequence {
|
||||
return try CGImageSourceFrameSequence(data: self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GIFImageEnvironment: EnvironmentKey {
|
||||
static var defaultValue: ImageLoader = {
|
||||
return ImageLoader()
|
||||
}()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var imageLoader: ImageLoader {
|
||||
get { self[GIFImageEnvironment.self] }
|
||||
set { self[GIFImageEnvironment.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func with(imageLoader: ImageLoader) -> some View {
|
||||
environment(\.imageLoader, imageLoader)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 06/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FrameRate {
|
||||
case dynamic
|
||||
case limited(fps: Int)
|
||||
case `static`(fps: Int)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 29/10/2020.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum GIFSource: Equatable {
|
||||
@available(*, deprecated, renamed: "remoteURL(_:)")
|
||||
case remote(url: URL)
|
||||
case remoteURL(_ url: URL)
|
||||
case remoteRequest(_ request: URLRequest)
|
||||
case local(filePath: String)
|
||||
case `static`(data: Data)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 29/10/2020.
|
||||
//
|
||||
|
||||
import ImageIO
|
||||
import Foundation
|
||||
|
||||
public struct ImageFrame {
|
||||
public let image: CGImage
|
||||
public let interval: TimeInterval?
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
public typealias RawImage = NSImage
|
||||
#else
|
||||
import UIKit
|
||||
public typealias RawImage = UIImage
|
||||
#endif
|
||||
|
||||
public extension RawImage {
|
||||
static func create(with cgImage: CGImage) -> RawImage {
|
||||
#if os(macOS)
|
||||
return RawImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
||||
#else
|
||||
return RawImage(cgImage: cgImage)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func create(symbol: String) -> RawImage? {
|
||||
#if os(macOS)
|
||||
return RawImage(systemSymbolName: symbol, accessibilityDescription: nil)
|
||||
#else
|
||||
return RawImage(systemName: symbol)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public extension Image {
|
||||
static func loadImage(with image: RawImage) -> Image {
|
||||
#if os(macOS)
|
||||
return Image(nsImage: image)
|
||||
#else
|
||||
return Image(uiImage: image)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user