Animating gif
iOS gif animations SwiftUI Combine ImageIO Estimated reading time: 10 minutesI like animation a lot. Sometimes we would like to animate some complex movement of objects, and writing animation from the scratch can be a time-consuming process. As result, we can use an animated image - gif.
There are a lot of engines (free and paid) like Lottie that can help a lot within playing such a format. But, sometimes we don’t want to use a plane for crossing a road, and a small and elegant solution can help a lot.
Luckily for us, iOS has a quite good engine (finally!) that can help a lot within gif - ImageIO.
Withs ImageIO
we have all the tools we need for such operations - we can do everything on our own, or use one of the available func for automated gif animation.
automated animations come into play starting from iOS 13
Manual animating
This option requires a bit more work from our side, but, at the same moment, we control every aspect of the process. This is great.
Of cause, we will work with CoreGraphics
objects such as CGImage
, CFData
, CGImageSource
, and others. Be ready to make your hands dirty - as usual, with great functionality, Apple gives poor documentation, so we will dive into the code and experiment a bit.
We can start by getting data that contains gif information. This data can be obtained in different ways - from a network or disk. Let’s start by assuming that we have our gif on the hard drive. So all that needs to be done - read the data of the gif file as Data
and convert it into CFData
(simple case as CFData
), then, we can create CGImageSource
- object that abstract the data-reading task:
if let resourceURL = bundle.url(
forResource: named,
withExtension: Constants.Extension.gif
) {
let data = try Data(contentsOf: resourceURL)
if let source = CGImageSourceCreateWithData(data as CFData, nil) {
if let gif = GifPlayer.animatedImageWithSource(source) {
return gif
} else {
throw GifFailure.invalidSetOfImages
}
} else {
throw GifFailure.noSourceData
}
} else {
throw GifFailure.noSourceFile
}
Now, using CGImageSource
, we can get all required information - duration and frames. To do so, we should find the number of frames
CGImageSourceGetCount(source)
then, for each frame we should get CGImage
by calling CGImageSourceCreateImageAtIndex
and duration for frame. To get duration, we should read properties from data and find the desired key with a value.
let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil)
let gifProperties: CFDictionary = unsafeBitCast(
CFDictionaryGetValue(
cfProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque()
),
to: CFDictionary.self
)
var delayObject: AnyObject = unsafeBitCast(
CFDictionaryGetValue(
gifProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()
),
to: AnyObject.self
)
if delayObject.doubleValue == 0 {
delayObject = unsafeBitCast(
CFDictionaryGetValue(
gifProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()
),
to: AnyObject.self
)
}
With this information, we have all the components for proper animation. The question - is how to do this animation. One way - is to calculate the total gif duration and use UIImage
type method animatedImage(with:duration:)
.
The possible code for this may look like next:
let count = CGImageSourceGetCount(source)
let images = (0..<count)
.compactMap({ CGImageSourceCreateImageAtIndex(source, $0, nil) })
let delaysInMiliseconds = (0..<count)
.map({ GifPlayer.delayForImageAtIndex($0, source: source) })
.map { Int($0 * 1000.0) }
let durationInSeconds = Double(delaysInMiliseconds.reduce(0, +)) / 1000.0
let delay = delaysInMiliseconds.compactMap({ $0 }).max() ?? 1
let frames: [[UIImage]] = (0...images.count-1).map({
let image = UIImage(cgImage: images[$0])
let framesPerImage = Int(delaysInMiliseconds[$0] / delay)
return [UIImage].init(repeating: image, count: framesPerImage)
})
let animatedImages = frames.flatMap({ $0 })
let animation = UIImage.animatedImage(
with: animatedImages,
duration: durationInSeconds
)
UIKit
The logic part is completed. But We still should somehow display this on UI. In UIKit
, we can simply create UIImageView
and set an image for it:
let image = try? GifPlayer.gif(named: name)
self.imageView.image = image
self.view.addSubview(imageView)
UIKit
is great, but, now we have a deal with SwiftUI
. The naive approach for using the code above can be a simple View
that conforms to UIViewRepresentable
:
struct GifView: UIViewRepresentable {
let name: String
func makeUIView(context: Context) -> UIView {
UIView()
}
func updateUIView(_ uiView: UIView, context: Context) {
let image = try? GifPlayer.gif(named: name)
let imageView: UIImageView = UIImageView(image: image)
uiView.addSubview(imageView)
}
}
This approach works, but, we can’t resize the gif, and we can’t control animating speed. So such an approach is not very useful.
SwiftUI
A good solution for us should provide an option to be used in SwiftUI
and provide an option to control its speed.
First of all, we should wrap the logic, related to extracting info from gif data into a separate component GifDataProvider
that can extract all required data as a simple model GifData
:
struct GifData {
let images: [Image]
let duration: Double
}
The next step - is to create a ViewModifier
, that can animate change of the image using data from a provider. To make something animatable, we can use Animatable
protocol.
I wrote an article about
Animatable
here.
If we use ViewModifier
and Animatable
we can simply use AnimatableModifier
. To create the one, that can animate images from the gif, we need a few components: images, duration, and progress. Using these 3 components, we can animate change of images:
struct GifAnimatableModifier: AnimatableModifier {
private let images: [Image]
private let duration: TimeInterval
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
init(
images: [Image],
duration: TimeInterval,
progress: Double
) {
self.progress = progress
self.images = images
self.duration = duration
}
func body(content: Content) -> some View {
content
.overlay(
imageForProgress(progress)
.resizable()
)
}
private func imageForProgress(_ progress: Double) -> Image {
let durationPerImage = duration / Double(images.count)
let currentTime = progress * duration
let currentImage = Int(currentTime / durationPerImage)
let idx = max(min(images.count-1, currentImage), 0)
let image = images[idx]
return image
}
}
.resizable()
is needed for correctly responds to frame change
And the last components - is a View
that wraps for our usage of this GifAnimatableModifier
:
public struct Gif: View {
public enum Duration {
case `default`
case custom(Double)
}
private let duration: Duration
private let images: [Image]
private let originalDuration: TimeInterval
internal init(
name: String,
bundle: Bundle,
duration: Gif.Duration
) {
self.duration = duration
let gifDataProvider = GifDataProvider(name: name, bundle: bundle)
if let gifData = try? gifDataProvider.read() {
self.originalDuration = gifData.duration
self.images = gifData.images
} else {
self.originalDuration = 0
self.images = []
}
}
@State private var flag: Bool = false
private var animation: Animation {
switch duration {
case .custom(let duration):
return Animation.linear(duration: duration)
.repeatForever(autoreverses: false)
case .default:
return Animation.linear(duration: originalDuration)
.repeatForever(autoreverses: false)
}
}
public var body: some View {
Rectangle()
.modifier(
GifAnimatableModifier(
images: images,
duration: originalDuration,
progress: flag ? 1 : 0
)
)
.onAppear(perform: {
withAnimation(animation) {
flag.toggle()
}
})
}
}
Here u can see a small trick, that allows repeat animation forever (as gifs do). I also add additional Duration
enum, that helps to customize the playing duration of the gif.
The final usage will be next:
// somewhere in the body for SwiftUI view
Gif(name: "giphy", bundle: .main, duration: .default)
.frame(width: 250, height: 200)
And the result:

We also has an option to control speed of the gif:

Looks good. We of cause can improve a bit the process of timing - for now, I assumed that the delay between each frame is the same. But, in the real world, this can be different. In this case, we should modify our GifDataProvider
and provide pair of images and delay for each frame. Of cause, in this case, the logic of frame selection in AnimatedModifier
will bring some additional complexity.
Automated animations
The good news is that starting from iOS 13 ImageIO
has additional tools, that allow doing part of the work above automatically.
The interesting part for use is placed in ImageIO.CGImageAnimation
header. There u can find CGAnimateImageData...
functions that are specifically created for animating gif and apng formats. They also allow pausing the animation.
The initial part of the work is still the same - we should obtain gif data and then, process it. The processing can be done as follow:
func animateWithFrameHandle(_ handle: @escaping (Int, CGImage) -> ()) -> OSStatus {
let status: OSStatus = CGAnimateImageDataWithBlock(data, dictinary(), { idx, image, value in
value.pointee = self.stop
handle(idx, image)
})
return status
}
where dictionary()
- is a set of settings needed for animation:
[
kCGImageAnimationStartIndex: 0,
kCGImageAnimationDelayTime: delay * speed,
kCGImageAnimationLoopCount: kCFNumberPositiveInfinity as Any
] as CFDictionary
We can pass nil
as options - if no options are provided - then, a default will be used.
The complete code for this gif animator is next:
import ImageIO.CGImageAnimation
final class GifImageAnimator {
enum GifFailure: Error {
case noSourceFile
}
enum Extension {
static let gif = "gif"
}
private let name: String
private let bundle: Bundle
private let data: CFData
private var stop: Bool = false
private let speed: Double
init(name: String, bundle: Bundle, speed: Double = 1) throws {
self.bundle = bundle
self.name = name
self.speed = speed
if let resourceURL = bundle.url(forResource: name, withExtension: Extension.gif) {
let data = try Data(contentsOf: resourceURL)
self.data = data as CFData
} else {
throw GifFailure.noSourceFile
}
}
func stopPlaying() {
self.stop = true
}
func animateWithFrameHandle(_ handle: @escaping (Int, CGImage) -> ()) -> OSStatus {
let status: OSStatus = CGAnimateImageDataWithBlock(data, nil, { idx, image, value in
value.pointee = self.stop
handle(idx, image)
})
return status
}
private func dictinary() -> CFDictionary {
var delay = 0.1
if let source = CGImageSourceCreateWithData(data as CFData, nil) {
let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil)
let gifProperties: CFDictionary = unsafeBitCast(
CFDictionaryGetValue(
cfProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque()
),
to: CFDictionary.self
)
var delayObject: AnyObject = unsafeBitCast(
CFDictionaryGetValue(
gifProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()
),
to: AnyObject.self
)
if delayObject.doubleValue == 0 {
delayObject = unsafeBitCast(
CFDictionaryGetValue(
gifProperties,
Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()
),
to: AnyObject.self
)
}
delay = max(delayObject as? Double ?? 0, 0.1)
}
return [
kCGImageAnimationStartIndex: 0,
kCGImageAnimationDelayTime: delay * speed,
kCGImageAnimationLoopCount: kCFNumberPositiveInfinity as Any
] as CFDictionary
}
}
Now, we should somehow observe the changes produced by CGAnimateImageDataWithBlock
. And here, Combine
can be used:
import Combine
import UIKit
final class GifAnimator: ObservableObject {
private let animator: GifImageAnimator
@Published var image: Image?
@Published var isFailure: Bool = false
init(name: String, bundle: Bundle, speed: Double = 1) throws {
animator = try .init(name: name, bundle: bundle, speed: speed)
}
func startAnimating() {
let status = animator.animateWithFrameHandle { _, frame in
self.image = Image(uiImage: .init(cgImage: frame))
}
if status != 0 {
isFailure = true
}
}
func stopAnimating() {
animator.stopPlaying()
}
}
And of cause, we can wrap it into reusable SwiftUI
View
:
import SwiftUI
public struct GifPlayerView: View {
@ObservedObject private var imageAnimator: GifAnimator
public init(name: String, bundle: Bundle, speed: Double = 1) throws {
imageAnimator = try .init(name: name, bundle: bundle, speed: speed)
}
public var body: some View {
VStack {
imageAnimator.image?
.resizable()
}
.onAppear {
imageAnimator.startAnimating()
}
.onDisappear {
imageAnimator.stopAnimating()
}
}
}
Then, we can use it as next:
// somewhere in the body
try? GifPlayerView(name: "giphy", bundle: .main, speed: 1)
.frame(width: 250, height: 200)
this produce an optional view for
body
And the result:

Resources
Share on: