From 0a9d2fc68f04683d58838d9968833815ec164cff Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Wed, 2 Nov 2022 15:04:14 -0700 Subject: [PATCH] Add snapshot tests for dot lottie animations, and other cleanup (#1794) --- Lottie.xcodeproj/project.pbxproj | 4 +- .../DotLottie/DotLottieImageProvider.swift | 15 +- .../Public/Animation/LottieAnimation.swift | 3 - .../Animation/LottieAnimationHelpers.swift | 23 +++ .../Animation/LottieAnimationView.swift | 22 ++- .../LottieAnimationViewInitializers.swift | 16 +- Sources/Public/DotLottie/DotLottieFile.swift | 47 ++++- .../DotLottie/DotLottieFileHelpers.swift | 175 +++++++++++------- Tests/AnimationKeypathTests.swift | 31 ++-- Tests/AutomaticEngineTests.swift | 13 +- Tests/ParsingTests.swift | 2 + ...lottie => animation_external_image.lottie} | Bin ...e.lottie => animation_inline_image.lottie} | Bin Tests/SnapshotTests.swift | 85 +++++---- Tests/ValueProvidersTests.swift | 9 +- 15 files changed, 277 insertions(+), 168 deletions(-) rename Tests/Samples/DotLottie/{animation-external-image.lottie => animation_external_image.lottie} (100%) rename Tests/Samples/DotLottie/{animation-inline-image.lottie => animation_inline_image.lottie} (100%) diff --git a/Lottie.xcodeproj/project.pbxproj b/Lottie.xcodeproj/project.pbxproj index 5b013b6a91..58c8e94edc 100644 --- a/Lottie.xcodeproj/project.pbxproj +++ b/Lottie.xcodeproj/project.pbxproj @@ -2682,7 +2682,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.airbnb.LottieTests; @@ -2701,7 +2701,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.airbnb.LottieTests; diff --git a/Sources/Private/Model/DotLottie/DotLottieImageProvider.swift b/Sources/Private/Model/DotLottie/DotLottieImageProvider.swift index 6591febf8e..8d3afe9cfa 100644 --- a/Sources/Private/Model/DotLottie/DotLottieImageProvider.swift +++ b/Sources/Private/Model/DotLottie/DotLottieImageProvider.swift @@ -14,7 +14,7 @@ import AppKit // MARK: - DotLottieImageProvider -/// Provides an image for a lottie animation from a provided Bundle. +/// An image provider that loads the images from a DotLottieFile into memory class DotLottieImageProvider: AnimationImageProvider { // MARK: Lifecycle @@ -38,12 +38,17 @@ class DotLottieImageProvider: AnimationImageProvider { let filepath: URL func imageForAsset(asset: ImageAsset) -> CGImage? { - imageCache.object(forKey: asset.name as NSString) + images[asset.name] } // MARK: Private - private var imageCache: NSCache = .init() + /// This is intentionally a Dictionary instead of an NSCache. Files from a decompressed dotLottie zip archive + /// are only valid are deleted after being read into memory. If we used an NSCache then the OS could evict + /// the cache entries when under memory pressure, and we would have no way to reload them later. + /// - Ideally we would have a way to remove image data when under memory pressure, but this would require + /// re-decompressing the dotLottie file when requesting an image that has been loaded but then removed. + private var images = [String: CGImage]() private func loadImages() { filepath.urls.forEach { @@ -52,14 +57,14 @@ class DotLottieImageProvider: AnimationImageProvider { let data = try? Data(contentsOf: $0), let image = UIImage(data: data)?.cgImage { - imageCache.setObject(image, forKey: $0.lastPathComponent as NSString) + images[$0.lastPathComponent] = image } #elseif os(macOS) if let data = try? Data(contentsOf: $0), let image = NSImage(data: data)?.lottie_CGImage { - imageCache.setObject(image, forKey: $0.lastPathComponent as NSString) + images[$0.lastPathComponent] = image } #endif } diff --git a/Sources/Public/Animation/LottieAnimation.swift b/Sources/Public/Animation/LottieAnimation.swift index 6ccb4463e5..5e619ba4eb 100644 --- a/Sources/Public/Animation/LottieAnimation.swift +++ b/Sources/Public/Animation/LottieAnimation.swift @@ -157,7 +157,4 @@ public final class LottieAnimation: Codable, DictionaryInitializable { /// Markers let markers: [Marker]? let markerMap: [String: Marker]? - - /// DotLottie configuration to setup the player - var dotLottieConfiguration: DotLottieConfiguration? } diff --git a/Sources/Public/Animation/LottieAnimationHelpers.swift b/Sources/Public/Animation/LottieAnimationHelpers.swift index e298ec0e54..0be2ef699b 100644 --- a/Sources/Public/Animation/LottieAnimationHelpers.swift +++ b/Sources/Public/Animation/LottieAnimationHelpers.swift @@ -164,6 +164,29 @@ extension LottieAnimation { } } + /// Loads a Lottie animation asynchronously from the URL. + /// + /// - Parameter url: The url to load the animation from. + /// - Parameter animationCache: A cache for holding loaded animations. Defaults to `LottieAnimationCache.shared`. Optional. + /// + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) + public static func loadedFrom( + url: URL, + session: URLSession = .shared, + animationCache: AnimationCacheProvider? = LottieAnimationCache.shared) async + -> LottieAnimation? + { + await withCheckedContinuation { continuation in + LottieAnimation.loadedFrom( + url: url, + session: session, + closure: { result in + continuation.resume(returning: result) + }, + animationCache: animationCache) + } + } + /// Loads a Lottie animation asynchronously from the URL. /// /// - Parameter url: The url to load the animation from. diff --git a/Sources/Public/Animation/LottieAnimationView.swift b/Sources/Public/Animation/LottieAnimationView.swift index d758c799a6..65e1992a0d 100644 --- a/Sources/Public/Animation/LottieAnimationView.swift +++ b/Sources/Public/Animation/LottieAnimationView.swift @@ -126,7 +126,8 @@ final public class LottieAnimationView: LottieAnimationViewBase { configuration: LottieConfiguration = .shared, logger: LottieLogger = .shared) { - animation = dotLottie?.animation(for: animationId) + let dotLottieAnimation = dotLottie?.animation(for: animationId) + animation = dotLottieAnimation?.animation imageProvider = dotLottie?.imageProvider ?? BundleImageProvider(bundle: Bundle.main, searchPath: nil) self.textProvider = textProvider self.fontProvider = fontProvider @@ -134,8 +135,8 @@ final public class LottieAnimationView: LottieAnimationViewBase { self.logger = logger super.init(frame: .zero) commonInit() - loopMode = animation?.dotLottieConfiguration?.loopMode ?? .playOnce - animationSpeed = CGFloat(animation?.dotLottieConfiguration?.speed ?? 1) + loopMode = dotLottieAnimation?.configuration.loopMode ?? .playOnce + animationSpeed = CGFloat(dotLottieAnimation?.configuration.speed ?? 1) makeAnimationLayer(usingEngine: configuration.renderingEngine) if let animation = animation { frame = animation.bounds @@ -440,15 +441,16 @@ final public class LottieAnimationView: LottieAnimationViewBase { _ animationId: String? = nil, from dotLottieFile: DotLottieFile) { - guard let animation = dotLottieFile.animation(for: animationId) else { return } - if let configuration = animation.dotLottieConfiguration { - loopMode = configuration.loopMode - animationSpeed = CGFloat(configuration.speed) - } - if let imageProvider = dotLottieFile.imageProvider { + guard let dotLottieAnimation = dotLottieFile.animation(for: animationId) else { return } + + loopMode = dotLottieAnimation.configuration.loopMode + animationSpeed = CGFloat(dotLottieAnimation.configuration.speed) + + if let imageProvider = dotLottieAnimation.configuration.imageProvider { self.imageProvider = imageProvider } - self.animation = animation + + animation = dotLottieAnimation.animation } /// Plays the animation from its current state to the end. diff --git a/Sources/Public/Animation/LottieAnimationViewInitializers.swift b/Sources/Public/Animation/LottieAnimationViewInitializers.swift index ae083f2781..d768c6dc3c 100644 --- a/Sources/Public/Animation/LottieAnimationViewInitializers.swift +++ b/Sources/Public/Animation/LottieAnimationViewInitializers.swift @@ -114,10 +114,10 @@ extension LottieAnimationView { configuration: LottieConfiguration = .shared) { self.init(dotLottie: nil, animationId: animationId, configuration: configuration) - DotLottieFile.named(name, bundle: bundle, subdirectory: nil, closure: { result in + DotLottieFile.named(name, bundle: bundle, dotLottieCache: dotLottieCache) { result in guard case Result.success(let lottie) = result else { return } self.loadAnimation(animationId, from: lottie) - }, dotLottieCache: dotLottieCache) + } } /// Loads a Lottie from a .lottie file in a specific path on disk. @@ -132,10 +132,10 @@ extension LottieAnimationView { configuration: LottieConfiguration = .shared) { self.init(dotLottie: nil, animationId: animationId, configuration: configuration) - DotLottieFile.filepath(filePath, closure: { result in + DotLottieFile.loadedFrom(filepath: filePath, dotLottieCache: dotLottieCache) { result in guard case Result.success(let lottie) = result else { return } self.loadAnimation(animationId, from: lottie) - }, dotLottieCache: dotLottieCache) + } } /// Loads a Lottie file asynchronously from the URL @@ -156,14 +156,14 @@ extension LottieAnimationView { closure(nil) } else { self.init(dotLottie: nil, configuration: configuration) - DotLottieFile.loadedFrom(url: url, closure: { result in + DotLottieFile.loadedFrom(url: url, dotLottieCache: dotLottieCache) { result in switch result { case .success(let lottie): self.loadAnimation(animationId, from: lottie) case .failure(let error): closure(error) } - }, dotLottieCache: dotLottieCache) + } } } @@ -181,10 +181,10 @@ extension LottieAnimationView { configuration: LottieConfiguration = .shared) { self.init(dotLottie: nil, animationId: animationId, configuration: configuration) - DotLottieFile.asset(name, bundle: bundle, closure: { result in + DotLottieFile.asset(named: name, bundle: bundle, dotLottieCache: dotLottieCache) { result in guard case Result.success(let lottie) = result else { return } self.loadAnimation(animationId, from: lottie) - }, dotLottieCache: dotLottieCache) + } } // MARK: Public diff --git a/Sources/Public/DotLottie/DotLottieFile.swift b/Sources/Public/DotLottie/DotLottieFile.swift index 0c5a119275..fb4039829b 100644 --- a/Sources/Public/DotLottie/DotLottieFile.swift +++ b/Sources/Public/DotLottie/DotLottieFile.swift @@ -27,11 +27,17 @@ public final class DotLottieFile { // MARK: Internal + /// Definition for a single animation within a `DotLottieFile` + struct Animation { + let animation: LottieAnimation + let configuration: DotLottieConfiguration + } + /// List of `LottieAnimation` in the file - var animations: [LottieAnimation] = [] + private(set) var animations: [Animation] = [] /// Image provider for animations - var imageProvider: AnimationImageProvider? + private(set) var imageProvider: AnimationImageProvider? /// Manifest.json file loading lazy var manifest: DotLottieManifest? = { @@ -62,6 +68,15 @@ public final class DotLottieFile { 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 = id { + return animations.first(where: { $0.configuration.id == id }) + } else { + return animations.first + } + } + // MARK: Private private static let manifestFileName = "manifest.json" @@ -70,6 +85,14 @@ public final class DotLottieFile { private let fileUrl: URL + private var dotLottieAnimations: [DotLottieAnimation] { + manifest?.animations.map({ + var animation = $0 + animation.animationUrl = animationsUrl.appendingPathComponent("\($0.id).json") + return animation + }) ?? [] + } + /// Decompresses .lottie file from `URL` and saves to local temp folder /// /// - Parameters: @@ -100,14 +123,20 @@ public final class DotLottieFile { private func loadContent() { imageProvider = DotLottieImageProvider(filepath: imagesUrl) - animations = dotLottieAnimations.compactMap { - let animation = try? $0.animation() - animation?.dotLottieConfiguration = DotLottieConfiguration( - id: $0.id, + animations = dotLottieAnimations.compactMap { dotLottieAnimation -> DotLottieFile.Animation? in + guard let animation = try? dotLottieAnimation.animation() else { + return nil + } + + let configuration = DotLottieConfiguration( + id: dotLottieAnimation.id, imageProvider: imageProvider, - loopMode: $0.loopMode, - speed: $0.animationSpeed) - return animation + loopMode: dotLottieAnimation.loopMode, + speed: dotLottieAnimation.animationSpeed) + + return DotLottieFile.Animation( + animation: animation, + configuration: configuration) } } } diff --git a/Sources/Public/DotLottie/DotLottieFileHelpers.swift b/Sources/Public/DotLottie/DotLottieFileHelpers.swift index 64ba14fdc7..845ab2341f 100644 --- a/Sources/Public/DotLottie/DotLottieFileHelpers.swift +++ b/Sources/Public/DotLottie/DotLottieFileHelpers.swift @@ -9,26 +9,40 @@ import Foundation extension DotLottieFile { - // MARK: Public - - /// A closure for an DotLottieFile download. The closure is passed `nil` if there was an error. - public typealias DotLottieLoadClosure = (Result) -> Void - - // MARK: DotLottie file (Loading) + /// Loads a DotLottie model from a bundle by its name. Returns `nil` if a file is not found. + /// + /// - Parameter name: The name of the lottie file without the lottie extension. EG "StarAnimation" + /// - Parameter bundle: The bundle in which the lottie is located. Defaults to `Bundle.main` + /// - Parameter subdirectory: A subdirectory in the bundle in which the lottie is located. Optional. + /// - Parameter dotLottieCache: A cache for holding loaded lotties. Defaults to `LRUDotLottieCache.sharedCache`. Optional. + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) + public static func named( + _ name: String, + bundle: Bundle = Bundle.main, + subdirectory: String? = nil, + dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache) async throws + -> DotLottieFile + { + try await withCheckedThrowingContinuation { continuation in + DotLottieFile.named(name, bundle: bundle, subdirectory: subdirectory, dotLottieCache: dotLottieCache) { result in + continuation.resume(with: result) + } + } + } /// Loads a DotLottie model from a bundle by its name. Returns `nil` if a file is not found. /// /// - Parameter name: The name of the lottie file without the lottie extension. EG "StarAnimation" /// - Parameter bundle: The bundle in which the lottie is located. Defaults to `Bundle.main` /// - Parameter subdirectory: A subdirectory in the bundle in which the lottie is located. Optional. - /// - Parameter closure: A closure to be called when the file has loaded. /// - Parameter dotLottieCache: A cache for holding loaded lotties. Defaults to `LRUDotLottieCache.sharedCache`. Optional. + /// - Parameter handleResult: A closure to be called when the file has loaded. public static func named( _ name: String, bundle: Bundle = Bundle.main, subdirectory: String? = nil, - closure: @escaping DotLottieLoadClosure, - dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache) + dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache, + handleResult: @escaping (Result) -> Void) { DispatchQueue.global().async { /// Create a cache key for the lottie. @@ -41,28 +55,29 @@ extension DotLottieFile { { DispatchQueue.main.async { /// If found, return the lottie. - closure(.success(lottie)) + handleResult(.success(lottie)) } + return } do { /// Decode animation. guard let data = try bundle.dotLottieData(name, subdirectory: subdirectory) else { DispatchQueue.main.async { - closure(.failure(DotLottieError.invalidData)) + handleResult(.failure(DotLottieError.invalidData)) } return } - let lottie = try DotLottieFile.from(data: data, filename: name) + let lottie = try DotLottieFile(data: data, filename: name) dotLottieCache?.setFile(lottie, forKey: cacheKey) DispatchQueue.main.async { - closure(.success(lottie)) + handleResult(.success(lottie)) } } catch { /// Decoding error. LottieLogger.shared.warn("Error when decoding lottie \"\(name)\": \(error)") DispatchQueue.main.async { - closure(.failure(error)) + handleResult(.failure(error)) } } } @@ -70,12 +85,28 @@ extension DotLottieFile { /// Loads an DotLottie from a specific filepath. /// - Parameter filepath: The absolute filepath of the lottie to load. EG "/User/Me/starAnimation.lottie" - /// - Parameter closure: A closure to be called when the file has loaded. /// - Parameter dotLottieCache: A cache for holding loaded lotties. Defaults to `LRUDotLottieCache.sharedCache`. Optional. - public static func filepath( - _ filepath: String, - closure: @escaping DotLottieLoadClosure, - dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache) + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) + public static func loadedFrom( + filepath: String, + dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache) async throws + -> DotLottieFile + { + try await withCheckedThrowingContinuation { continuation in + DotLottieFile.loadedFrom(filepath: filepath, dotLottieCache: dotLottieCache) { result in + continuation.resume(with: result) + } + } + } + + /// Loads an DotLottie from a specific filepath. + /// - Parameter filepath: The absolute filepath of the lottie to load. EG "/User/Me/starAnimation.lottie" + /// - Parameter dotLottieCache: A cache for holding loaded lotties. Defaults to `LRUDotLottieCache.sharedCache`. Optional. + /// - Parameter handleResult: A closure to be called when the file has loaded. + public static func loadedFrom( + filepath: String, + dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache, + handleResult: @escaping (Result) -> Void) { DispatchQueue.global().async { /// Check cache for lottie @@ -84,23 +115,24 @@ extension DotLottieFile { let lottie = dotLottieCache.file(forKey: filepath) { DispatchQueue.main.async { - closure(.success(lottie)) + handleResult(.success(lottie)) } + return } do { /// Decode the lottie. let url = URL(fileURLWithPath: filepath) let data = try Data(contentsOf: url) - let lottie = try DotLottieFile.from(data: data, filename: url.deletingPathExtension().lastPathComponent) + let lottie = try DotLottieFile(data: data, filename: url.deletingPathExtension().lastPathComponent) dotLottieCache?.setFile(lottie, forKey: filepath) DispatchQueue.main.async { - closure(.success(lottie)) + handleResult(.success(lottie)) } } catch { /// Decoding Error. DispatchQueue.main.async { - closure(.failure(error)) + handleResult(.failure(error)) } } } @@ -109,13 +141,31 @@ extension DotLottieFile { /// Loads a DotLottie model from the asset catalog by its name. Returns `nil` if a lottie is not found. /// - Parameter name: The name of the lottie file in the asset catalog. EG "StarAnimation" /// - Parameter bundle: The bundle in which the lottie is located. Defaults to `Bundle.main` - /// - Parameter closure: A closure to be called when the file has loaded. /// - Parameter dotLottieCache: A cache for holding loaded lottie files. Defaults to `LRUDotLottieCache.sharedCache` Optional. + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) public static func asset( - _ name: String, + named name: String, bundle: Bundle = Bundle.main, - closure: @escaping DotLottieLoadClosure, - dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache) + dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache) async throws + -> DotLottieFile + { + try await withCheckedThrowingContinuation { continuation in + DotLottieFile.asset(named: name, bundle: bundle, dotLottieCache: dotLottieCache) { result in + continuation.resume(with: result) + } + } + } + + /// Loads a DotLottie model from the asset catalog by its name. Returns `nil` if a lottie is not found. + /// - Parameter name: The name of the lottie file in the asset catalog. EG "StarAnimation" + /// - Parameter bundle: The bundle in which the lottie is located. Defaults to `Bundle.main` + /// - Parameter dotLottieCache: A cache for holding loaded lottie files. Defaults to `LRUDotLottieCache.sharedCache` Optional. + /// - Parameter handleResult: A closure to be called when the file has loaded. + public static func asset( + named name: String, + bundle: Bundle = Bundle.main, + dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache, + handleResult: @escaping (Result) -> Void) { DispatchQueue.global().async { /// Create a cache key for the lottie. @@ -128,77 +178,83 @@ extension DotLottieFile { { /// If found, return the lottie. DispatchQueue.main.async { - closure(.success(lottie)) + handleResult(.success(lottie)) } + return } /// Load data from Asset guard let data = Data.jsonData(from: name, in: bundle) else { DispatchQueue.main.async { - closure(.failure(DotLottieError.invalidData)) + handleResult(.failure(DotLottieError.invalidData)) } return } do { /// Decode lottie. - let lottie = try DotLottieFile.from(data: data, filename: name) + let lottie = try DotLottieFile(data: data, filename: name) dotLottieCache?.setFile(lottie, forKey: cacheKey) DispatchQueue.main.async { - closure(.success(lottie)) + handleResult(.success(lottie)) } } catch { /// Decoding error. DispatchQueue.main.async { - closure(.failure(error)) + handleResult(.failure(error)) } } } } - /// Loads a DotLottie animation from a `Data` object containing a compressed .lottie file. - /// - /// - Parameter data: The object to load the file from. - /// - Parameter filename: The name of the file. - /// - Returns: Deserialized `DotLottie`. Optional. + /// Loads a DotLottie animation asynchronously from the URL. /// - public static func from(data: Data, filename: String) throws + /// - Parameter url: The url to load the animation from. + /// - Parameter animationCache: A cache for holding loaded animations. Defaults to `LRUAnimationCache.sharedCache`. Optional. + @available(iOS 13.0, macOS 10.15, tvOS 13.0, *) + public static func loadedFrom( + url: URL, + session: URLSession = .shared, + dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache) async throws -> DotLottieFile { - try DotLottieFile(data: data, filename: filename) + try await withCheckedThrowingContinuation { continuation in + DotLottieFile.loadedFrom(url: url, session: session, dotLottieCache: dotLottieCache) { result in + continuation.resume(with: result) + } + } } /// Loads a DotLottie animation asynchronously from the URL. /// /// - Parameter url: The url to load the animation from. - /// - Parameter closure: A closure to be called when the animation has loaded. /// - Parameter animationCache: A cache for holding loaded animations. Defaults to `LRUAnimationCache.sharedCache`. Optional. - /// + /// - Parameter handleResult: A closure to be called when the animation has loaded. public static func loadedFrom( url: URL, session: URLSession = .shared, - closure: @escaping DotLottieLoadClosure, - dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache) + dotLottieCache: DotLottieCacheProvider? = DotLottieCache.sharedCache, + handleResult: @escaping (Result) -> Void) { if let dotLottieCache = dotLottieCache, let lottie = dotLottieCache.file(forKey: url.absoluteString) { - closure(.success(lottie)) + handleResult(.success(lottie)) } else { let task = session.dataTask(with: url) { data, _, error in guard error == nil, let data = data else { DispatchQueue.main.async { - closure(.failure(DotLottieError.invalidData)) + handleResult(.failure(DotLottieError.invalidData)) } return } do { - let lottie = try DotLottieFile.from(data: data, filename: url.deletingPathExtension().lastPathComponent) + let lottie = try DotLottieFile(data: data, filename: url.deletingPathExtension().lastPathComponent) DispatchQueue.main.async { dotLottieCache?.setFile(lottie, forKey: url.absoluteString) - closure(.success(lottie)) + handleResult(.success(lottie)) } } catch { DispatchQueue.main.async { - closure(.failure(error)) + handleResult(.failure(error)) } } } @@ -206,27 +262,4 @@ extension DotLottieFile { } } - /// Returns animation with id - /// - Parameter id: id to animation. Specified in .lottie file manifest. Optional - /// Defaults to first animation - /// - Returns: LottieAnimation with id. Optional - public func animation(for id: String? = nil) -> LottieAnimation? { - if let id = id { - return animations.first(where: { $0.dotLottieConfiguration?.id == id }) - } else { - return animations.first - } - } - - // MARK: Internal - - /// Returns the list of `DotLottieAnimation` in the file - var dotLottieAnimations: [DotLottieAnimation] { - manifest?.animations.map({ - var animation = $0 - animation.animationUrl = animationsUrl.appendingPathComponent("\($0.id).json") - return animation - }) ?? [] - } - } diff --git a/Tests/AnimationKeypathTests.swift b/Tests/AnimationKeypathTests.swift index e8613700c7..67c5bfed0a 100644 --- a/Tests/AnimationKeypathTests.swift +++ b/Tests/AnimationKeypathTests.swift @@ -6,6 +6,7 @@ import XCTest @testable import Lottie +@MainActor final class AnimationKeypathTests: XCTestCase { // MARK: Internal @@ -39,27 +40,30 @@ final class AnimationKeypathTests: XCTestCase { XCTAssertNotNil(animationView.animationLayer?.layer(for: "Success.*.Fish1Tail 7")) } - func testMainThreadEngineKeypathLogging() { - snapshotHierarchyKeypaths( + func testMainThreadEngineKeypathLogging() async { + await snapshotHierarchyKeypaths( animationName: "Switch", configuration: LottieConfiguration(renderingEngine: .mainThread)) } - func testCoreAnimationEngineKeypathLogging() { - snapshotHierarchyKeypaths( + func testCoreAnimationEngineKeypathLogging() async { + await snapshotHierarchyKeypaths( animationName: "Switch", configuration: LottieConfiguration(renderingEngine: .coreAnimation)) - snapshotHierarchyKeypaths( + await snapshotHierarchyKeypaths( animationName: "Issues/issue_1664", configuration: LottieConfiguration(renderingEngine: .coreAnimation)) } /// The Core Animation engine supports a subset of the keypaths supported by the Main Thread engine. /// All keypaths that are supported in the Core Animation engine should also be supported by the Main Thread engine. - func testCoreAnimationEngineKeypathCompatibility() { - let mainThreadKeypaths = Set(hierarchyKeypaths(animationName: "Switch", configuration: .init(renderingEngine: .mainThread))) - let coreAnimationKeypaths = hierarchyKeypaths(animationName: "Switch", configuration: .init(renderingEngine: .coreAnimation)) + func testCoreAnimationEngineKeypathCompatibility() async { + let mainThreadKeypaths = + Set(await hierarchyKeypaths(animationName: "Switch", configuration: .init(renderingEngine: .mainThread))) + let coreAnimationKeypaths = await hierarchyKeypaths( + animationName: "Switch", + configuration: .init(renderingEngine: .coreAnimation)) for coreAnimationKeypath in coreAnimationKeypaths { XCTAssert( @@ -77,9 +81,9 @@ final class AnimationKeypathTests: XCTestCase { animationName: String, configuration: LottieConfiguration, function: String = #function, - line: UInt = #line) + line: UInt = #line) async { - let hierarchyKeypaths = hierarchyKeypaths(animationName: animationName, configuration: configuration) + let hierarchyKeypaths = await hierarchyKeypaths(animationName: animationName, configuration: configuration) assertSnapshot( matching: hierarchyKeypaths.sorted().joined(separator: "\n"), @@ -89,13 +93,16 @@ final class AnimationKeypathTests: XCTestCase { line: line) } - private func hierarchyKeypaths(animationName: String, configuration: LottieConfiguration) -> [String] { + private func hierarchyKeypaths(animationName: String, configuration: LottieConfiguration) async -> [String] { var printedMessages = [String]() let logger = LottieLogger(info: { message in printedMessages.append(message()) }) - let animationView = SnapshotConfiguration.makeAnimationView(for: animationName, configuration: configuration, logger: logger) + let animationView = await SnapshotConfiguration.makeAnimationView( + for: animationName, + configuration: configuration, + logger: logger) animationView?.logHierarchyKeypaths() return Array(printedMessages[1...]) } diff --git a/Tests/AutomaticEngineTests.swift b/Tests/AutomaticEngineTests.swift index 4bc8488fb4..7555c10496 100644 --- a/Tests/AutomaticEngineTests.swift +++ b/Tests/AutomaticEngineTests.swift @@ -7,12 +7,21 @@ import XCTest @testable import Lottie +@MainActor final class AutomaticEngineTests: XCTestCase { /// Snapshot tests for whether or not each sample animation supports the Core Animation engine - func testAutomaticEngineDetection() throws { + func testAutomaticEngineDetection() async throws { for sampleAnimationName in Samples.sampleAnimationNames { - guard let animation = Samples.animation(named: sampleAnimationName) else { continue } + var animation = Samples.animation(named: sampleAnimationName) + if animation == nil { + animation = await Samples.dotLottie(named: sampleAnimationName)?.animations.first?.animation + } + + guard let animation = animation else { + XCTFail("Couldn't load animation named \(sampleAnimationName)") + continue + } var compatibilityIssues = [CompatibilityIssue]() diff --git a/Tests/ParsingTests.swift b/Tests/ParsingTests.swift index dd4df2af99..8fc071d887 100644 --- a/Tests/ParsingTests.swift +++ b/Tests/ParsingTests.swift @@ -16,6 +16,8 @@ final class ParsingTests: XCTestCase { func testParsingIsTheSameForBothImplementations() throws { for url in Samples.sampleAnimationURLs { + guard url.pathExtension == "json" else { continue } + do { let data = try Data(contentsOf: url) let codableAnimation = try LottieAnimation.from(data: data, strategy: .legacyCodable) diff --git a/Tests/Samples/DotLottie/animation-external-image.lottie b/Tests/Samples/DotLottie/animation_external_image.lottie similarity index 100% rename from Tests/Samples/DotLottie/animation-external-image.lottie rename to Tests/Samples/DotLottie/animation_external_image.lottie diff --git a/Tests/Samples/DotLottie/animation-inline-image.lottie b/Tests/Samples/DotLottie/animation_inline_image.lottie similarity index 100% rename from Tests/Samples/DotLottie/animation-inline-image.lottie rename to Tests/Samples/DotLottie/animation_inline_image.lottie diff --git a/Tests/SnapshotTests.swift b/Tests/SnapshotTests.swift index d423930a06..33f89e6f76 100644 --- a/Tests/SnapshotTests.swift +++ b/Tests/SnapshotTests.swift @@ -12,23 +12,24 @@ import UIKit // MARK: - SnapshotTests +@MainActor class SnapshotTests: XCTestCase { // MARK: Internal /// Snapshots all of the sample animation JSON files visible to this test target - func testMainThreadRenderingEngine() throws { - try compareSampleSnapshots(configuration: LottieConfiguration(renderingEngine: .mainThread)) + func testMainThreadRenderingEngine() async throws { + try await compareSampleSnapshots(configuration: LottieConfiguration(renderingEngine: .mainThread)) } /// Snapshots sample animation files using the Core Animation rendering engine - func testCoreAnimationRenderingEngine() throws { - try compareSampleSnapshots(configuration: LottieConfiguration(renderingEngine: .coreAnimation)) + func testCoreAnimationRenderingEngine() async throws { + try await compareSampleSnapshots(configuration: LottieConfiguration(renderingEngine: .coreAnimation)) } /// Snapshots sample animation files using the automatic rendering engine option - func testAutomaticRenderingEngine() throws { - try compareSampleSnapshots(configuration: LottieConfiguration(renderingEngine: .automatic)) + func testAutomaticRenderingEngine() async throws { + try await compareSampleSnapshots(configuration: LottieConfiguration(renderingEngine: .automatic)) } /// Validates that all of the snapshots in __Snapshots__ correspond to @@ -57,7 +58,8 @@ class SnapshotTests: XCTestCase { animationName = animationName.replacingOccurrences(of: "-", with: "/") XCTAssert( - Samples.sampleAnimationURLs.contains(where: { $0.absoluteString.hasSuffix("\(animationName).json") }), + Samples.sampleAnimationURLs.contains(where: { $0.absoluteString.hasSuffix("\(animationName).json") }) + || Samples.sampleAnimationURLs.contains(where: { $0.absoluteString.hasSuffix("\(animationName).lottie") }), "Snapshot \"\(snapshotURL.lastPathComponent)\" has no corresponding sample animation") } } @@ -99,7 +101,7 @@ class SnapshotTests: XCTestCase { /// Captures snapshots of `sampleAnimationURLs` and compares them to the snapshot images stored on disk private func compareSampleSnapshots( configuration: LottieConfiguration, - testName: String = #function) throws + testName: String = #function) async throws { #if os(iOS) guard UIScreen.main.scale == 2 else { @@ -113,7 +115,7 @@ class SnapshotTests: XCTestCase { for sampleAnimationName in Samples.sampleAnimationNames { for percent in progressPercentagesToSnapshot { guard - let animationView = SnapshotConfiguration.makeAnimationView( + let animationView = await SnapshotConfiguration.makeAnimationView( for: sampleAnimationName, configuration: configuration) else { continue } @@ -189,9 +191,8 @@ enum Samples { withSuffix: "png") /// The list of sample animation files in `Tests/Samples` - static let sampleAnimationURLs = Bundle.module.fileURLs( - in: Samples.directoryName, - withSuffix: "json") + static let sampleAnimationURLs = Bundle.module.fileURLs(in: Samples.directoryName, withSuffix: "json") + + Bundle.module.fileURLs(in: Samples.directoryName, withSuffix: "lottie") /// The list of sample animation names in `Tests/Samples` static let sampleAnimationNames = sampleAnimationURLs.lazy @@ -207,6 +208,7 @@ enum Samples { return subpath .joined(separator: "/") .replacingOccurrences(of: ".json", with: "") + .replacingOccurrences(of: ".lottie", with: "") } static func animation(named sampleAnimationName: String) -> LottieAnimation? { @@ -215,67 +217,64 @@ enum Samples { sampleAnimationName, bundle: .module, subdirectory: Samples.directoryName) - else { - XCTFail("Could not parse Samples/\(sampleAnimationName).json") - return nil - } + else { return nil } return animation } - static func dotLottie(named sampleDotLottieName: String, closure: @escaping (DotLottieFile?) -> Void) { - DotLottieFile.named( - sampleDotLottieName, - bundle: .module, - subdirectory: Samples.directoryName) - { result in - switch result { - case .success(let lottie): - closure(lottie) - case .failure: - XCTFail("Could not parse Samples/\(sampleDotLottieName).lottie") - closure(nil) - } + static func dotLottie(named sampleDotLottieName: String) async -> DotLottieFile? { + guard + let dotLottieFile = try? await DotLottieFile.named( + sampleDotLottieName, + bundle: .module, + subdirectory: Samples.directoryName) + else { + XCTFail("Could not parse Samples/\(sampleDotLottieName).lottie") + return nil } + + return dotLottieFile } } extension SnapshotConfiguration { /// Creates a `LottieAnimationView` for the sample snapshot with the given name + @MainActor static func makeAnimationView( for sampleAnimationName: String, configuration: LottieConfiguration, logger: LottieLogger = LottieLogger.shared) - -> LottieAnimationView? - { + async -> LottieAnimationView? { let snapshotConfiguration = SnapshotConfiguration.forSample(named: sampleAnimationName) - guard - snapshotConfiguration.shouldSnapshot(using: configuration) - else { return nil } + guard snapshotConfiguration.shouldSnapshot(using: configuration) else { + return nil + } - var animationView: LottieAnimationView? + let animationView: LottieAnimationView if let animation = Samples.animation(named: sampleAnimationName) { animationView = LottieAnimationView( animation: animation, configuration: configuration, logger: logger) - animationView?.frame.size = animation.snapshotSize - } else { + } else if let dotLottieFile = await Samples.dotLottie(named: sampleAnimationName) { animationView = LottieAnimationView( - dotLottie: nil, + dotLottie: dotLottieFile, configuration: configuration, logger: logger) - Samples.dotLottie(named: sampleAnimationName) { lottie in - guard let lottie = lottie else { return } - animationView?.loadAnimation(from: lottie) - } + } else { + XCTFail("Couldn't create Animation View for \(sampleAnimationName)") + return nil } - guard let animationView = animationView else { return nil } + guard let animation = animationView.animation else { + XCTFail("Couldn't create Animation View for \(sampleAnimationName)") + return nil + } // Set up the animation view with a valid frame // so the geometry is correct when setting up the `CAAnimation`s + animationView.frame.size = animation.snapshotSize for (keypath, customValueProvider) in snapshotConfiguration.customValueProviders { animationView.setValueProvider(customValueProvider, keypath: keypath) diff --git a/Tests/ValueProvidersTests.swift b/Tests/ValueProvidersTests.swift index 6e394b0927..8b316ea5eb 100644 --- a/Tests/ValueProvidersTests.swift +++ b/Tests/ValueProvidersTests.swift @@ -8,12 +8,15 @@ import Lottie import XCTest +@MainActor final class ValueProvidersTests: XCTestCase { - func testGetValue() throws { - let animationView = try XCTUnwrap(SnapshotConfiguration.makeAnimationView( + func testGetValue() async throws { + let optionalAnimationView = await SnapshotConfiguration.makeAnimationView( for: "HamburgerArrow", - configuration: .init(renderingEngine: .mainThread))) + configuration: .init(renderingEngine: .mainThread)) + + let animationView = try XCTUnwrap(optionalAnimationView) let keypath = AnimationKeypath(keypath: "A1.Shape 1.Stroke 1.Color") animationView.setValueProvider(ColorValueProvider(.red), keypath: keypath)