Last active
August 16, 2024 06:03
-
-
Save swiftui-lab/aa5d73b81c8696dee4a5996954b22e5c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment