Lottie는 JSON 기반의 애니메션을 다양한 플랫폼에서 손쉽게 사용할 수 있게 해주는 라이브러리이다. 탭탭 프로젝트를 진행하면서 Lottie를 사용하게 되었는데, 디자인 요구사항을 구현하기에는 기본 제공되는 Lottie API만으로는 한계가 있었다. 이 글에서는 SwiftUI 환경에서 Lottie를 어떻게 래핑하고, 커스텀하는지를 작성해보려고 한다.
SwiftUI 환경에서 Lottie를 래핑하는 방법
Lottie는 기본적으로 UIKit 기반으로 동작하는 라이브러리이다. LottieAnimationView 역시 UIView를 상속받고 있기 때문에, SwiftUI 환경에서는 그대로 사용할 수 없고 래핑이 필요하다.
SwiftUI에서는 UIKit View를 사용하기 위해 UIViewRepresentable 프로토콜을 통해 UIKit <-> SwiftUI 브릿지 역할을 하는 래퍼 뷰를 구현하여 사용한다. Lottie도 동일하게 UIViewRepresentable 프로토콜을 활용하여 래핑하면 된다.
import SwiftUI
import Lottie
struct LottieView: UIViewRepresentable {
let animationName: String
let loopMode: LottieLoopMode
func makeUIView(context: Context) -> UIView {
let containerView = UIView(frame: .zero)
let animationView = LottieAnimationView(name: animationName)
animationView.loopMode = loopMode
animationView.contentMode = .scaleAspectFit
animationView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
animationView.topAnchor.constraint(equalTo: containerView.topAnchor),
animationView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
])
animationView.play()
return containerView
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
Lottie를 커스텀해보자
Lottie를 커스텀하기에 앞서.. 일단 UIViewRepresentable의 Coordinator를 알아야한다. 간단하게 Coordinator는 UIKit과 SwiftUI 사이의 중재자 역할을 한다. 상태 관리나 메모리 안정성, 생명주기 관리를 위해 사용된다.
public struct LottieWrapperView: UIViewRepresentable {
let animationName: String
let loopMode: LottieLoopMode
let loopCount: Int?
let loopInterval: TimeInterval?
let bundle: Bundle
let onComplete: (() -> Void)?
public init(
animationName: String,
loopMode: LottieLoopMode = .playOnce,
loopCount: Int? = nil,
loopInterval: TimeInterval? = nil,
bundle: Bundle = .main,
onComplete: (() -> Void)? = nil
) {
self.animationName = animationName
self.loopMode = loopMode
self.loopCount = loopCount
self.loopInterval = loopInterval
self.bundle = bundle
self.onComplete = onComplete
}
public func makeCoordinator() -> Coordinator {
Coordinator(
loopMode: loopMode,
loopCount: loopCount,
loopInterval: loopInterval,
onComplete: onComplete
)
}
public func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
let animationView = LottieAnimationView(name: animationName, bundle: bundle)
animationView.contentMode = .scaleAspectFit
animationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: view.widthAnchor),
animationView.heightAnchor.constraint(equalTo: view.heightAnchor)
])
context.coordinator.animationView = animationView
context.coordinator.startAnimation()
return view
}
public func updateUIView(_ uiView: UIView, context: Context) {}
public final class Coordinator {
weak var animationView: LottieAnimationView?
let loopMode: LottieLoopMode
let loopCount: Int?
let loopInterval: TimeInterval?
let onComplete: (() -> Void)?
private var currentCount = 0
private var workItem: DispatchWorkItem?
init(
loopMode: LottieLoopMode,
loopCount: Int?,
loopInterval: TimeInterval?,
onComplete: (() -> Void)?
) {
self.loopMode = loopMode
self.loopCount = loopCount
self.loopInterval = loopInterval
self.onComplete = onComplete
}
func startAnimation() {
guard let animationView = animationView else { return }
if let count = loopCount {
animationView.loopMode = .playOnce
playWithCount()
}
else if let interval = loopInterval, loopMode == .loop {
animationView.loopMode = .playOnce
playWithInterval()
}
else {
animationView.loopMode = loopMode
animationView.play { [weak self] finished in
if finished {
DispatchQueue.main.async {
guard let self = self else { return }
self.onComplete?()
}
}
}
}
}
private func playWithCount() {
guard let animationView = animationView,
let loopCount = loopCount else { return }
animationView.play { [weak self] finished in
guard let self = self, finished else { return }
self.currentCount += 1
if self.currentCount < loopCount {
if let interval = self.loopInterval {
self.workItem?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
self.playWithCount()
}
self.workItem = work
DispatchQueue.main.asyncAfter(
deadline: .now() + interval,
execute: work
)
} else {
self.playWithCount()
}
} else {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onComplete?()
}
}
}
}
private func playWithInterval() {
guard let animationView = animationView,
let interval = loopInterval else { return }
animationView.play { [weak self] finished in
guard finished, let self = self else { return }
self.workItem?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
self.playWithInterval()
}
self.workItem = work
DispatchQueue.main.asyncAfter(
deadline: .now() + interval,
execute: work
)
}
}
deinit {
workItem?.cancel()
}
}
}
실제 탭탭의 커스텀 로티 래퍼뷰 전체코드이다. 기능별로 살펴보자.
일단 loopMode, loopCount, loopInterval을 파라미터로 받아서 애니메이션을 재생한다. startAnimation 함수에서 우선순위에 따라 어떤 방식으로 재생할지 결정하는데, 가장 먼저 loopCount가 있는지 확인하고, 있으면 playWithCount를 호출해서 입력한 횟수만큼 재생한다. loopCount가 없을땐, loopInterval이 존재하는지, loopMode가 .loop인지 확인해서, 조건이 맞으면 playWithInterval을 호출해서 간격을 두고 무한 반복한다. 둘 다 아닐 경우 그냥 기본 loopMode를 사용해서 Lottie의 기본 동작대로 재생한다.
1. playWithCount() - 횟수 제한 반복
playWithCount는 애니메이션을 정확히 지정된 횟수만큼만 재생하는 메소드다. 먼저 animationView.play를 호출해서 애니메이션을 한 번 재생하고, 재생이 끝나 클로저가 실행되면서 currenCount를 1 증가시킨다. 그리고 currentCount가 아직 loopCount보다 작은지 확인해서, 작으면 계속 재생해야 하니까 다시 playWithCount를 호출한다.
이때 loopInterval이 설정되어 있으면 DispatchWorkItem을 만들어서 지정된 시간만큼 기다린 후에 playWithCount 메소드를 재귀 호출하고, loopInterval이 없다면 바로 재귀 호출한다. currentCount가 loopCount에 도달하면 onComplete 콜백을 실행해서 애니메이션이 종료되었음을 알린다.
2. playWithInterval() - 인터벌 간격 제어
playWithInterval은 간격을 두고 무한 반복하는 메소드이다. animationView.play로 애니메이션을 한 번 재생하고, 재생이 끝나면 클로저에서 DispatchWorkItem을 만들어서 loopInterval 시간만큼 기다린 후에 다시 playWithInterval을 재귀 호출한다. 이전에 스케줄된 wrokItem이 있으면 cancel로 취소하고 새로운 작업을 스케줄하는 방식으로 동작한다. 이렇게 하면 애니메이션 재생 - 대기 - 재생 패턴이
무한하게 반복된다.
Coordinator 없이 구현한다면?
1. 상태를 저장할 공간이 없다.
public func makeUIView(context: Context) -> UIView {
var currentCount = 0
animationView.play { finished in
currentCount += 1
}
return view
}
위의 코드를 확인해보자. 지역 변수인 currentCount += 1의 값은 증가하지만 다음 재생을 위해 값을 저장할 공간이 없다.
2. 재귀 호출 불가능
animationView.play { finished in
// 함수가 없음!
}
호출할 함수가 없기 때문에 재귀 호출이 불가능하다.
아예 불가능한 것은 아니다. 억지로 Coordinator 없이 구현해보자.
public struct LottieWrapperView: UIViewRepresentable {
let animationName: String
let loopCount: Int?
public func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
let animationView = LottieAnimationView(name: animationName)
view.addSubview(animationView)
var playAnimation: (() -> Void)?
var currentCount = 0
playAnimation = {
animationView.play { finished in
currentCount += 1
if currentCount < (self.loopCount ?? 0) {
playAnimation?()
}
}
}
playAnimation?()
return view
}
public func updateUIView(_ uiView: UIView, context: Context) {}
}
동작은 하겠지만, 몇 가지 문제가 있다.
1. 메모리 누수(순환 참조)
var playAnimation: (() -> Void)?
playAnimation = {
animationView.play { finished in
playAnimation?()
}
}
playAnimation 클로저가 자기 자신을 캡처하기 때문에 순환 참조가 발생하고 ARC가 메모리 해제를 하지 못해 메모리 누수가 발생한다.
2. 취소 불가능
deinit {
workItem?.cancel()
}
코디네이터는 cancel을 통해 뷰가 사라질 때 정리할 수 있다. 하지만 위와 같은 방식은 deinit이 없으니 취소가 불가능하다.
public class Coordinator {
weak var animationView: LottieAnimationView?
private var workItem: DispatchWorkItem?
private var currentCount = 0
func playWithCount() {
animationView?.play { [weak self] finished in
guard let self = self else { return }
self.currentCount += 1
if self.currentCount < self.loopCount {
self.playWithCount()
}
}
}
deinit {
workItem?.cancel()
print("Coordinator 메모리 해제됨")
}
}
물론 Coordinator 없이도 어떻게든 메모리 문제를 해결할 수 있겠지만.. 코드가 길어질 것이다. 편하게 Coordinator로 관리하자! 그러라고 만들어 놓은 것이니까!
'iOS > iOS' 카테고리의 다른 글
| [iOS] ShareExtension을 사용해보자 (2) - NSExtensionActivationRule (0) | 2025.10.08 |
|---|---|
| [iOS] ShareExtension을 사용해보자 (1) - ShareExtension 생성하기 (0) | 2025.10.08 |
| [iOS] FSCalendar를 사용해보자. (0) | 2024.08.08 |
| [iOS] Moya가 모야? (0) | 2024.06.27 |
| [iOS] 라우터 패턴과 Alamofire를 이용해서 API 통신을 해보자. (0) | 2024.06.26 |