I am facing challenge in displaying multiple lottie animations on screen without causing memory spikes and performance issue.
Basically, I load and display multiple Lotties (json) in a grid as stickers:
import SwiftUI
import Lottie
struct StickersPackCollectionView: View
{
private let columns = Array(repeating: GridItem(.flexible(), spacing: 1), count: 5)
var body: some View
{
ScrollView
{
LazyVGrid(columns: columns, spacing: 1)
{
stickersCollectionView()
}
.padding()
}
.background(Color(ColorManager.navigationBarBackgroundColor))
}
}
extension StickersPackCollectionView
{
private func stickersCollectionView() -> some View
{
ForEach((1..<100)) { index in
LottieView(animation: .named("vault_boy_test")) /// json from bundle
.playbackMode(.playing(.toProgress(1, loopMode: .loop)))
.resizable()
.frame(width: 75, height: 75)
}
}
}
The json animation is 512x512 resolution. I have tried to cache images data and load it from memory cache and also dispatch load process to background thread, but it did not helped. Even without animation play it spikes the memory. It seems that the issue is in the rendering process of multiple images at the same time. Is there any way to optimize this? Is it even appropriate in case of multiple stickers to display them as lottie or should I switch to gif/webp formats ?
Okay, I was able to solve the issue. You can import the librlottie-Xcode and use it for creation and rendering of json lottie animations. Here is full code (UIKit), just change :
private let animations = Stickers.Category.allCases
.flatMap { $0.pack.map { $0.deletingPathExtension().lastPathComponent } }
to your json file(s) paths.
import UIKit
import librlottie
extension OpaquePointer: @unchecked @retroactive Sendable {}
// MARK: - Animation Manager
actor LottieAnimationManager
{
static let shared = LottieAnimationManager()
private var cachedAnimations: [String: OpaquePointer] = [:]
private init() {}
// Async get with lazy loading
func getAnimation(named name: String) async -> OpaquePointer?
{
if let cached = cachedAnimations[name] {
return cached
}
// Do file loading off the actor context to avoid blocking
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
var anim: OpaquePointer? = nil
if let path = Bundle.main.path(forResource: name, ofType: "json") {
anim = lottie_animation_from_file(path)
}
Task { @MainActor in
continuation.resume(returning: anim)
}
}
}
}
// Store animation
func cacheAnimation(_ anim: OpaquePointer, named name: String) {
cachedAnimations[name] = anim
}
// Cleanup everything
func cleanup() {
for (_, anim) in cachedAnimations {
lottie_animation_destroy(anim)
}
cachedAnimations.removeAll()
}
deinit { cleanup() }
}
// MARK: - ViewController
class ViewController: UIViewController,
UICollectionViewDataSource,
UICollectionViewDelegateFlowLayout
{
private var collectionView: UICollectionView!
private let animations = Stickers.Category.allCases
.flatMap { $0.pack.map { $0.deletingPathExtension().lastPathComponent } }
private var displayLink: CADisplayLink?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setupCollectionView()
startAnimationLoop()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopAnimationLoop()
}
private func setupCollectionView() {
let spacing: CGFloat = 10
let layout = UICollectionViewFlowLayout()
let itemWidth = (view.bounds.width - spacing * 5) / 4
layout.itemSize = CGSize(width: itemWidth, height: itemWidth)
layout.minimumLineSpacing = spacing
layout.minimumInteritemSpacing = spacing
layout.sectionInset = UIEdgeInsets(top: spacing, left: spacing, bottom: spacing, right: spacing)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.backgroundColor = .white
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(LottieCell.self, forCellWithReuseIdentifier: LottieCell.identifier)
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
}
// MARK: - Animation Loop
private func startAnimationLoop() {
displayLink = CADisplayLink(target: self, selector: #selector(renderFrame))
displayLink?.add(to: .main, forMode: .common)
}
private func stopAnimationLoop() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func renderFrame() {
let visibleIndexPaths = collectionView.indexPathsForVisibleItems
for indexPath in visibleIndexPaths {
if let cell = collectionView.cellForItem(at: indexPath) as? LottieCell {
cell.lottieView.renderNextFrame()
}
}
}
// MARK: - UICollectionViewDataSource
func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
return animations.count
}
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: LottieCell.identifier,
for: indexPath
) as! LottieCell
cell.configure(withAnimationNamed: animations[indexPath.item])
return cell
}
// MARK: - Visibility Management
func collectionView(_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath) {
(cell as? LottieCell)?.lottieView.setVisible(true)
}
func collectionView(_ collectionView: UICollectionView,
didEndDisplaying cell: UICollectionViewCell,
forItemAt indexPath: IndexPath) {
(cell as? LottieCell)?.lottieView.setVisible(false)
}
deinit { stopAnimationLoop() }
}
// MARK: - LottieCell
class LottieCell: UICollectionViewCell {
static let identifier = "LottieCell"
let lottieView = RLLottieView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(lottieView)
lottieView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
lottieView.topAnchor.constraint(equalTo: contentView.topAnchor),
lottieView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
lottieView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
lottieView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(withAnimationNamed name: String) {
lottieView.loadAnimation(named: name)
}
override func prepareForReuse() {
super.prepareForReuse()
lottieView.reset()
}
}
// MARK: - RLLottieView
class RLLottieView: UIView
{
private var animationName: String?
private var animation: OpaquePointer?
private var frameNumber: Int = 0
private var totalFrames: Int = 0
private let renderSize = CGSize(width: 200, height: 200)
private var buffer: UnsafeMutablePointer<UInt32>?
private var isVisible = false
private var renderInProgress = false
private var startTime: CFTimeInterval = 0
private var randomOffset: TimeInterval = 0
// Cached graphics objects
private let cachedColorSpace: CGColorSpace
private let cachedBitmapInfo: CGBitmapInfo
private let renderQueue = DispatchQueue(label: "lottie.render.queue", qos: .userInitiated)
override init(frame: CGRect) {
cachedColorSpace = CGColorSpaceCreateDeviceRGB()
cachedBitmapInfo = CGBitmapInfo(rawValue:
CGBitmapInfo.byteOrder32Little.rawValue |
CGImageAlphaInfo.premultipliedFirst.rawValue)
super.init(frame: frame)
let pixelCount = Int(renderSize.width * renderSize.height)
buffer = UnsafeMutablePointer<UInt32>.allocate(capacity: pixelCount)
buffer?.initialize(repeating: 0, count: pixelCount)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Load Animation
func loadAnimation(named name: String) {
animationName = name
animation = nil
layer.contents = nil
Task {
if let anim = await LottieAnimationManager.shared.getAnimation(named: name) {
// Double-check cell is still expecting this animation
guard self.animationName == name else { return }
await LottieAnimationManager.shared.cacheAnimation(anim, named: name)
self.animation = anim
self.totalFrames = Int(lottie_animation_get_totalframe(anim))
self.frameNumber = 0
self.startTime = CACurrentMediaTime()
self.randomOffset = TimeInterval.random(in: 0..<2.0)
self.renderFirstFrame()
}
}
}
private func renderFirstFrame()
{
guard let animation = animation, let buffer = buffer else { return }
renderQueue.async { [weak self] in
guard let self = self, let animation = self.animation, let buffer = self.buffer else { return }
lottie_animation_render(animation,
0,
buffer,
size_t(self.renderSize.width),
size_t(self.renderSize.height),
size_t(Int(self.renderSize.width) * MemoryLayout<UInt32>.size))
self.createAndDisplayImage(from: buffer)
DispatchQueue.main.async { self.renderInProgress = false }
}
}
func setVisible(_ visible: Bool) {
isVisible = visible
}
func renderNextFrame() {
guard isVisible,
let animation = animation,
let buffer = buffer,
!renderInProgress,
totalFrames > 0 else { return }
renderInProgress = true
let elapsed = CACurrentMediaTime() - startTime + randomOffset
let duration = Double(totalFrames) / Double(lottie_animation_get_framerate(animation))
let progress = fmod(elapsed, duration) / duration
let currentFrame = Int(progress * Double(totalFrames))
renderQueue.async { [weak self] in
guard let self = self, let animation = self.animation, let buffer = self.buffer else { return }
lottie_animation_render(animation,
size_t(currentFrame),
buffer,
size_t(self.renderSize.width),
size_t(self.renderSize.height),
size_t(Int(self.renderSize.width) * MemoryLayout<UInt32>.size))
self.createAndDisplayImage(from: buffer)
DispatchQueue.main.async { self.renderInProgress = false }
}
}
private func createAndDisplayImage(from cgBuffer: UnsafeMutableRawPointer)
{
guard let context = CGContext(data: cgBuffer,
width: Int(renderSize.width),
height: Int(renderSize.height),
bitsPerComponent: 8,
bytesPerRow: Int(renderSize.width) * 4,
space: cachedColorSpace,
bitmapInfo: cachedBitmapInfo.rawValue),
let cgImage = context.makeImage() else { return }
DispatchQueue.main.async { [weak self] in
self?.layer.contents = cgImage
}
}
func reset() {
isVisible = false
renderInProgress = false
frameNumber = 0
animationName = nil
animation = nil
layer.contents = nil
}
deinit {
buffer?.deallocate()
}
}