[iOS] Lottie를 커스텀 해보자

2026. 1. 20. 22:22·iOS/iOS
728x90

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
'iOS/iOS' 카테고리의 다른 글
  • [iOS] ShareExtension을 사용해보자 (2) - NSExtensionActivationRule
  • [iOS] ShareExtension을 사용해보자 (1) - ShareExtension 생성하기
  • [iOS] FSCalendar를 사용해보자.
  • [iOS] Moya가 모야?
여성일
여성일
  • 여성일
    성일노트
    여성일
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 탭탭 - TapTap
        • 리팩토링
        • 트러블슈팅
        • 개발일지
      • 애플 디벨로퍼 아카데미
        • 챌린지 회고
        • 하루의 날씨
      • Swift Student Challenge 202..
      • AI를 잘쓰는 개발자가 될래요
      • 우리 같이 협업하자
      • ToyProject - 사카마카 (살까말까 고민 ..
      • ToyProject - Book2OnNon (모바..
      • ToyProject - 바꿔조 (환율 계산기)
      • iOS
        • iOS
        • Vapor
        • Design Pattern
        • CoreData
        • Tuist
        • RxSwift
        • ReactorKit
        • TCA
      • Swift
        • Swift 기본기
        • UIkit
        • SwiftUI
      • UX, 사용성 N
      • 원티드 프리온보딩 챌린지 iOS 과정
  • 블로그 메뉴

    • 링크

    • 공지사항

    • 인기 글

    • 태그

      F
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.6
    여성일
    [iOS] Lottie를 커스텀 해보자
    상단으로

    티스토리툴바