From 797b8188976d92eace212f77167245ed201687f9 Mon Sep 17 00:00:00 2001 From: Miguel Jimenez Date: Thu, 27 Jul 2023 17:11:25 -0400 Subject: [PATCH] Add `loadAnimationTrigger` to support calling `loadAnimation` again after `onAppear` (#2118) --- Example/Example/AnimationPreviewView.swift | 37 +++++++++++++++++-- Example/Example/RemoteAnimationDemoView.swift | 14 +++++-- Lottie.xcodeproj/project.pbxproj | 16 ++++++++ .../Private/Utility/Helpers/Binding+Map.swift | 18 +++++++++ .../Utility/Helpers/View+ValueChanged.swift | 20 ++++++++++ Sources/Public/Animation/LottieView.swift | 24 +++++++++++- 6 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 Sources/Private/Utility/Helpers/Binding+Map.swift create mode 100644 Sources/Private/Utility/Helpers/View+ValueChanged.swift diff --git a/Example/Example/AnimationPreviewView.swift b/Example/Example/AnimationPreviewView.swift index 3d4aea1ee4..b6559740d2 100644 --- a/Example/Example/AnimationPreviewView.swift +++ b/Example/Example/AnimationPreviewView.swift @@ -9,11 +9,26 @@ import SwiftUI /// TODO: Implement functionality from UIKit `AnimationPreviewViewController` struct AnimationPreviewView: View { + // MARK: Lifecycle + + init(animationSource: AnimationSource) { + self.animationSource = animationSource + + switch animationSource { + case .remote(let urls, _): + _currentURLIndex = State(initialValue: urls.startIndex) + self.urls = urls + default: + _currentURLIndex = State(initialValue: 0) + urls = [] + } + } + // MARK: Internal enum AnimationSource { case local(animationPath: String) - case remote(url: URL, name: String) + case remote(urls: [URL], name: String) var name: String { switch self { @@ -33,9 +48,11 @@ struct AnimationPreviewView: View { try await lottieSource() } placeholder: { LoadingIndicator() + .frame(width: 50, height: 50) } .imageProvider(.exampleAppSampleImages) .resizable() + .loadAnimationTrigger($currentURLIndex) .looping() .currentProgress(animationPlaying ? nil : sliderValue) .getRealtimeAnimationProgress(animationPlaying ? $sliderValue : nil) @@ -54,12 +71,20 @@ struct AnimationPreviewView: View { .navigationTitle(animationSource.name.components(separatedBy: "/").last!) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.secondaryBackground) + .onReceive(timer) { _ in + updateIndex() + } } // MARK: Private + /// Used for remote animations only, when more than one URL is provided we loop over the urls every 2 seconds. + private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() + private let urls: [URL] + @State private var animationPlaying = true @State private var sliderValue: AnimationProgressTime = 0 + @State private var currentURLIndex: Int private func lottieSource() async throws -> LottieAnimationSource? { switch animationSource { @@ -70,11 +95,17 @@ struct AnimationPreviewView: View { let lottie = try await DotLottieFile.named(name) return .dotLottieFile(lottie) } - case .remote(let url, _): - let animation = await LottieAnimation.loadedFrom(url: url) + case .remote: + let animation = await LottieAnimation.loadedFrom(url: urls[currentURLIndex]) return animation.map(LottieAnimationSource.lottieAnimation) } } + + private func updateIndex() { + let currentIndex = currentURLIndex + let nextIndex = currentIndex == urls.index(before: urls.endIndex) ? urls.startIndex : currentIndex + 1 + currentURLIndex = nextIndex + } } extension Color { diff --git a/Example/Example/RemoteAnimationDemoView.swift b/Example/Example/RemoteAnimationDemoView.swift index aa82505ace..3e6fc8e3f8 100644 --- a/Example/Example/RemoteAnimationDemoView.swift +++ b/Example/Example/RemoteAnimationDemoView.swift @@ -10,7 +10,7 @@ struct RemoteAnimationsDemoView: View { struct Item: Hashable { let name: String - let url: URL + let urls: [URL] } let wrapInNavStack: Bool @@ -31,7 +31,7 @@ struct RemoteAnimationsDemoView: View { NavigationLink(value: item) { HStack { LottieView { - await LottieAnimation.loadedFrom(url: item.url) + await LottieAnimation.loadedFrom(url: item.urls.first!) } placeholder: { LoadingIndicator() } @@ -44,7 +44,7 @@ struct RemoteAnimationsDemoView: View { } } .navigationDestination(for: Item.self) { item in - AnimationPreviewView(animationSource: .remote(url: item.url, name: item.name)) + AnimationPreviewView(animationSource: .remote(urls: item.urls, name: item.name)) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -56,7 +56,13 @@ struct RemoteAnimationsDemoView: View { [ Item( name: "Rooms Animation", - url: URL(string: "https://a0.muscache.com/pictures/96699af6-b73e-499f-b0f5-3c862ae7d126.json")!), + urls: [URL(string: "https://a0.muscache.com/pictures/96699af6-b73e-499f-b0f5-3c862ae7d126.json")!]), + Item( + name: "Multiple Animations", + urls: [ + URL(string: "https://a0.muscache.com/pictures/a7c140ee-6818-4a8a-b3b1-0c785054a611.json")!, + URL(string: "https://a0.muscache.com/pictures/96699af6-b73e-499f-b0f5-3c862ae7d126.json")!, + ]), ] } diff --git a/Lottie.xcodeproj/project.pbxproj b/Lottie.xcodeproj/project.pbxproj index 353bf059bf..bdbcdbc5cf 100644 --- a/Lottie.xcodeproj/project.pbxproj +++ b/Lottie.xcodeproj/project.pbxproj @@ -816,6 +816,12 @@ A40460592832C52B00ACFEDC /* BlendMode+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40460582832C52B00ACFEDC /* BlendMode+Filter.swift */; }; A404605A2832C52B00ACFEDC /* BlendMode+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40460582832C52B00ACFEDC /* BlendMode+Filter.swift */; }; A404605B2832C52B00ACFEDC /* BlendMode+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40460582832C52B00ACFEDC /* BlendMode+Filter.swift */; }; + AB3278132A71BA0400A9C9F1 /* View+ValueChanged.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3278122A71BA0400A9C9F1 /* View+ValueChanged.swift */; }; + AB3278142A71BA3500A9C9F1 /* View+ValueChanged.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3278122A71BA0400A9C9F1 /* View+ValueChanged.swift */; }; + AB87F02C2A72F5A80091D7B8 /* View+ValueChanged.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3278122A71BA0400A9C9F1 /* View+ValueChanged.swift */; }; + AB87F02E2A72FA3A0091D7B8 /* Binding+Map.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB87F02D2A72FA3A0091D7B8 /* Binding+Map.swift */; }; + AB87F02F2A72FA3A0091D7B8 /* Binding+Map.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB87F02D2A72FA3A0091D7B8 /* Binding+Map.swift */; }; + AB87F0302A72FA3A0091D7B8 /* Binding+Map.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB87F02D2A72FA3A0091D7B8 /* Binding+Map.swift */; }; D453D8AB28FE6EE300D3F49C /* LottieAnimationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D453D8AA28FE6EE300D3F49C /* LottieAnimationCache.swift */; }; D453D8AC28FE6EE300D3F49C /* LottieAnimationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D453D8AA28FE6EE300D3F49C /* LottieAnimationCache.swift */; }; D453D8AD28FE6EE300D3F49C /* LottieAnimationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D453D8AA28FE6EE300D3F49C /* LottieAnimationCache.swift */; }; @@ -1124,6 +1130,8 @@ 82A552742A2FD44B00E47AC8 /* LottieAnimationLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationLayer.swift; sourceTree = ""; }; A1D5BAAB27C731A500777D06 /* DataURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataURLTests.swift; sourceTree = ""; }; A40460582832C52B00ACFEDC /* BlendMode+Filter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BlendMode+Filter.swift"; sourceTree = ""; }; + AB3278122A71BA0400A9C9F1 /* View+ValueChanged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ValueChanged.swift"; sourceTree = ""; }; + AB87F02D2A72FA3A0091D7B8 /* Binding+Map.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Map.swift"; sourceTree = ""; }; D453D8AA28FE6EE300D3F49C /* LottieAnimationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationCache.swift; sourceTree = ""; }; D453D8AE28FF9BC600D3F49C /* AnimationCacheProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationCacheProviderTests.swift; sourceTree = ""; }; D453D8B028FF9E3A00D3F49C /* DefaultAnimationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAnimationCache.swift; sourceTree = ""; }; @@ -1818,7 +1826,9 @@ 2E9C95D12822F43100677516 /* Helpers */ = { isa = PBXGroup; children = ( + AB3278122A71BA0400A9C9F1 /* View+ValueChanged.swift */, 2E9C95D22822F43100677516 /* AnimationContext.swift */, + AB87F02D2A72FA3A0091D7B8 /* Binding+Map.swift */, ); path = Helpers; sourceTree = ""; @@ -2336,6 +2346,7 @@ 2E9C96BA2822F43100677516 /* KeypathSearchable.swift in Sources */, 2E9C963C2822F43100677516 /* AssetLibrary.swift in Sources */, 2E9C97022822F43100677516 /* PreCompLayer.swift in Sources */, + AB3278132A71BA0400A9C9F1 /* View+ValueChanged.swift in Sources */, 2E9C96EA2822F43100677516 /* SolidLayer.swift in Sources */, 08E207302A56014E002DCE17 /* WillDisplayProviding.swift in Sources */, 08E207482A56014E002DCE17 /* AnimatedProviding.swift in Sources */, @@ -2365,6 +2376,7 @@ 5721091B2910874A00169699 /* RoundedCornersNode.swift in Sources */, 2E9C95FA2822F43100677516 /* Star.swift in Sources */, 2E9C961E2822F43100677516 /* KeyedDecodingContainerExtensions.swift in Sources */, + AB87F02E2A72FA3A0091D7B8 /* Binding+Map.swift in Sources */, 08C001F32A46150D00AB54BA /* Archive+Helpers.swift in Sources */, 08C002022A46150D00AB54BA /* Archive+ReadingDeprecated.swift in Sources */, 2E9C96512822F43100677516 /* PreCompositionLayer.swift in Sources */, @@ -2573,7 +2585,9 @@ 2E9C96B22822F43100677516 /* NodeProperty.swift in Sources */, 08E2071C2A56014E002DCE17 /* EpoxySwiftUIHostingView.swift in Sources */, 2E9C965E2822F43100677516 /* MainThreadAnimationLayer.swift in Sources */, + AB87F02C2A72F5A80091D7B8 /* View+ValueChanged.swift in Sources */, 2E9C964F2822F43100677516 /* SolidCompositionLayer.swift in Sources */, + AB87F02F2A72FA3A0091D7B8 /* Binding+Map.swift in Sources */, 2E9C96402822F43100677516 /* Asset.swift in Sources */, 2E9C96FA2822F43100677516 /* BaseCompositionLayer.swift in Sources */, 2EAF5A9C27A0798700E00531 /* BundleImageProvider.macOS.swift in Sources */, @@ -2903,6 +2917,7 @@ 2E9C97042822F43100677516 /* PreCompLayer.swift in Sources */, 2E9C96EC2822F43100677516 /* SolidLayer.swift in Sources */, 08C002E82A46196300AB54BA /* Data+CompressionDeprecated.swift in Sources */, + AB3278142A71BA3500A9C9F1 /* View+ValueChanged.swift in Sources */, 2EAF5AA327A0798700E00531 /* AnimationSubview.macOS.swift in Sources */, 08E207322A56014E002DCE17 /* WillDisplayProviding.swift in Sources */, 08E2074A2A56014E002DCE17 /* AnimatedProviding.swift in Sources */, @@ -2932,6 +2947,7 @@ 5721091D2910874A00169699 /* RoundedCornersNode.swift in Sources */, 2E9C95FC2822F43100677516 /* Star.swift in Sources */, 2E9C96202822F43100677516 /* KeyedDecodingContainerExtensions.swift in Sources */, + AB87F0302A72FA3A0091D7B8 /* Binding+Map.swift in Sources */, 2E9C96532822F43100677516 /* PreCompositionLayer.swift in Sources */, 2EAF5AF427A0798700E00531 /* AnyValueProvider.swift in Sources */, 2E9C96652822F43100677516 /* CoreTextRenderLayer.swift in Sources */, diff --git a/Sources/Private/Utility/Helpers/Binding+Map.swift b/Sources/Private/Utility/Helpers/Binding+Map.swift new file mode 100644 index 0000000000..6c657bff19 --- /dev/null +++ b/Sources/Private/Utility/Helpers/Binding+Map.swift @@ -0,0 +1,18 @@ +// Created by miguel_jimenez on 7/27/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +import SwiftUI + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, *) +extension Binding { + + /// Helper to transform a `Binding` from one `Value` type to another. + func map(transform: @escaping (Value) -> Transformed) -> Binding { + .init { + transform(wrappedValue) + } set: { newValue in + guard let newValue = newValue as? Value else { return } + self.wrappedValue = newValue + } + } +} diff --git a/Sources/Private/Utility/Helpers/View+ValueChanged.swift b/Sources/Private/Utility/Helpers/View+ValueChanged.swift new file mode 100644 index 0000000000..2341297fae --- /dev/null +++ b/Sources/Private/Utility/Helpers/View+ValueChanged.swift @@ -0,0 +1,20 @@ +// Created by miguel_jimenez on 7/26/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +import Combine +import SwiftUI + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, *) +extension View { + /// A backwards compatible wrapper for iOS 14 `onChange` + @ViewBuilder + func valueChanged(value: T, onChange: @escaping (T) -> Void) -> some View { + if #available(iOS 14.0, *, macOS 11.0, tvOS 14.0) { + self.onChange(of: value, perform: onChange) + } else { + onReceive(Just(value)) { value in + onChange(value) + } + } + } +} diff --git a/Sources/Public/Animation/LottieView.swift b/Sources/Public/Animation/LottieView.swift index eea6028602..1fa3d28806 100644 --- a/Sources/Public/Animation/LottieView.swift +++ b/Sources/Public/Animation/LottieView.swift @@ -1,7 +1,6 @@ // Created by Bryn Bodayle on 1/20/22. // Copyright © 2022 Airbnb Inc. All rights reserved. -import Combine import SwiftUI // MARK: - LottieView @@ -26,12 +25,14 @@ public struct LottieView: UIViewConfiguringSwiftUIView { /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimation`. /// The `loadAnimation` closure is called exactly once in `onAppear`. + /// If you wish to call `loadAnimation` again at a different time, you can use `.loadAnimationTrigger(...)`. 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`. + /// If you wish to call `loadAnimation` again at a different time, you can use `.loadAnimationTrigger(...)`. /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`. public init( _ loadAnimation: @escaping () async throws -> LottieAnimation?, @@ -46,12 +47,14 @@ public struct LottieView: UIViewConfiguringSwiftUIView { /// Creates a `LottieView` that asynchronously loads and displays the given `DotLottieFile`. /// The `loadDotLottieFile` closure is called exactly once in `onAppear`. + /// If you wish to call `loadAnimation` again at a different time, you can use `.loadAnimationTrigger(...)`. 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`. + /// If you wish to call `loadAnimation` again at a different time, you can use `.loadAnimationTrigger(...)`. /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`. public init( _ loadDotLottieFile: @escaping () async throws -> DotLottieFile?, @@ -66,6 +69,7 @@ public struct LottieView: UIViewConfiguringSwiftUIView { /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimationSource`. /// The `loadAnimation` closure is called exactly once in `onAppear`. + /// If you wish to call `loadAnimation` again at a different time, you can use `.loadAnimationTrigger(...)`. /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`. public init( loadAnimation: @escaping () async throws -> LottieAnimationSource?, @@ -106,6 +110,10 @@ public struct LottieView: UIViewConfiguringSwiftUIView { .onAppear { loadAnimationIfNecessary() } + .valueChanged(value: loadAnimationTrigger?.wrappedValue) { _ in + animationSource = nil + loadAnimationIfNecessary() + } } /// Returns a copy of this `LottieView` updated to have the given closure applied to its @@ -287,6 +295,19 @@ public struct LottieView: UIViewConfiguringSwiftUIView { } } + /// Returns a new instance of this view, which will invoke the provided `loadAnimation` closure + /// whenever the `binding` value is updated. + /// + /// - Note: This function requires a valid `loadAnimation` closure provided during view initialization, + /// otherwise the `loadAnimationTrigger` will have no effect. + /// - Note: The existing animation will be removed before calling `loadAnimation`, + /// which will cause the `Placeholder` to be displayed until the new animation finishes loading. + public func loadAnimationTrigger(_ binding: Binding) -> Self { + var copy = self + copy.loadAnimationTrigger = binding.map(transform: AnyHashable.init) + return copy + } + /// Returns a view that updates the given binding each frame with the animation's `realtimeAnimationProgress`. /// The `LottieView` is wrapped in a `TimelineView` with the `.animation` schedule. /// - This is a one-way binding. Its value is updated but never read. @@ -334,6 +355,7 @@ public struct LottieView: UIViewConfiguringSwiftUIView { // MARK: Private @State private var animationSource: LottieAnimationSource? + private var loadAnimationTrigger: Binding? private var loadAnimation: (() async throws -> LottieAnimationSource?)? private var imageProvider: AnimationImageProvider? private var textProvider: AnimationTextProvider = DefaultTextProvider()