From 5f21ab00a6ad2f8da1d799b130b2347370737ae4 Mon Sep 17 00:00:00 2001 From: miguel-jimenez-0529 Date: Wed, 26 Jul 2023 17:24:08 -0400 Subject: [PATCH] Add SwiftUI support for loading async LottieAnimations and DotLottieFiles Co-authored-by: Cal Stephens --- Example/Example.xcodeproj/project.pbxproj | 6 + Example/Example/AnimationListView.swift | 25 +++- Example/Example/AnimationPreviewView.swift | 67 ++++++++- Example/Example/Example.entitlements | 8 +- Example/Example/RemoteAnimationDemoView.swift | 63 ++++++++ .../SampleListViewController.swift | 14 +- Lottie.xcodeproj/project.pbxproj | 8 + .../DotLottie/DotLottieImageProvider.swift | 13 ++ .../Utility/LottieAnimationSource.swift | 17 +++ .../Animation/LottieAnimationLayer.swift | 11 ++ .../Animation/LottieAnimationView.swift | 41 ++++++ Sources/Public/Animation/LottieView.swift | 139 +++++++++++++++--- Sources/Public/DotLottie/DotLottieFile.swift | 2 +- 13 files changed, 377 insertions(+), 37 deletions(-) create mode 100644 Example/Example/RemoteAnimationDemoView.swift create mode 100644 Sources/Private/Utility/LottieAnimationSource.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 0d5997627e..7d498b1e46 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -29,6 +29,8 @@ 2E9E77AF27602BD400C84BA3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9E77AE27602BD400C84BA3 /* AppDelegate.swift */; }; 2EC6E5082763D981002E091C /* LinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC6E5072763D981002E091C /* LinkView.swift */; }; 2EC6E5102763E79F002E091C /* AnimationPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC6E50F2763E79F002E091C /* AnimationPreviewViewController.swift */; }; + AB3278112A71A86E00A9C9F1 /* RemoteAnimationDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB82EA9A2A7090B400AEBB48 /* RemoteAnimationDemoView.swift */; }; + AB82EA9B2A7090B400AEBB48 /* RemoteAnimationDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB82EA9A2A7090B400AEBB48 /* RemoteAnimationDemoView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -67,6 +69,7 @@ 2E9E77BC27602BD400C84BA3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 2EC6E5072763D981002E091C /* LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkView.swift; sourceTree = ""; }; 2EC6E50F2763E79F002E091C /* AnimationPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationPreviewViewController.swift; sourceTree = ""; }; + AB82EA9A2A7090B400AEBB48 /* RemoteAnimationDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAnimationDemoView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -101,6 +104,7 @@ 08E359932A55FFC400141956 /* LottieViewLayoutDemoView.swift */, 08E359972A55FFC600141956 /* Example.entitlements */, 607FACD11AFB9204008FA782 /* Products */, + AB82EA9A2A7090B400AEBB48 /* RemoteAnimationDemoView.swift */, ); path = Example; sourceTree = ""; @@ -284,6 +288,7 @@ 08E359922A55FFC400141956 /* ExampleApp.swift in Sources */, 085D97872A5E0DB600C78D18 /* AnimationPreviewView.swift in Sources */, 085D97852A5DF94C00C78D18 /* AnimationListView.swift in Sources */, + AB3278112A71A86E00A9C9F1 /* RemoteAnimationDemoView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -295,6 +300,7 @@ 2E1670CC2784F9C1009CDED3 /* AnimatedSwitchRow.swift in Sources */, 2E97E3052767E7C600FE22C3 /* Configuration.swift in Sources */, 2E1670C32784F009009CDED3 /* ControlsDemoViewController.swift in Sources */, + AB82EA9B2A7090B400AEBB48 /* RemoteAnimationDemoView.swift in Sources */, 2E1670CA2784F123009CDED3 /* AnimatedButtonRow.swift in Sources */, 2E362A1E2762BA06006AE7D2 /* SampleListViewController.swift in Sources */, 2E9E77AF27602BD400C84BA3 /* AppDelegate.swift in Sources */, diff --git a/Example/Example/AnimationListView.swift b/Example/Example/AnimationListView.swift index ed0f41d155..407d905df6 100644 --- a/Example/Example/AnimationListView.swift +++ b/Example/Example/AnimationListView.swift @@ -8,6 +8,8 @@ import SwiftUI struct AnimationListView: View { + // MARK: Internal + let directory: String var body: some View { @@ -29,18 +31,31 @@ struct AnimationListView: View { case .subdirectory(let subdirectoryURL): Text(subdirectoryURL.lastPathComponent) .frame(height: 50) + case .remoteDemo: + Text("Remote animations") + .frame(height: 50) } } .navigationDestination(for: Item.self) { item in switch item { case .animation(_, let animationPath): - AnimationPreviewView(animationName: animationPath) + AnimationPreviewView(animationSource: .local(animationPath: animationPath)) case .subdirectory(let subdirectoryURL): AnimationListView(directory: "\(directory)/\(subdirectoryURL.lastPathComponent)") + case .remoteDemo: + // View is already contained in a nav stack + RemoteAnimationsDemoView(wrapInNavStack: false) } } } - }.navigationTitle(directory) + } + .navigationTitle(directory) + } + + // MARK: Private + + private var isTopLevel: Bool { + directory == "Samples" } } @@ -52,11 +67,13 @@ extension AnimationListView { enum Item: Hashable { case subdirectory(URL) case animation(name: String, path: String) + case remoteDemo } var items: [Item] { animations.map { .animation(name: $0.name, path: $0.path) } + subdirectoryURLs.map { .subdirectory($0) } + + customDemos } // MARK: Private @@ -92,4 +109,8 @@ extension AnimationListView { (Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: directory) ?? []) + (Bundle.main.urls(forResourcesWithExtension: "lottie", subdirectory: directory) ?? []) } + + private var customDemos: [Item] { + isTopLevel ? [.remoteDemo] : [] + } } diff --git a/Example/Example/AnimationPreviewView.swift b/Example/Example/AnimationPreviewView.swift index 5cc5e51df9..3d4aea1ee4 100644 --- a/Example/Example/AnimationPreviewView.swift +++ b/Example/Example/AnimationPreviewView.swift @@ -11,16 +11,34 @@ struct AnimationPreviewView: View { // MARK: Internal - let animationName: String + enum AnimationSource { + case local(animationPath: String) + case remote(url: URL, name: String) + + var name: String { + switch self { + case .local(let name): + return name + case .remote(_, let name): + return name + } + } + } + + let animationSource: AnimationSource var body: some View { VStack { - LottieView(animation: .named(animationName)) - .imageProvider(.exampleAppSampleImages) - .resizable() - .looping() - .currentProgress(animationPlaying ? nil : sliderValue) - .getRealtimeAnimationProgress(animationPlaying ? $sliderValue : nil) + LottieView { + try await lottieSource() + } placeholder: { + LoadingIndicator() + } + .imageProvider(.exampleAppSampleImages) + .resizable() + .looping() + .currentProgress(animationPlaying ? nil : sliderValue) + .getRealtimeAnimationProgress(animationPlaying ? $sliderValue : nil) Spacer() @@ -33,7 +51,7 @@ struct AnimationPreviewView: View { .padding(.all, 16) #endif } - .navigationTitle(animationName.components(separatedBy: "/").last!) + .navigationTitle(animationSource.name.components(separatedBy: "/").last!) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.secondaryBackground) } @@ -43,6 +61,20 @@ struct AnimationPreviewView: View { @State private var animationPlaying = true @State private var sliderValue: AnimationProgressTime = 0 + private func lottieSource() async throws -> LottieAnimationSource? { + switch animationSource { + case .local(let name): + if let animation = LottieAnimation.named(name) { + return .lottieAnimation(animation) + } else { + let lottie = try await DotLottieFile.named(name) + return .dotLottieFile(lottie) + } + case .remote(let url, _): + let animation = await LottieAnimation.loadedFrom(url: url) + return animation.map(LottieAnimationSource.lottieAnimation) + } + } } extension Color { @@ -60,3 +92,22 @@ extension AnimationImageProvider where Self == FilepathImageProvider { FilepathImageProvider(filepath: Bundle.main.resourceURL!.appending(path: "Samples/Images")) } } + +// MARK: - LoadingIndicator + +struct LoadingIndicator: View { + @State private var animating = false + + var body: some View { + Image(systemName: "rays") + .rotationEffect(animating ? Angle.degrees(360) : .zero) + .animation( + Animation + .linear(duration: 2) + .repeatForever(autoreverses: false), + value: animating) + .onAppear { + animating = true + } + } +} diff --git a/Example/Example/Example.entitlements b/Example/Example/Example.entitlements index f2ef3ae026..311b32bd20 100644 --- a/Example/Example/Example.entitlements +++ b/Example/Example/Example.entitlements @@ -2,9 +2,9 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + diff --git a/Example/Example/RemoteAnimationDemoView.swift b/Example/Example/RemoteAnimationDemoView.swift new file mode 100644 index 0000000000..aa82505ace --- /dev/null +++ b/Example/Example/RemoteAnimationDemoView.swift @@ -0,0 +1,63 @@ +// Created by miguel_jimenez on 7/25/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +import Lottie +import SwiftUI + +// MARK: - AnimationListView + +struct RemoteAnimationsDemoView: View { + + struct Item: Hashable { + let name: String + let url: URL + } + + let wrapInNavStack: Bool + + var body: some View { + if wrapInNavStack { + NavigationStack { + listBody + } + } else { + listBody + } + } + + var listBody: some View { + List { + ForEach(items, id: \.self) { item in + NavigationLink(value: item) { + HStack { + LottieView { + await LottieAnimation.loadedFrom(url: item.url) + } placeholder: { + LoadingIndicator() + } + .currentProgress(0.5) + .imageProvider(.exampleAppSampleImages) + .frame(width: 50, height: 50) + .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + + Text(item.name) + } + } + .navigationDestination(for: Item.self) { item in + AnimationPreviewView(animationSource: .remote(url: item.url, name: item.name)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Remote Animations") + } + } + + var items: [Item] { + [ + Item( + name: "Rooms Animation", + url: URL(string: "https://a0.muscache.com/pictures/96699af6-b73e-499f-b0f5-3c862ae7d126.json")!), + ] + } + +} diff --git a/Example/iOS/ViewControllers/SampleListViewController.swift b/Example/iOS/ViewControllers/SampleListViewController.swift index 6e5d3640b8..a98e213a6f 100644 --- a/Example/iOS/ViewControllers/SampleListViewController.swift +++ b/Example/iOS/ViewControllers/SampleListViewController.swift @@ -59,6 +59,7 @@ final class SampleListViewController: CollectionViewController { if isTopLevel { demoLinks + remoteAnimationLinks } } @@ -87,7 +88,7 @@ final class SampleListViewController: CollectionViewController { switch Configuration.previewImplementation { case .swiftUI: previewViewController = UIHostingController( - rootView: AnimationPreviewView(animationName: animationPath)) + rootView: AnimationPreviewView(animationSource: .local(animationPath: animationPath))) case .uiKit: previewViewController = AnimationPreviewViewController(animationPath) @@ -124,6 +125,17 @@ final class SampleListViewController: CollectionViewController { } } + @ItemModelBuilder + private var remoteAnimationLinks: [ItemModeling] { + LinkView.itemModel( + dataID: "Remote animations", + content: .init(animationName: nil, title: "Remote animations")) + .didSelect { [weak self] _ in + let remoteAnimationsDemo = UIHostingController(rootView: RemoteAnimationsDemoView(wrapInNavStack: true)) + self?.present(remoteAnimationsDemo, animated: true) + } + } + private func configureSettingsMenu() { navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Settings", diff --git a/Lottie.xcodeproj/project.pbxproj b/Lottie.xcodeproj/project.pbxproj index 3da54967f2..353bf059bf 100644 --- a/Lottie.xcodeproj/project.pbxproj +++ b/Lottie.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 0819D2A12A718CAE00D7DE49 /* LottieAnimationSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0819D2A02A718CAE00D7DE49 /* LottieAnimationSource.swift */; }; + 0819D2A22A718CAE00D7DE49 /* LottieAnimationSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0819D2A02A718CAE00D7DE49 /* LottieAnimationSource.swift */; }; + 0819D2A32A718CAE00D7DE49 /* LottieAnimationSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0819D2A02A718CAE00D7DE49 /* LottieAnimationSource.swift */; }; 0887346F28F0CBDE00458627 /* LottieAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0887346E28F0CBDE00458627 /* LottieAnimation.swift */; }; 0887347028F0CBDE00458627 /* LottieAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0887346E28F0CBDE00458627 /* LottieAnimation.swift */; }; 0887347128F0CBDE00458627 /* LottieAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0887346E28F0CBDE00458627 /* LottieAnimation.swift */; }; @@ -833,6 +836,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0819D2A02A718CAE00D7DE49 /* LottieAnimationSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationSource.swift; sourceTree = ""; }; 0887346E28F0CBDE00458627 /* LottieAnimation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LottieAnimation.swift; sourceTree = ""; }; 0887347228F0CCDD00458627 /* LottieAnimationHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LottieAnimationHelpers.swift; sourceTree = ""; }; 0887347328F0CCDD00458627 /* LottieAnimationViewInitializers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LottieAnimationViewInitializers.swift; sourceTree = ""; }; @@ -1756,6 +1760,7 @@ 2E9C95C72822F43100677516 /* Primitives */, 2E9C95CE2822F43100677516 /* Interpolatable */, 2E9C95D12822F43100677516 /* Helpers */, + 0819D2A02A718CAE00D7DE49 /* LottieAnimationSource.swift */, ); path = Utility; sourceTree = ""; @@ -2267,6 +2272,7 @@ 2E9C96422822F43100677516 /* RootAnimationLayer.swift in Sources */, 2E9C97712822F43100677516 /* AnimationContext.swift in Sources */, 08C002052A46150D00AB54BA /* Archive+Progress.swift in Sources */, + 0819D2A12A718CAE00D7DE49 /* LottieAnimationSource.swift in Sources */, 08C002F52A461D6A00AB54BA /* LottieView.swift in Sources */, 2E9C96B12822F43100677516 /* NodeProperty.swift in Sources */, 2E9C965D2822F43100677516 /* MainThreadAnimationLayer.swift in Sources */, @@ -2615,6 +2621,7 @@ 2E9C95E92822F43100677516 /* Merge.swift in Sources */, 2E9C96042822F43100677516 /* ImageLayerModel.swift in Sources */, 19465F53282F998B00BB2C97 /* CachedImageProvider.swift in Sources */, + 0819D2A22A718CAE00D7DE49 /* LottieAnimationSource.swift in Sources */, 08F8B20E2898A7B100CB5323 /* RepeaterLayer.swift in Sources */, 0887347928F0CCDD00458627 /* LottieAnimationViewInitializers.swift in Sources */, 2E9C96BB2822F43100677516 /* KeypathSearchable.swift in Sources */, @@ -2832,6 +2839,7 @@ 2E9C96442822F43100677516 /* RootAnimationLayer.swift in Sources */, 2E9C97732822F43200677516 /* AnimationContext.swift in Sources */, 08C002F62A461D6A00AB54BA /* LottieView.swift in Sources */, + 0819D2A32A718CAE00D7DE49 /* LottieAnimationSource.swift in Sources */, 2E9C96B32822F43100677516 /* NodeProperty.swift in Sources */, 2E9C965F2822F43100677516 /* MainThreadAnimationLayer.swift in Sources */, 2E9C96502822F43100677516 /* SolidCompositionLayer.swift in Sources */, diff --git a/Sources/Private/Model/DotLottie/DotLottieImageProvider.swift b/Sources/Private/Model/DotLottie/DotLottieImageProvider.swift index 8d3afe9cfa..43af2fe951 100644 --- a/Sources/Private/Model/DotLottie/DotLottieImageProvider.swift +++ b/Sources/Private/Model/DotLottie/DotLottieImageProvider.swift @@ -71,3 +71,16 @@ class DotLottieImageProvider: AnimationImageProvider { } } + +// MARK: Hashable + +extension DotLottieImageProvider: Hashable { + static func ==(_ lhs: DotLottieImageProvider, _ rhs: DotLottieImageProvider) -> Bool { + lhs.filepath == rhs.filepath + } + + func hash(into hasher: inout Hasher) { + hasher.combine(filepath) + } + +} diff --git a/Sources/Private/Utility/LottieAnimationSource.swift b/Sources/Private/Utility/LottieAnimationSource.swift new file mode 100644 index 0000000000..a3d28d0c27 --- /dev/null +++ b/Sources/Private/Utility/LottieAnimationSource.swift @@ -0,0 +1,17 @@ +// Created by Cal Stephens on 7/26/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +public enum LottieAnimationSource { + case lottieAnimation(LottieAnimation) + case dotLottieFile(DotLottieFile) + + /// The animation displayed by this data source + var animation: LottieAnimation? { + switch self { + case .lottieAnimation(let animation): + return animation + case .dotLottieFile(let dotLottieFile): + return dotLottieFile.animation()?.animation + } + } +} diff --git a/Sources/Public/Animation/LottieAnimationLayer.swift b/Sources/Public/Animation/LottieAnimationLayer.swift index 406b13e8c2..ee6dc087fa 100644 --- a/Sources/Public/Animation/LottieAnimationLayer.swift +++ b/Sources/Public/Animation/LottieAnimationLayer.swift @@ -925,6 +925,17 @@ public class LottieAnimationLayer: CALayer { addNewAnimationForContext(newContext) } + func loadAnimation(_ animationSource: LottieAnimationSource?) { + switch animationSource { + case .lottieAnimation(let animation): + self.animation = animation + case .dotLottieFile(let dotLottieFile): + loadAnimation(from: dotLottieFile) + case nil: + animation = nil + } + } + // MARK: Fileprivate fileprivate var _activeAnimationName: String = LottieAnimationLayer.animationName diff --git a/Sources/Public/Animation/LottieAnimationView.swift b/Sources/Public/Animation/LottieAnimationView.swift index 59d7aaac79..fbb0feae8e 100644 --- a/Sources/Public/Animation/LottieAnimationView.swift +++ b/Sources/Public/Animation/LottieAnimationView.swift @@ -180,6 +180,43 @@ open class LottieAnimationView: LottieAnimationViewBase { commonInit() } + convenience init( + animationSource: LottieAnimationSource?, + imageProvider: AnimationImageProvider? = nil, + textProvider: AnimationTextProvider = DefaultTextProvider(), + fontProvider: AnimationFontProvider = DefaultFontProvider(), + configuration: LottieConfiguration = .shared, + logger: LottieLogger = .shared) + { + switch animationSource { + case .lottieAnimation(let animation): + self.init( + animation: animation, + imageProvider: imageProvider, + textProvider: textProvider, + fontProvider: fontProvider, + configuration: configuration, + logger: logger) + + case .dotLottieFile(let dotLottieFile): + self.init( + dotLottie: dotLottieFile, + textProvider: textProvider, + fontProvider: fontProvider, + configuration: configuration, + logger: logger) + + case nil: + self.init( + animation: nil, + imageProvider: imageProvider, + textProvider: textProvider, + fontProvider: fontProvider, + configuration: configuration, + logger: logger) + } + } + // MARK: Open /// Plays the animation from its current state to the end. @@ -937,6 +974,10 @@ open class LottieAnimationView: LottieAnimationViewBase { lottieAnimationLayer.updateInFlightAnimation() } + func loadAnimation(_ animationSource: LottieAnimationSource?) { + lottieAnimationLayer.loadAnimation(animationSource) + } + // MARK: Fileprivate fileprivate var waitingToPlayAnimation = false diff --git a/Sources/Public/Animation/LottieView.swift b/Sources/Public/Animation/LottieView.swift index 8218596b89..eea6028602 100644 --- a/Sources/Public/Animation/LottieView.swift +++ b/Sources/Public/Animation/LottieView.swift @@ -1,42 +1,111 @@ // Created by Bryn Bodayle on 1/20/22. // Copyright © 2022 Airbnb Inc. All rights reserved. +import Combine import SwiftUI // MARK: - LottieView /// A wrapper which exposes Lottie's `LottieAnimationView` to SwiftUI @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) -public struct LottieView: UIViewConfiguringSwiftUIView { +public struct LottieView: UIViewConfiguringSwiftUIView { // MARK: Lifecycle /// Creates a `LottieView` that displays the given animation - public init(animation: LottieAnimation?) { - self.animation = animation + public init(animation: LottieAnimation?) where Placeholder == EmptyView { + _animationSource = State(initialValue: animation.map(LottieAnimationSource.lottieAnimation)) + placeholder = nil + } + + /// Creates a `LottieView` that displays the given `DotLottieFile` + public init(dotLottieFile: DotLottieFile?) where Placeholder == EmptyView { + _animationSource = State(initialValue: dotLottieFile.map(LottieAnimationSource.dotLottieFile)) + placeholder = nil + } + + /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimation`. + /// The `loadAnimation` closure is called exactly once in `onAppear`. + public init(_ loadAnimation: @escaping () async throws -> LottieAnimation?) where Placeholder == EmptyView { + self.init(loadAnimation, placeholder: EmptyView.init) + } + + /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimation`. + /// The `loadAnimation` closure is called exactly once in `onAppear`. + /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`. + public init( + _ loadAnimation: @escaping () async throws -> LottieAnimation?, + @ViewBuilder placeholder: @escaping (() -> Placeholder)) + { + self.init { + try await loadAnimation().map(LottieAnimationSource.lottieAnimation) + } placeholder: { + placeholder() + } + } + + /// Creates a `LottieView` that asynchronously loads and displays the given `DotLottieFile`. + /// The `loadDotLottieFile` closure is called exactly once in `onAppear`. + public init(_ loadDotLottieFile: @escaping () async throws -> DotLottieFile?) where Placeholder == EmptyView { + self.init(loadDotLottieFile, placeholder: EmptyView.init) + } + + /// Creates a `LottieView` that asynchronously loads and displays the given `DotLottieFile`. + /// The `loadDotLottieFile` closure is called exactly once in `onAppear`. + /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`. + public init( + _ loadDotLottieFile: @escaping () async throws -> DotLottieFile?, + @ViewBuilder placeholder: @escaping (() -> Placeholder)) + { + self.init { + try await loadDotLottieFile().map(LottieAnimationSource.dotLottieFile) + } placeholder: { + placeholder() + } + } + + /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimationSource`. + /// The `loadAnimation` closure is called exactly once in `onAppear`. + /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`. + public init( + loadAnimation: @escaping () async throws -> LottieAnimationSource?, + @ViewBuilder placeholder: @escaping () -> Placeholder) + { + self.loadAnimation = loadAnimation + self.placeholder = placeholder + _animationSource = State(initialValue: nil) } // MARK: Public public var body: some View { - LottieAnimationView.swiftUIView { - LottieAnimationView( - animation: animation, - imageProvider: imageProvider, - textProvider: textProvider, - fontProvider: fontProvider, - configuration: configuration) - } - .sizing(sizing) - .configure { context in - // We check referential equality of the animation before updating as updating the - // animation has a side-effect of rebuilding the animation layer, and it would be - // prohibitive to do so on every state update. - if animation !== context.view.animation { - context.view.animation = animation + ZStack { + if let animationSource = animationSource { + LottieAnimationView.swiftUIView { + LottieAnimationView( + animationSource: animationSource, + imageProvider: imageProvider, + textProvider: textProvider, + fontProvider: fontProvider, + configuration: configuration) + } + .sizing(sizing) + .configure { context in + // We check referential equality of the animation before updating as updating the + // animation has a side-effect of rebuilding the animation layer, and it would be + // prohibitive to do so on every state update. + if animationSource.animation !== context.view.animation { + context.view.loadAnimation(animationSource) + } + } + .configurations(configurations) + } else { + placeholder?() } } - .configurations(configurations) + .onAppear { + loadAnimationIfNecessary() + } } /// Returns a copy of this `LottieView` updated to have the given closure applied to its @@ -88,7 +157,7 @@ public struct LottieView: UIViewConfiguringSwiftUIView { } } - /// Returns a copt of this view with its `LottieConfiguration` updated to the given value. + /// Returns a copy of this view with its `LottieConfiguration` updated to the given value. public func configuration(_ configuration: LottieConfiguration) -> Self { var copy = self copy.configuration = configuration @@ -102,6 +171,15 @@ public struct LottieView: UIViewConfiguringSwiftUIView { return copy } + /// Returns a copy of this view with its `LottieLogger` updated to the given value. + /// - The underlying `LottieAnimationView`'s `LottieLogger` is immutable after configured, + /// so this value is only used when initializing the `LottieAnimationView` for the first time. + public func logger(_ logger: LottieLogger) -> Self { + var copy = self + copy.logger = logger + return copy + } + /// Returns a copy of this view with its image provider updated to the given value. /// The image provider must be `Equatable` to avoid unnecessary state updates / re-renders. public func imageProvider(_ imageProvider: ImageProvider) -> Self { @@ -255,10 +333,29 @@ public struct LottieView: UIViewConfiguringSwiftUIView { // MARK: Private - private let animation: LottieAnimation? + @State private var animationSource: LottieAnimationSource? + private var loadAnimation: (() async throws -> LottieAnimationSource?)? private var imageProvider: AnimationImageProvider? private var textProvider: AnimationTextProvider = DefaultTextProvider() private var fontProvider: AnimationFontProvider = DefaultFontProvider() private var configuration: LottieConfiguration = .shared + private var logger: LottieLogger = .shared private var sizing = SwiftUIMeasurementContainerStrategy.automatic + private let placeholder: (() -> Placeholder)? + + private func loadAnimationIfNecessary() { + guard + let loadAnimation = loadAnimation, + animationSource == nil + else { return } + + Task { + do { + animationSource = try await loadAnimation() + } catch { + logger.warn("Failed to load asynchronous Lottie animation with error: \(error)") + } + } + } + } diff --git a/Sources/Public/DotLottie/DotLottieFile.swift b/Sources/Public/DotLottie/DotLottieFile.swift index 72043bdd2a..41edce11f2 100644 --- a/Sources/Public/DotLottie/DotLottieFile.swift +++ b/Sources/Public/DotLottie/DotLottieFile.swift @@ -39,7 +39,7 @@ public final class DotLottieFile { // MARK: Internal /// Image provider for animations - private(set) var imageProvider: AnimationImageProvider? + private(set) var imageProvider: DotLottieImageProvider? /// Animations folder url lazy var animationsUrl: URL = fileUrl.appendingPathComponent("\(DotLottieFile.animationsFolderName)")