// Author: The SwiftUI-Lab // This code is part of the tutorial: https://swiftui-lab.com/swiftui-animations-part4/ import SwiftUI // Sample usage struct ContentView: View { var body: some View { VStack { GifImage(url: URL(string: "https://media.giphy.com/media/YAlhwn67KT76E/giphy.gif?cid=790b7611b26260b2ad23535a70e343e67443ff80ef623844&rid=giphy.gif&ct=g")!) .padding(10) .overlay { RoundedRectangle(cornerRadius: 8) .stroke(.green) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // ObservableObject that holds the data and logic to get all frames in the gif image. class GifData: ObservableObject { var loopCount: Int = 0 var width: CGFloat = 0 var height: CGFloat = 0 var capInsets: EdgeInsets? var resizingMode: Image.ResizingMode struct ImageFrame { let image: Image let delay: TimeInterval } var frames: [ImageFrame] = [] init(url: URL, capInsets: EdgeInsets?, resizingMode: Image.ResizingMode) { self.capInsets = capInsets self.resizingMode = resizingMode let label = url.deletingPathExtension().lastPathComponent Task { guard let (data, _) = try? await URLSession.shared.data(from: url) else { return } guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return } let imageCount = CGImageSourceGetCount(source) guard let imgProperties = CGImageSourceCopyProperties(source, nil) as? Dictionary<String, Any> else { return } guard let gifProperties = imgProperties[kCGImagePropertyGIFDictionary as String] as? Dictionary<String, Any> else { return } loopCount = gifProperties[kCGImagePropertyGIFLoopCount as String] as? Int ?? 0 width = gifProperties[kCGImagePropertyGIFCanvasPixelWidth as String] as? CGFloat ?? 0 height = gifProperties[kCGImagePropertyGIFCanvasPixelHeight as String] as? CGFloat ?? 0 let frameInfo = gifProperties[kCGImagePropertyGIFFrameInfoArray as String] as? [Dictionary<String, TimeInterval>] ?? [] for i in 0 ..< min(imageCount, frameInfo.count) { if let image = CGImageSourceCreateImageAtIndex(source, i, nil) { var img = Image(image, scale: 1.0, label: Text(label)) if let insets = capInsets { img = img.resizable(capInsets: insets, resizingMode: resizingMode) } frames.append( ImageFrame(image: img, delay: frameInfo[i][kCGImagePropertyGIFDelayTime as String] ?? 0.05) ) } } DispatchQueue.main.async { self.objectWillChange.send() } } } } // The GifImage view struct GifImage: View { @StateObject var gifData: GifData /// Create an animated Gif Image /// - Parameters: /// - url: the url holding the animated gif file /// - capInsets: if nil, image is not resizable. Otherwise, the capInsets for image resizing (same as the standard image .resizable() modifier). /// - resizingMode: ignored if capInsets is nil, otherwise, equivalent to the standard image .resizable() modifier parameter) init(url: URL, capInsets: EdgeInsets? = nil, resizingMode: Image.ResizingMode = .stretch) { _gifData = StateObject(wrappedValue: GifData(url: url, capInsets: capInsets, resizingMode: resizingMode)) } var body: some View { Group { if gifData.frames.count == 0 { Color.clear } else { VStack { TimelineView(.cyclic(loopCount: gifData.loopCount, timeOffsets: gifData.frames.map { $0.delay })) { timeline in ImageFrame(gifData: gifData, date: timeline.date) } } } } } struct ImageFrame: View { @State private var frame = 0 let gifData: GifData let date: Date var body: some View { gifData.frames[frame].image .onChange(of: date) { _ in frame = (frame + 1) % gifData.frames.count } } } } // A cyclic TimelineSchedule struct CyclicTimelineSchedule: TimelineSchedule { let loopCount: Int // loopCount == 0 means inifinite loops. let timeOffsets: [TimeInterval] func entries(from startDate: Date, mode: TimelineScheduleMode) -> Entries { Entries(loopCount: loopCount, last: startDate, offsets: timeOffsets) } struct Entries: Sequence, IteratorProtocol { let loopCount: Int var loops = 0 var last: Date let offsets: [TimeInterval] var idx: Int = -1 mutating func next() -> Date? { idx = (idx + 1) % offsets.count if idx == 0 { loops += 1 } if loopCount != 0 && loops >= loopCount { return nil } last = last.addingTimeInterval(offsets[idx]) return last } } } extension TimelineSchedule where Self == CyclicTimelineSchedule { static func cyclic(loopCount: Int, timeOffsets: [TimeInterval]) -> CyclicTimelineSchedule { .init(loopCount: loopCount, timeOffsets: timeOffsets) } }