Last active
April 6, 2025 15:42
-
-
Save pbk20191/ed6455a205864032c333b3c1faefefd1 to your computer and use it in GitHub Desktop.
Apple Swift Animated Bitmap View
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import UniformTypeIdentifiers | |
import MobileCoreServices | |
struct AnimatingBitmapView: View, Equatable { | |
static func == (lhs: Self, rhs: Self) -> Bool { | |
lhs.param == rhs.param && lhs.label == rhs.label && lhs.paused == rhs.paused && lhs.resizingMode == rhs.resizingMode && lhs.capInsets == rhs.capInsets | |
} | |
@State private var source = AnimationSource?.none | |
private var param:Param | |
var paused:Bool | |
var label:Text | |
var capInsets: EdgeInsets = EdgeInsets() | |
var resizingMode: Image.ResizingMode = .stretch | |
enum Param: Hashable { | |
case data(Data) | |
case url(URL) | |
} | |
@State private var initalized = false | |
var body: some View { | |
Group { | |
if let source { | |
NativeAnimatedImageView(animator: source, paused: paused, label: label, capInsets: capInsets, resizingMode: resizingMode) | |
.equatable() | |
} | |
}.onChange(of: param) { newValue in | |
setup(param: newValue) | |
}.onAppear { | |
if !initalized { | |
initalized = true | |
setup(param: param) | |
} | |
} | |
} | |
private func setup(param:Param) { | |
let cgSource:CGImageSource? | |
switch param { | |
case .data(let data): | |
cgSource = CGImageSourceCreateWithData(data as CFData, nil) | |
case .url(let url): | |
cgSource = CGImageSourceCreateWithURL(url as CFURL, nil) | |
} | |
if let cgSource { | |
source = .init(cgSource) | |
} else { | |
source = nil | |
} | |
} | |
init(data: Data, paused: Bool, label: Text, capInsets: EdgeInsets = EdgeInsets(), | |
resizingMode: Image.ResizingMode = .stretch) { | |
self.param = .data(data) | |
self.paused = paused | |
self.label = label | |
} | |
init(url:URL, paused:Bool, label: Text, capInsets: EdgeInsets = EdgeInsets(), | |
resizingMode: Image.ResizingMode = .stretch) { | |
self.param = .url(url) | |
self.paused = paused | |
self.label = label | |
} | |
} | |
private struct NativeAnimatedImageView: View, Equatable { | |
static func == (lhs: NativeAnimatedImageView, rhs: NativeAnimatedImageView) -> Bool { | |
lhs.animator == rhs.animator && lhs.paused == rhs.paused && lhs.label == rhs.label && lhs.resizingMode == rhs.resizingMode && lhs.capInsets == rhs.capInsets | |
} | |
struct TimingInfo { | |
var startTime: Date | |
var index:Int | |
var current:CGImage | |
let info:AnimationSource | |
// var uttype:String | |
} | |
var animator: AnimationSource | |
var paused: Bool = false | |
var label:Text | |
var capInsets: EdgeInsets = EdgeInsets() | |
var resizingMode: Image.ResizingMode = .stretch | |
@Environment(\.displayScale) private var displayScale | |
@State private var oldDate = ManagedBuffer<TimingInfo?,Void>.create(minimumCapacity: 0) { _ in | |
nil | |
} | |
var body: some View { | |
TimelineView(.animation(minimumInterval: animator.minDelay, paused: paused)) { time in | |
if let cgImage = evaluate(time, &oldDate.header) { | |
Image(cgImage, scale: displayScale, label: label) | |
.resizable() | |
} | |
} | |
.onChange(of: animator.source) { _ in | |
// time.invalidateTimelineContent() | |
oldDate.header = nil | |
} | |
} | |
func updateImage(_ cgImage:CGImage?) -> CGImage? { | |
guard let cgImage else { return nil } | |
if cgImage.alphainfo != .noneSkipFirst && cgImage.alphainfo != .noneSkipLast { | |
return cgImage | |
} | |
var bitInfo = cgImage.bitmapInfo | |
bitInfo.subtract(.alphaInfoMask) | |
if cgImage.alphainfo == .noneSkipFirst { | |
bitInfo += .init(rawValue: CGImageAlphaInfo.first.rawValue) | |
} else { | |
bitInfo += .init(rawValue: CGImageAlphaInfo.last.rawValue) | |
} | |
return CGImage( | |
width: cgImage.width, | |
height: cgImage.height, | |
bitsPerComponent: cgImage.bitsPerComponent, | |
bitsPerPixel: cgImage.bitsPerPixel, | |
bytesPerRow: cgImage.bytesPerRow, | |
space: cgImage.colorSpace!, | |
bitmapInfo: bitInfo, | |
provider: cgImage.dataProvider!, | |
decode: cgImage.decode, | |
shouldInterpolate: cgImage.shouldInterpolate, | |
intent: cgImage.renderingIntent | |
) | |
} | |
func evaluate(_ context: TimelineViewDefaultContext, _ state: inout TimingInfo?) -> CGImage? { | |
guard let snapShot = state else { | |
// AnimationTimelineSchedule.animation(minimumInterval: nil) | |
// CGImageSourceCopy | |
let info = animator | |
// let k2 = CGImageSourceCopyProperties(animator.source, nil) as? [String:Any] | |
var cgImage = CGImageSourceCreateImageAtIndex(animator.source, 0, [kCGImageSourceShouldCache: true] as CFDictionary) | |
cgImage = updateImage(cgImage) | |
if let cgImage { | |
// let prop = CGImageSourceCopyPropertiesAtIndex(animator.source, 0, nil) as! [String:Any] | |
// let delay = info.frames[0].unclampDelay | |
state = .init(startTime: context.date, index: 0, current: cgImage, info: info) | |
return cgImage | |
} | |
return nil | |
} | |
if snapShot.startTime.advanced(by: snapShot.info.frames[snapShot.index].unclampDelay) > context.date { | |
return snapShot.current | |
} else { | |
// var cgImage = CGImageSourceCreateImageAtIndex(snapShot.info.source, snapShot.index+1, nil) | |
if snapShot.index+1 < snapShot.info.count, let cgImage = updateImage(CGImageSourceCreateImageAtIndex(snapShot.info.source, snapShot.index+1, [kCGImageSourceShouldCache: true] as CFDictionary)) { | |
// let prop = CGImageSourceCopyPropertiesAtIndex(snapShot.info.source, snapShot.index+1, nil) as! [String:Any] | |
state = .init(startTime: context.date, index: snapShot.index+1, current: cgImage, info: snapShot.info) | |
return cgImage | |
} else { | |
// reset | |
if let cgImage = updateImage(CGImageSourceCreateImageAtIndex(snapShot.info.source, 0, [kCGImageSourceShouldCache: true] as CFDictionary)) { | |
state = .init(startTime: context.date, index: 0, current: cgImage, info: snapShot.info) | |
return cgImage | |
} | |
state = nil | |
return nil | |
} | |
} | |
} | |
} | |
enum FrameInfoKey { | |
case png | |
case webp | |
case heics | |
case gif | |
case avif | |
var arrayKey:CFString { | |
switch self { | |
case .png: | |
return kCGImagePropertyAPNGFrameInfoArray | |
case .gif: | |
return kCGImagePropertyGIFFrameInfoArray | |
case .heics: | |
return kCGImagePropertyHEICSFrameInfoArray | |
case .webp: | |
return kCGImagePropertyWebPFrameInfoArray | |
case .avif: | |
return "FrameInfo" as CFString | |
} | |
} | |
var propKey:CFString { | |
switch self { | |
case .png: | |
return kCGImagePropertyPNGDictionary | |
case .webp: | |
return kCGImagePropertyWebPDictionary | |
case .heics: | |
return kCGImagePropertyHEICSDictionary | |
case .gif: | |
return kCGImagePropertyGIFDictionary | |
case .avif: | |
if #available(iOS 16.0, tvOS 16.0, macOS 13.0, visionOS 1.0, watchOS 9.0, macCatalyst 16.0, *) { | |
return kCGImagePropertyAVISDictionary | |
} | |
return "{AVIS}" as CFString | |
} | |
} | |
var type:CFString { | |
switch self { | |
case .png: | |
return kUTTypePNG | |
case .webp: | |
return "org.webmproject.webp" as CFString | |
case .heics: | |
return "public.heics" as CFString | |
case .gif: | |
return kUTTypeGIF | |
case .avif: | |
return "public.avis" as CFString | |
} | |
} | |
var delayKey:CFString { | |
switch self { | |
case .png: | |
return kCGImagePropertyAPNGDelayTime | |
case .webp: | |
return kCGImagePropertyWebPDelayTime | |
case .heics: | |
return kCGImagePropertyHEICSDelayTime | |
case .gif: | |
return kCGImagePropertyGIFDelayTime | |
case .avif: | |
return "DelayTime" as CFString | |
} | |
} | |
var unclampDelayKey:CFString { | |
switch self { | |
case .png: | |
return kCGImagePropertyAPNGUnclampedDelayTime | |
case .webp: | |
return kCGImagePropertyWebPUnclampedDelayTime | |
case .heics: | |
return kCGImagePropertyHEICSUnclampedDelayTime | |
case .gif: | |
return kCGImagePropertyGIFUnclampedDelayTime | |
case .avif: | |
return "UnclampedDelayTime" as CFString | |
} | |
} | |
var widthKey:CFString { | |
switch self { | |
case .png: | |
return kCGImagePropertyAPNGCanvasPixelWidth | |
case .webp: | |
return kCGImagePropertyWebPCanvasPixelWidth | |
case .heics: | |
return kCGImagePropertyHEICSCanvasPixelWidth | |
case .gif: | |
return kCGImagePropertyGIFCanvasPixelWidth | |
case .avif: | |
return "CanvasPixelWidth" as CFString | |
} | |
} | |
var heightKey:CFString { | |
switch self { | |
case .png: | |
return kCGImagePropertyAPNGCanvasPixelHeight | |
case .webp: | |
return kCGImagePropertyWebPCanvasPixelHeight | |
case .heics: | |
return kCGImagePropertyHEICSCanvasPixelHeight | |
case .gif: | |
return kCGImagePropertyGIFCanvasPixelHeight | |
case .avif: | |
return "CanvasPixelHeight" as CFString | |
} | |
} | |
} | |
private struct AnimationSource: Equatable, Hashable { | |
static func == (lhs: AnimationSource, rhs: AnimationSource) -> Bool { | |
lhs.source == rhs.source | |
} | |
func hash(into hasher: inout Hasher) { | |
hasher.combine(source) | |
} | |
struct FrameInfo { | |
let unclampDelay:TimeInterval | |
let delay:TimeInterval | |
} | |
let source:CGImageSource | |
let count:Int | |
// let delay:ContiguousArray<TimeInterval> | |
let frames:ContiguousArray<FrameInfo> | |
let size:CGSize | |
let minDelay:TimeInterval? | |
// let size:CGSize | |
init(_ source:CGImageSource) { | |
self.source = source | |
self.count = CGImageSourceGetCount(source) | |
let contentKey:FrameInfoKey? | |
switch CGImageSourceGetType(source) as String? { | |
case UTType.png.identifier: | |
contentKey = .png | |
case UTType.webP.identifier: | |
contentKey = .webp | |
case UTType.gif.identifier: | |
contentKey = .gif | |
case "public.heics": | |
contentKey = .heics | |
case "public.avis": | |
contentKey = .avif | |
default: | |
contentKey = nil | |
} | |
if let contentKey { | |
let prop = (CGImageSourceCopyProperties(source, nil) as? [String:Any] ?? [:]) | |
let table = prop[contentKey.propKey as String] as? [String:Any] ?? [:] | |
let frameArray = table[contentKey.arrayKey as String] as? Array<[String:Any]> ?? [] | |
let width = (table[contentKey.widthKey as String] as? Double) ?? 0 | |
let height = (table[contentKey.heightKey as String] as? Double) ?? 0 | |
var delayValue = -1 | |
self.frames = frameArray.reduce(into: []){ a, b in | |
var unclamp = (b[contentKey.unclampDelayKey as String] as? Double) ?? 0 | |
let delay = (b[contentKey.delayKey as String] as? Double) ?? 0 | |
if !b.keys.contains(contentKey.unclampDelayKey as String) { | |
unclamp = delay | |
} | |
if delayValue == -1 { | |
delayValue = Int(unclamp * 1000) | |
} else { | |
delayValue = gcdForPair(delayValue, Int(unclamp * 1000)) | |
} | |
a.append(.init(unclampDelay: unclamp, delay: delay)) | |
} | |
self.size = .init(width: width, height: height) | |
self.minDelay = Double(delayValue)/1000 | |
} else { | |
self.size = .zero | |
self.minDelay = nil | |
//delay = [] | |
frames = [] | |
} | |
} | |
} | |
private func gcdForPair(_ a: Int, _ b: Int) -> Int { | |
var a = a, b = b | |
if a < b { swap(&a, &b) } | |
while true { | |
guard a % b > 0 else { return b } | |
a = b | |
b = a % b | |
} | |
} | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import SwiftUI | |
import ImageIO | |
struct InfiniteAnimationImageView: View, Equatable { | |
static func == (lhs: Self, rhs: Self) -> Bool { | |
lhs.param == rhs.param && lhs.label == rhs.label | |
} | |
enum Param: Hashable { | |
case data(Data) | |
case url(URL) | |
} | |
struct ContextData { | |
var ids = 0 | |
var index = 0 | |
var pausing = false | |
} | |
private var ids:Int { | |
get { context.header.ids } | |
nonmutating _modify { yield &context.header.ids } | |
nonmutating set { context.header.ids = newValue } | |
} | |
private var index:Int { | |
get { context.header.index } | |
nonmutating _modify { yield &context.header.index } | |
nonmutating set { context.header.index = newValue } | |
} | |
private var pausing:Bool { | |
get { context.header.pausing } | |
nonmutating _modify { yield &context.header.pausing } | |
nonmutating set { context.header.pausing = newValue } | |
} | |
@State private var context:ManagedBuffer<ContextData,Void> = .create(minimumCapacity: 0) { _ in | |
.init() | |
} | |
@State private var cgImage:CGImage? | |
@Environment(\.displayScale) private var displayScale | |
@Environment(\.imageScale) private var imageScale | |
var param:Param | |
var label:Text | |
var body: some View { | |
Group { | |
if let cgImage { | |
Image(cgImage, scale: displayScale, label: label) | |
.resizable() | |
} else { | |
Spacer() | |
.frame(width: 0, height: 0) | |
} | |
}.onAppear { | |
self.pausing = false | |
launchAnimation(param) | |
}.onChange(of: param) { newValue in | |
index = 0 | |
launchAnimation(newValue) | |
}.onDisappear { | |
self.pausing = true | |
}.frame(idealWidth: width ?? 0, idealHeight: height ?? 0) | |
} | |
private var width:CGFloat? { | |
if let cgImage { | |
return CGFloat(cgImage.width) / displayScale | |
} | |
return nil | |
} | |
private var height:CGFloat? { | |
if let cgImage { | |
return CGFloat(cgImage.height) / displayScale | |
} | |
return nil | |
} | |
private func launchAnimation(_ param:Param) { | |
ids &+= 1 | |
let snapId = ids | |
let block:CGImageSourceAnimationBlock = { index, cgImage, exiter in | |
guard ids == snapId else { | |
exiter.pointee = true | |
return | |
} | |
self.index = index | |
self.cgImage = cgImage | |
exiter.pointee = self.pausing | |
} | |
var dictionary = [:] as [String:Any] | |
dictionary[kCGImageAnimationStartIndex as String] = index | |
switch param { | |
case .data(let data): | |
CGAnimateImageDataWithBlock(data as CFData, dictionary as CFDictionary, block) | |
break | |
case .url(let url): | |
CGAnimateImageAtURLWithBlock(url as CFURL, dictionary as CFDictionary, block) | |
break | |
} | |
} | |
init(url:URL, label:Text) { | |
self.param = .url(url) | |
self.label = label | |
} | |
init(data:Data, label:Text) { | |
self.param = .data(data) | |
self.label = label | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment