GIF Images in SwiftUI
Reading time: 4 min
This recipe shows how to render GIFs in SwiftUI.
The end result looks like this:
This component is available as a Swift package in this repo.
GIFs are a weird animal in the iOS/macOS world - while CoreGraphics contain ways of extracting GIF-related info from raw data, there's no native support for them in either UIKit or SwiftUI. This recipe will bridge these gaps step-by-step to produce a working GIFImage
SwiftUI View.
Reading GIF frames from data
A GIF image's raw data are all about frames - which image is shown at a certain point in time, and how long is it displayed before another one takes its place. So, the first step involves using CoreGraphics to extract frames from data.
It's fine if you don't deep-dive into this section of code straight away. Besides, there are better (and more complex) implementations available, such as SwiftyGif.
extension UIImage {
class func gifImage(data: Data) -> UIImage? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil)
else {
return nil
}
let count = CGImageSourceGetCount(source)
let delays = (0..<count).map {
// store in ms and truncate to compute GCD more easily
Int(delayForImage(at: $0, source: source) * 1000)
}
let duration = delays.reduce(0, +)
let gcd = delays.reduce(0, gcd)
var frames = [UIImage]()
for i in 0..<count {
if let cgImage = CGImageSourceCreateImageAtIndex(source, i, nil) {
let frame = UIImage(cgImage: cgImage)
let frameCount = delays[i] / gcd
for _ in 0..<frameCount {
frames.append(frame)
}
} else {
return nil
}
}
return UIImage.animatedImage(with: frames,
duration: Double(duration) / 1000.0)
}
}
private func gcd(_ a: Int, _ b: Int) -> Int {
let absB = abs(b)
let r = abs(a) % absB
if r != 0 {
return gcd(absB, r)
} else {
return absB
}
}
private func delayForImage(at index: Int, source: CGImageSource) -> Double {
let defaultDelay = 1.0
let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil)
let gifPropertiesPointer = UnsafeMutablePointer<UnsafeRawPointer?>.allocate(capacity: 0)
defer {
gifPropertiesPointer.deallocate()
}
let unsafePointer = Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque()
if CFDictionaryGetValueIfPresent(cfProperties, unsafePointer, gifPropertiesPointer) == false {
return defaultDelay
}
let gifProperties = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self)
var delayWrapper = unsafeBitCast(CFDictionaryGetValue(gifProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()),
to: AnyObject.self)
if delayWrapper.doubleValue == 0 {
delayWrapper = unsafeBitCast(CFDictionaryGetValue(gifProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()),
to: AnyObject.self)
}
if let delay = delayWrapper as? Double,
delay > 0 {
return delay
} else {
return defaultDelay
}
}
With this in place, let's add one more convenience method for loading GIFs from assets:
extension UIImage {
class func gifImage(name: String) -> UIImage? {
guard let url = Bundle.main.url(forResource: name, withExtension: "gif"),
let data = try? Data(contentsOf: url)
else {
return nil
}
return gifImage(data: data)
}
}
Rendering GIFs in UIKIt
The next step is to create a UIView
that takes the UIImage
with GIF data. The important part here is in the layoutSubviews
override, as it allows for usage of the SwiftUI frame
modifier for our GIFImage
wrapper later on.
class UIGIFImage: UIView {
private let imageView = UIImageView()
private var data: Data?
private var name: String?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
convenience init(name: String) {
self.init()
self.name = name
initView()
}
convenience init(data: Data) {
self.init()
self.data = data
initView()
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = bounds
self.addSubview(imageView)
}
func updateGIF(data: Data) {
imageView.image = UIImage.gifImage(data: data)
}
func updateGIF(name: String) {
imageView.image = UIImage.gifImage(name: name)
}
private func initView() {
imageView.contentMode = .scaleAspectFit
}
}
SwiftUI Wrapper
Lastly, create a simple UIViewRepresentable
wrapper around UIGIFImage
:
struct GIFImage: UIViewRepresentable {
private let data: Data?
private let name: String?
init(data: Data) {
self.data = data
self.name = nil
}
public init(name: String) {
self.data = nil
self.name = name
}
func makeUIView(context: Context) -> UIGIFImage {
if let data = data {
return UIGIFImage(data: data)
} else {
return UIGIFImage(name: name ?? "")
}
}
func updateUIView(_ uiView: UIGIFImage, context: Context) {
if let data = data {
uiView.updateGIF(data: data)
} else {
uiView.updateGIF(name: name ?? "")
}
}
}
All done! Here's some sample code that contains two GIFs, one loaded from local assets, and other from a remote storage:
struct GIFImageTest: View {
@State private var imageData: Data? = nil
var body: some View {
VStack {
GIFImage(name: "preview")
.frame(height: 300)
if let data = imageData {
GIFImage(data: data)
.frame(width: 300)
} else {
Text("Loading...")
.onAppear(perform: loadData)
}
}
}
private func loadData() {
let task = URLSession.shared.dataTask(with: URL(string: "https://github.com/globulus/swiftui-webview/raw/main/Images/preview_macos.gif?raw=true")!) { data, response, error in
imageData = data
}
task.resume()
}
}