DotLottieFile.swift 5.0 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
//
// DotLottie.swift
// Lottie
//
// Created by Evandro Harrison Hoffmann on 27/06/2020.
//

import Foundation

// MARK: - DotLottieFile

/// Detailed .lottie file structure
public final class DotLottieFile {

  // MARK: Lifecycle

  /// Loads `DotLottie` from `Data` object containing a compressed animation.
  ///
  /// - Parameters:
  ///  - data: Data of .lottie file
  ///  - filename: Name of .lottie file
  ///  - Returns: Deserialized `DotLottie`. Optional.
  init(data: Data, filename: String) throws {
    fileUrl = DotLottieUtils.tempDirectoryURL.appendingPathComponent(filename.asFilename())
    try decompress(data: data, to: fileUrl)
  }

  // MARK: Public

  /// Definition for a single animation within a `DotLottieFile`
  public struct Animation {
    public let animation: LottieAnimation
    public let configuration: DotLottieConfiguration
  }

  /// List of `LottieAnimation` in the file
  public private(set) var animations: [Animation] = []

  // MARK: Internal

  /// Image provider for animations
  private(set) var imageProvider: DotLottieImageProvider?

  /// Animations folder url
  lazy var animationsUrl: URL = fileUrl.appendingPathComponent("\(DotLottieFile.animationsFolderName)")

  /// All files in animations folder
  lazy var animationUrls: [URL] = FileManager.default.urls(for: animationsUrl) ?? []

  /// Images folder url
  lazy var imagesUrl: URL = fileUrl.appendingPathComponent("\(DotLottieFile.imagesFolderName)")

  /// All images in images folder
  lazy var imageUrls: [URL] = FileManager.default.urls(for: imagesUrl) ?? []

  /// The `LottieAnimation` and `DotLottieConfiguration` for the given animation ID in this file
  func animation(for id: String? = nil) -> DotLottieFile.Animation? {
    if let id {
      animations.first(where: { $0.configuration.id == id })
    } else {
      animations.first
    }
  }

  /// The `LottieAnimation` and `DotLottieConfiguration` for the given animation index in this file
  func animation(at index: Int) -> DotLottieFile.Animation? {
    guard index < animations.count else { return nil }
    return animations[index]
  }

  // MARK: Private

  private static let manifestFileName = "manifest.json"
  private static let animationsFolderName = "animations"
  private static let imagesFolderName = "images"

  private let fileUrl: URL

  /// Decompresses .lottie file from `URL` and saves to local temp folder
  ///
  /// - Parameters:
  ///  - url: url to .lottie file
  ///  - destinationURL: url to destination of decompression contents
  private func decompress(from url: URL, to destinationURL: URL) throws {
    try? FileManager.default.removeItem(at: destinationURL)
    try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil)
    try FileManager.default.unzipItem(at: url, to: destinationURL)
    try loadContent()
    try? FileManager.default.removeItem(at: destinationURL)
    try? FileManager.default.removeItem(at: url)
  }

  /// Decompresses .lottie file from `Data` and saves to local temp folder
  ///
  /// - Parameters:
  ///  - url: url to .lottie file
  ///  - destinationURL: url to destination of decompression contents
  private func decompress(data: Data, to destinationURL: URL) throws {
    let url = destinationURL.appendingPathExtension("lottie")
    try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil)
    try data.write(to: url)
    try decompress(from: url, to: destinationURL)
  }

  /// Loads file content to memory
  private func loadContent() throws {
    imageProvider = DotLottieImageProvider(filepath: imagesUrl)

    animations = try loadManifest().animations.map { dotLottieAnimation in
      let animation = try dotLottieAnimation.animation(url: animationsUrl)
      let configuration = DotLottieConfiguration(
        id: dotLottieAnimation.id,
        loopMode: dotLottieAnimation.loopMode,
        speed: dotLottieAnimation.animationSpeed,
        dotLottieImageProvider: imageProvider)

      return DotLottieFile.Animation(
        animation: animation,
        configuration: configuration)
    }
  }

  private func loadManifest() throws -> DotLottieManifest {
    let path = fileUrl.appendingPathComponent(DotLottieFile.manifestFileName)
    return try DotLottieManifest.load(from: path)
  }
}

extension String {

  // MARK: Fileprivate

  fileprivate func asFilename() -> String {
    lastPathComponent().removingPathExtension()
  }

  // MARK: Private

  private func lastPathComponent() -> String {
    (self as NSString).lastPathComponent
  }

  private func removingPathExtension() -> String {
    (self as NSString).deletingPathExtension
  }
}

// MARK: - DotLottieFile + Sendable

// Mark `DotLottieFile` as `@unchecked Sendable` to allow it to be used when strict concurrency is enabled.
// In the future, it may be necessary to make changes to the internal implementation of `DotLottieFile`
// to make it truly thread-safe.
// swiftlint:disable:next no_unchecked_sendable
extension DotLottieFile: @unchecked Sendable { }