Skip to content

Instantly share code, notes, and snippets.

@pbk20191
Last active April 6, 2025 15:42
Show Gist options
  • Save pbk20191/ed6455a205864032c333b3c1faefefd1 to your computer and use it in GitHub Desktop.
Save pbk20191/ed6455a205864032c333b3c1faefefd1 to your computer and use it in GitHub Desktop.
Apple Swift Animated Bitmap View
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
}
}
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