// Created by Bryn Bodayle on 1/20/22. // Copyright © 2022 Airbnb Inc. All rights reserved. #if canImport(SwiftUI) import SwiftUI // MARK: - LottieView /// A wrapper which exposes Lottie's `LottieAnimationView` to SwiftUI public struct LottieView: UIViewConfiguringSwiftUIView { // MARK: Lifecycle /// Creates a `LottieView` that displays the given animation public init(animation: LottieAnimation?) where Placeholder == EmptyView { localAnimation = animation.map(LottieAnimationSource.lottieAnimation) placeholder = nil } /// Initializes a `LottieView` with the provided `DotLottieFile` for display. /// /// - Important: Avoid using this initializer with the `SynchronouslyBlockingCurrentThread` APIs. /// If decompression of a `.lottie` file is necessary, prefer using the `.init(_ loadAnimation:)` /// initializer, which takes an asynchronous closure: /// ``` /// LottieView { /// try await DotLottieFile.named(name) /// } /// ``` public init(dotLottieFile: DotLottieFile?) where Placeholder == EmptyView { localAnimation = 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`. /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`. 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 `.reloadAnimationTrigger(...)`. /// 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`. /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`. /// You can use the `DotLottieFile` static methods API which use Swift concurrency to load your `.lottie` files: /// ``` /// LottieView { /// try await DotLottieFile.named(name) /// } /// ``` 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 `.reloadAnimationTrigger(...)`. /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`. /// You can use the `DotLottieFile` static methods API which use Swift concurrency to load your `.lottie` files: /// ``` /// LottieView { /// try await DotLottieFile.named(name) /// } placeholder: { /// LoadingView() /// } /// ``` 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`. /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`. /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`. public init(_ loadAnimation: @escaping () async throws -> LottieAnimationSource?) where Placeholder == EmptyView { self.init(loadAnimation, placeholder: EmptyView.init) } /// 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 `.reloadAnimationTrigger(...)`. /// 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) { localAnimation = nil self.loadAnimation = loadAnimation self.placeholder = placeholder } // MARK: Public public var body: some View { LottieAnimationView.swiftUIView { LottieAnimationView( animationSource: animationSource, imageProvider: imageProviderConfiguration?.imageProvider, textProvider: textProvider, fontProvider: fontProvider, configuration: configuration, logger: logger) } .sizing(sizing) .configure { context in applyCurrentAnimationConfiguration(to: context.view, in: context.container) } .configurations(configurations) .opacity(animationSource == nil ? 0 : 1) .overlay { placeholder?() .opacity(animationSource == nil ? 1 : 0) } .onAppear { loadAnimationIfNecessary() } .valueChanged(value: reloadAnimationTrigger) { _ in reloadAnimationTriggerDidChange() } } /// Returns a copy of this `LottieView` updated to have the specific configuration property /// applied to its represented `LottieAnimationView` whenever it is updated via the `updateUIView(...)` /// or `updateNSView(...)` methods. /// /// - note: This configuration will be applied on every SwiftUI render pass. /// Be wary of applying heavy side-effects as configuration values. public func configure( _ property: ReferenceWritableKeyPath, to value: Property) -> Self { configure { $0[keyPath: property] = value } } /// Returns a copy of this `LottieView` updated to have the specific configuration property /// applied to its represented `LottieAnimationView` whenever it is updated via the `updateUIView(...)` /// or `updateNSView(...)` methods. /// /// - note: If the `value` is already the currently-applied configuration value, it won't be applied public func configure( _ property: ReferenceWritableKeyPath, to value: Property) -> Self { configure { guard $0[keyPath: property] != value else { return } $0[keyPath: property] = value } } /// Returns a copy of this `LottieView` updated to have the given closure applied to its /// represented `LottieAnimationView` whenever it is updated via the `updateUIView(…)` /// or `updateNSView(…)` method. /// /// - note: This configuration closure will be executed on every SwiftUI render pass. /// Be wary of applying heavy side-effects inside it. public func configure(_ configure: @escaping (LottieAnimationView) -> Void) -> Self { var copy = self copy.configurations.append { context in configure(context.view) } return copy } /// Returns a copy of this view that can be resized by scaling its animation /// to always fit the size offered by its parent. public func resizable() -> Self { var copy = self copy.sizing = .proposed return copy } /// Returns a copy of this view that adopts the intrinsic size of the animation, /// up to the proposed size. public func intrinsicSize() -> Self { var copy = self copy.sizing = .intrinsic return copy } @available(*, deprecated, renamed: "playing()", message: "Will be removed in a future major release.") public func play() -> Self { playbackMode(.playing(.fromProgress(nil, toProgress: 1, loopMode: .playOnce))) } /// Returns a copy of this view that loops its animation from the start to end whenever visible public func looping() -> Self { playbackMode(.playing(.fromProgress(0, toProgress: 1, loopMode: .loop))) } @available(*, deprecated, renamed: "playing(_:)", message: "Will be removed in a future major release.") public func play(loopMode: LottieLoopMode = .playOnce) -> Self { playbackMode(.playing(.fromProgress(nil, toProgress: 1, loopMode: loopMode))) } @available(*, deprecated, renamed: "playbackMode(_:)", message: "Will be removed in a future major release.") public func play(_ playbackMode: LottiePlaybackMode) -> Self { self.playbackMode(playbackMode) } /// Returns a copy of this view playing with the given playback mode public func playing(_ mode: LottiePlaybackMode.PlaybackMode) -> Self { playbackMode(.playing(mode)) } /// Returns a copy of this view playing from the current frame to the end frame, /// with the given `LottiePlaybackMode`. public func playing(loopMode: LottieLoopMode) -> Self { playbackMode(.playing(.fromProgress(nil, toProgress: 1, loopMode: loopMode))) } /// Returns a copy of this view playing once from the current frame to the end frame public func playing() -> Self { playbackMode(.playing(.fromProgress(nil, toProgress: 1, loopMode: .playOnce))) } /// Returns a copy of this view paused with the given state public func paused(at state: LottiePlaybackMode.PausedState = .currentFrame) -> Self { playbackMode(.paused(at: state)) } /// Returns a copy of this view using the given `LottiePlaybackMode` public func playbackMode(_ playbackMode: LottiePlaybackMode) -> Self { var copy = self copy.playbackMode = playbackMode return copy } /// Returns a copy of this view playing its animation at the given speed public func animationSpeed(_ animationSpeed: Double) -> Self { var copy = self copy.animationSpeed = animationSpeed return copy } /// Returns a copy of this view with the given closure that is called whenever the /// `LottieAnimationSource` provided via `init` is loaded and applied to the underlying `LottieAnimationView`. public func animationDidLoad(_ animationDidLoad: @escaping (LottieAnimationSource) -> Void) -> Self { var copy = self copy.animationDidLoad = animationDidLoad return copy } /// Returns a copy of this view with the given `LottieCompletionBlock` that is called /// when an animation finishes playing. public func animationDidFinish(_ animationCompletionHandler: LottieCompletionBlock?) -> Self { var copy = self copy.animationCompletionHandler = { [previousCompletionHandler = self.animationCompletionHandler] completed in previousCompletionHandler?(completed) animationCompletionHandler?(completed) } return copy } /// Returns a copy of this view updated to have the provided background behavior. public func backgroundBehavior(_ value: LottieBackgroundBehavior) -> Self { configure { view in view.backgroundBehavior = value } } /// Returns a copy of this view with its accessibility label updated to the given value. public func accessibilityLabel(_ accessibilityLabel: String?) -> Self { configure { view in #if os(macOS) view.setAccessibilityElement(accessibilityLabel != nil) view.setAccessibilityLabel(accessibilityLabel) #else view.isAccessibilityElement = accessibilityLabel != nil view.accessibilityLabel = accessibilityLabel #endif } } /// 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 copy = copy.configure { view in if view.configuration != configuration { view.configuration = configuration } } 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 { var copy = self copy.imageProviderConfiguration = ( imageProvider: imageProvider, imageProvidersAreEqual: { untypedLHS, untypedRHS in guard let lhs = untypedLHS as? ImageProvider, let rhs = untypedRHS as? ImageProvider else { return false } return lhs == rhs }) return copy } /// Returns a copy of this view with its text provider updated to the given value. /// The image provider must be `Equatable` to avoid unnecessary state updates / re-renders. public func textProvider(_ textProvider: TextProvider) -> Self { var copy = self copy.textProvider = textProvider copy = copy.configure { view in if (view.textProvider as? TextProvider) != textProvider { view.textProvider = textProvider } } 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 fontProvider(_ fontProvider: FontProvider) -> Self { var copy = self copy.fontProvider = fontProvider copy = configure { view in if (view.fontProvider as? FontProvider) != fontProvider { view.fontProvider = fontProvider } } return copy } /// Returns a copy of this view using the given value provider for the given keypath. /// The value provider must be `Equatable` to avoid unnecessary state updates / re-renders. public func valueProvider( _ valueProvider: ValueProvider, for keypath: AnimationKeypath) -> Self { configure { view in if (view.valueProviders[keypath] as? ValueProvider) != valueProvider { view.setValueProvider(valueProvider, keypath: keypath) } } } /// Returns a copy of this view updated to display the given `AnimationProgressTime`. /// - If the `currentProgress` value is provided, the `currentProgress` of the /// underlying `LottieAnimationView` is updated. This will pause any existing animations. /// - If the `animationProgress` is `nil`, no changes will be made and any existing animations /// will continue playing uninterrupted. public func currentProgress(_ currentProgress: AnimationProgressTime?) -> Self { guard let currentProgress else { return self } var copy = self copy.playbackMode = .paused(at: .progress(currentProgress)) return copy } /// Returns a copy of this view updated to display the given `AnimationFrameTime`. /// - If the `currentFrame` value is provided, the `currentFrame` of the /// underlying `LottieAnimationView` is updated. This will pause any existing animations. /// - If the `currentFrame` is `nil`, no changes will be made and any existing animations /// will continue playing uninterrupted. public func currentFrame(_ currentFrame: AnimationFrameTime?) -> Self { guard let currentFrame else { return self } var copy = self copy.playbackMode = .paused(at: .frame(currentFrame)) return copy } /// Returns a copy of this view updated to display the given time value. /// - If the `currentTime` value is provided, the `currentTime` of the /// underlying `LottieAnimationView` is updated. This will pause any existing animations. /// - If the `currentTime` is `nil`, no changes will be made and any existing animations /// will continue playing uninterrupted. public func currentTime(_ currentTime: TimeInterval?) -> Self { guard let currentTime else { return self } var copy = self copy.playbackMode = .paused(at: .time(currentTime)) return copy } /// 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 `reloadAnimationTrigger` will have no effect. /// - Parameters: /// - binding: The binding that triggers the reloading when its value changes. /// - showPlaceholder: When `true`, the current animation will be removed before invoking `loadAnimation`, /// displaying the `Placeholder` until the new animation loads. /// When `false`, the previous animation remains visible while the new one loads. public func reloadAnimationTrigger(_ value: some Equatable, showPlaceholder: Bool = true) -> Self { var copy = self copy.reloadAnimationTrigger = AnyEquatable(value) copy.showPlaceholderWhileReloading = showPlaceholder 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. /// - If provided, the binding will be updated each frame with the `realtimeAnimationProgress` /// of the underlying `LottieAnimationView`. This is potentially expensive since it triggers /// a state update every frame. /// - If the binding is `nil`, the `TimelineView` will be paused and no updates will occur to the binding. @available(iOS 15.0, tvOS 15.0, macOS 12.0, *) public func getRealtimeAnimationProgress(_ realtimeAnimationProgress: Binding?) -> some View { TimelineView(.animation(paused: realtimeAnimationProgress == nil)) { _ in configure { view in if let realtimeAnimationProgress { DispatchQueue.main.async { realtimeAnimationProgress.wrappedValue = view.realtimeAnimationProgress } } } } } /// 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. /// - If provided, the binding will be updated each frame with the `realtimeAnimationProgress` /// of the underlying `LottieAnimationView`. This is potentially expensive since it triggers /// a state update every frame. /// - If the binding is `nil`, the `TimelineView` will be paused and no updates will occur to the binding. @available(iOS 15.0, tvOS 15.0, macOS 12.0, *) public func getRealtimeAnimationFrame(_ realtimeAnimationFrame: Binding?) -> some View { TimelineView(.animation(paused: realtimeAnimationFrame == nil)) { _ in configure { view in if let realtimeAnimationFrame { DispatchQueue.main.async { realtimeAnimationFrame.wrappedValue = view.realtimeAnimationFrame } } } } } /// Returns a copy of this view with the `DotLottieConfigurationComponents` /// updated to the given value. /// - Defaults to `[.imageProvider]` /// - If a component is specified here, that value in the `DotLottieConfiguration` /// of an active dotLottie animation will override any value provided via other methods. public func dotLottieConfigurationComponents( _ dotLottieConfigurationComponents: DotLottieConfigurationComponents) -> Self { var copy = self copy.dotLottieConfigurationComponents = dotLottieConfigurationComponents return copy } // MARK: Internal var configurations = [SwiftUIView.Configuration]() // MARK: Private private let localAnimation: LottieAnimationSource? @State private var remoteAnimation: LottieAnimationSource? private var playbackMode: LottiePlaybackMode? private var animationSpeed: Double? private var reloadAnimationTrigger: AnyEquatable? private var loadAnimation: (() async throws -> LottieAnimationSource?)? private var animationDidLoad: ((LottieAnimationSource) -> Void)? private var animationCompletionHandler: LottieCompletionBlock? private var showPlaceholderWhileReloading = false private var textProvider: AnimationKeypathTextProvider = DefaultTextProvider() private var fontProvider: AnimationFontProvider = DefaultFontProvider() private var configuration: LottieConfiguration = .shared private var dotLottieConfigurationComponents: DotLottieConfigurationComponents = .imageProvider private var logger: LottieLogger = .shared private var sizing = SwiftUIMeasurementContainerStrategy.automatic private let placeholder: (() -> Placeholder)? private var imageProviderConfiguration: ( imageProvider: AnimationImageProvider, imageProvidersAreEqual: (AnimationImageProvider, AnimationImageProvider) -> Bool)? private var animationSource: LottieAnimationSource? { localAnimation ?? remoteAnimation } private func loadAnimationIfNecessary() { guard let loadAnimation else { return } Task { do { remoteAnimation = try await loadAnimation() } catch { logger.warn("Failed to load asynchronous Lottie animation with error: \(error)") } } } private func reloadAnimationTriggerDidChange() { guard loadAnimation != nil else { return } if showPlaceholderWhileReloading { remoteAnimation = nil } loadAnimationIfNecessary() } /// Applies playback configuration for the current animation to the `LottieAnimationView` private func applyCurrentAnimationConfiguration( to view: LottieAnimationView, in container: SwiftUIMeasurementContainer) { guard let animationSource else { return } var imageProviderConfiguration = imageProviderConfiguration var playbackMode = playbackMode var animationSpeed = animationSpeed // When playing a dotLottie animation, its `DotLottieConfiguration` // can override some behavior of the animation. if let dotLottieConfiguration = animationSource.dotLottieAnimation?.configuration { // Only use the value from the `DotLottieConfiguration` is that component // is specified in the list of `dotLottieConfigurationComponents`. if dotLottieConfigurationComponents.contains(.loopMode) { playbackMode = playbackMode?.loopMode(dotLottieConfiguration.loopMode) } if dotLottieConfigurationComponents.contains(.animationSpeed) { animationSpeed = dotLottieConfiguration.speed } if dotLottieConfigurationComponents.contains(.imageProvider), let dotLottieImageProvider = dotLottieConfiguration.dotLottieImageProvider { imageProviderConfiguration = ( imageProvider: dotLottieImageProvider, imageProvidersAreEqual: { untypedLHS, untypedRHS in guard let lhs = untypedLHS as? DotLottieImageProvider, let rhs = untypedRHS as? DotLottieImageProvider else { return false } return lhs == rhs }) } } // 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 !== view.animation { view.loadAnimation(animationSource) animationDidLoad?(animationSource) // Invalidate the intrinsic size of the SwiftUI measurement container, // since any cached measurements will be out of date after updating the animation. container.invalidateIntrinsicContentSize() } if let playbackMode, playbackMode != view.currentPlaybackMode { view.setPlaybackMode(playbackMode, completion: animationCompletionHandler) } if let (imageProvider, imageProvidersAreEqual) = imageProviderConfiguration, !imageProvidersAreEqual(imageProvider, view.imageProvider) { view.imageProvider = imageProvider } if let animationSpeed, animationSpeed != view.animationSpeed { view.animationSpeed = animationSpeed } } } extension View { /// The `.overlay` modifier that uses a `ViewBuilder` is available in iOS 15+, this helper function helps us to use the same API in older OSs fileprivate func overlay( @ViewBuilder content: () -> some View) -> some View { overlay(content(), alignment: .center) } } #endif