Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ let package = Package(
.target(
name: "ThorVGSwift",
dependencies: ["thorvg"],
path: "swift"
path: "swift",
resources: [.process("Resources")]
),
.target(
name: "thorvg",
Expand Down
4 changes: 4 additions & 0 deletions swift/Lottie.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ public class Lottie {
self.numberOfFrames = animation.getNumberOfFrames()
self.duration = animation.getDuration()
}

public func getSize() -> CGSize {
animation.getPicture().getSize()
}
}
48 changes: 48 additions & 0 deletions swift/LottieUIKitView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import UIKit
import Combine

/// TODO: ....
public class LottieUIKitView: UIView {
private let imageView: UIImageView
private var viewModel: LottieViewModel
private var cancellables = Set<AnyCancellable>()

/// TODO: ....
public init(lottie: Lottie) {
self.viewModel = LottieViewModel(lottie: lottie, size: lottie.getSize())
self.imageView = UIImageView()
super.init(frame: .zero)
setupView()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

private func setupView() {
imageView.contentMode = .scaleAspectFit
addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor)
])
}

/// TODO: ....
public func startAnimating() {
viewModel.startAnimating()
viewModel.$renderedFrame
.sink { [weak self] image in
self?.imageView.image = image
}
.store(in: &cancellables)
Comment on lines +37 to +41
Copy link

Copilot AI Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subscription to viewModel.$renderedFrame is created every time startAnimating() is called, but previous subscriptions are not cancelled. This will create multiple subscriptions and potential memory leaks. Consider cancelling existing subscriptions first or moving this subscription to setupView().

Copilot uses AI. Check for mistakes.

}

/// TODO: ....
public func stopAnimating() {
viewModel.stopAnimating()
}
}
54 changes: 54 additions & 0 deletions swift/LottieView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import SwiftUI

/// TODO: ....
@available(iOS 14.0, *)
public struct LottieView: View {

@StateObject private var viewModel: LottieViewModel

// TODO: Later feature - be dynamic with size.

/// TODO: ....
public init(lottie: Lottie) {
let viewModel = LottieViewModel(
lottie: lottie,
size: lottie.getSize()
)
_viewModel = StateObject(wrappedValue: viewModel)
}

@ViewBuilder private func content() -> some View {
if let image = viewModel.renderedFrame {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else {
Color.clear
}
}

/// TODO: ....
public var body: some View {
content()
.onAppear {
viewModel.startAnimating()
}
.onDisappear {
viewModel.stopAnimating()
}
}
}

#Preview {
if let path = Bundle.module.path(forResource: "test", ofType: "json"),
let lottie = try? Lottie(path: path) {

if #available(iOS 14.0, *) {
LottieView(lottie: lottie)
} else {
Text("Unsupported iOS Version.")
}
} else {
Text("Failed to load Lottie file.")
}
}
113 changes: 113 additions & 0 deletions swift/LottieViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Combine
import SwiftUI

/// TODO: ....
class LottieViewModel: ObservableObject {
private let totalFrames: Float
private let size: CGSize
private var buffer: [UInt32]
private let renderer: LottieRenderer

private var timer: AnyCancellable?

private var currentFrame: Float = 0
private let frameRate: Double = 30.0

@Published var renderedFrame: UIImage? = nil

// TODO: Handle different engine types.
init(lottie: Lottie, size: CGSize) {
var buffer = [UInt32](repeating: 0, count: Int(size.width * size.height))
self.renderer = LottieRenderer(
lottie,
engine: .main,
size: size,
buffer: &buffer,
stride: Int(size.width),
pixelFormat: .argb
)
self.buffer = buffer
self.totalFrames = lottie.numberOfFrames
self.size = size
}

func startAnimating() {
let frameDuration = 1.0 / frameRate
timer = Timer.publish(every: frameDuration, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.renderNextFrame()
}
}

func stopAnimating() {
timer?.cancel()
timer = nil
}

// TODO: Handle errors propery here.
Copy link

Copilot AI Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a typo in the comment: 'propery' should be 'properly'.

Suggested change
// TODO: Handle errors propery here.
// TODO: Handle errors properly here.

Copilot uses AI. Check for mistakes.

private func renderNextFrame() {
guard currentFrame < totalFrames else {
currentFrame = 0
return
}

let contentRect = CGRect(x: 0, y: 0, width: size.width, height: size.height)

do {
try renderer.render(frameIndex: Float(currentFrame), contentRect: contentRect)
} catch {
print(error)
fatalError("Rendering error.")
Comment on lines +60 to +61
Copy link

Copilot AI Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message 'Rendering error.' is too generic and doesn't provide useful debugging information. Consider including the actual error details or a more descriptive message.

Suggested change
print(error)
fatalError("Rendering error.")
fatalError("Rendering error: \(error)")

Copilot uses AI. Check for mistakes.

}



if let image = UIImage(buffer: &buffer, size: size, pixelFormat: .argb) {
renderedFrame = image
currentFrame += 1
} else {
print("UI IMAGE CAST ERROR")
fatalError("UIImage cast error.")
Comment on lines +70 to +71
Copy link

Copilot AI Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error messages are inconsistent in style (all caps vs sentence case) and not descriptive. Consider using a consistent format and providing more context about what failed during the UIImage creation.

Suggested change
print("UI IMAGE CAST ERROR")
fatalError("UIImage cast error.")
print("Failed to create UIImage from buffer. Buffer count: \(buffer.count), size: \(size), pixelFormat: .argb")
fatalError("Failed to create UIImage from buffer with size \(size) and pixelFormat .argb.")

Copilot uses AI. Check for mistakes.

}
}
}

extension UIImage {
convenience init?(buffer: Buffer, size: CGSize, pixelFormat: PixelFormat) {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = pixelFormat.bitmapInfo.rawValue
let bitsPerComponent = 8
let bytesPerRow = Int(size.width) * 4

guard let context = CGContext(
data: buffer,
width: Int(size.width),
height: Int(size.height),
bitsPerComponent: bitsPerComponent,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: bitmapInfo
) else {
return nil
}

guard let cgImage = context.makeImage() else {
return nil
}

self.init(cgImage: cgImage, scale: 1.0, orientation: .up)
}
}

extension PixelFormat {
var bitmapInfo: CGBitmapInfo {
switch self {
case .argb:
return [.byteOrder32Little, CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)]
case .abgr:
return [.byteOrder32Big, CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)]
}
}
}

1 change: 1 addition & 0 deletions swift/Resources/test.json

Large diffs are not rendered by default.