Reading time: 4 min

This recipe shows how to render GIFs in SwiftUI. The end result looks like this:

Preview iOS

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()
  }
}

Next Post Previous Post