// // CachedAsyncGiffy.swift // Pocket Poster // // Created by lemin on 7/18/25. // // // Created by Lorenzo Fiamingo on 04/11/20. // import SwiftUI import Giffy /// A view that asynchronously loads, cache and displays an image. /// /// This view uses a custom default /// /// instance to load an image from the specified URL, and then display it. /// For example, you can display an icon that's stored on a server: /// /// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) /// .frame(width: 200, height: 200) /// /// Until the image loads, the view displays a standard placeholder that /// fills the available space. After the load completes successfully, the view /// updates to display the image. In the example above, the icon is smaller /// than the frame, and so appears smaller than the placeholder. /// /// ![A diagram that shows a grey box on the left, the SwiftUI icon on the /// right, and an arrow pointing from the first to the second. The icon /// is about half the size of the grey box.](AsyncImage-1) /// /// You can specify a custom placeholder using /// ``init(url:urlCache:scale:content:placeholder:)``. With this initializer, you can /// also use the `content` parameter to manipulate the loaded image. /// For example, you can add a modifier to make the loaded image resizable: /// /// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { image in /// image.resizable() /// } placeholder: { /// ProgressView() /// } /// .frame(width: 50, height: 50) /// /// For this example, SwiftUI shows a ``ProgressView`` first, and then the /// image scaled to fit in the specified frame: /// /// ![A diagram that shows a progress view on the left, the SwiftUI icon on the /// right, and an arrow pointing from the first to the second.](AsyncImage-2) /// /// > Important: You can't apply image-specific modifiers, like /// ``Image/resizable(capInsets:resizingMode:)``, directly to a `CachedAsyncImage`. /// Instead, apply them to the ``Image`` instance that your `content` /// closure gets when defining the view's appearance. /// /// To gain more control over the loading process, use the /// ``init(url:urlCache:scale:transaction:content:)`` initializer, which takes a /// `content` closure that receives an ``AsyncImagePhase`` to indicate /// the state of the loading operation. Return a view that's appropriate /// for the current phase: /// /// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { phase in /// if let image = phase.image { /// image // Displays the loaded image. /// } else if phase.error != nil { /// Color.red // Indicates an error. /// } else { /// Color.blue // Acts as a placeholder. /// } /// } /// @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct CachedAsyncGiffy: View where Content: View { @State private var phase: AsyncGiffyPhase private let urlRequest: URLRequest? private let urlSession: URLSession private let scale: CGFloat private let transaction: Transaction private let content: (AsyncGiffyPhase) -> Content public var body: some View { content(phase) .task(id: urlRequest, load) } /// Loads and displays an image from the specified URL. /// /// Until the image loads, SwiftUI displays a default placeholder. When /// the load operation completes successfully, SwiftUI updates the /// view to show the loaded image. If the operation fails, SwiftUI /// continues to display the placeholder. The following example loads /// and displays an icon from an example server: /// /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) /// /// If you want to customize the placeholder or apply image-specific /// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` --- /// to the loaded image, use the ``init(url:scale:content:placeholder:)`` /// initializer instead. /// /// - Parameters: /// - url: The URL of the image to display. /// - urlCache: The URL cache for providing cached responses to requests within the session. /// - scale: The scale to use for the image. The default is `1`. Set a /// different value when loading images designed for higher resolution /// displays. For example, set a value of `2` for an image that you /// would name with the `@2x` suffix if stored in a file on disk. public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy { let urlRequest = url == nil ? nil : URLRequest(url: url!) self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) } /// Loads and displays an image from the specified URL. /// /// Until the image loads, SwiftUI displays a default placeholder. When /// the load operation completes successfully, SwiftUI updates the /// view to show the loaded image. If the operation fails, SwiftUI /// continues to display the placeholder. The following example loads /// and displays an icon from an example server: /// /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) /// /// If you want to customize the placeholder or apply image-specific /// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` --- /// to the loaded image, use the ``init(url:scale:content:placeholder:)`` /// initializer instead. /// /// - Parameters: /// - urlRequest: The URL request of the image to display. /// - urlCache: The URL cache for providing cached responses to requests within the session. /// - scale: The scale to use for the image. The default is `1`. Set a /// different value when loading images designed for higher resolution /// displays. For example, set a value of `2` for an image that you /// would name with the `@2x` suffix if stored in a file on disk. public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy { self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in #if os(macOS) phase.image ?? Image(nsImage: .init()) #else switch phase { case .success(let giffy): return giffy default: return Giffy("") } #endif } } /// Loads and displays a modifiable image from the specified URL using /// a custom placeholder until the image loads. /// /// Until the image loads, SwiftUI displays the placeholder view that /// you specify. When the load operation completes successfully, SwiftUI /// updates the view to show content that you specify, which you /// create using the loaded image. For example, you can show a green /// placeholder, followed by a tiled version of the loaded image: /// /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in /// image.resizable(resizingMode: .tile) /// } placeholder: { /// Color.green /// } /// /// If the load operation fails, SwiftUI continues to display the /// placeholder. To be able to display a different view on a load error, /// use the ``init(url:scale:transaction:content:)`` initializer instead. /// /// - Parameters: /// - url: The URL of the image to display. /// - urlCache: The URL cache for providing cached responses to requests within the session. /// - scale: The scale to use for the image. The default is `1`. Set a /// different value when loading images designed for higher resolution /// displays. For example, set a value of `2` for an image that you /// would name with the `@2x` suffix if stored in a file on disk. /// - content: A closure that takes the loaded image as an input, and /// returns the view to show. You can return the image directly, or /// modify it as needed before returning it. /// - placeholder: A closure that returns the view to show until the /// load operation completes successfully. public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { let urlRequest = url == nil ? nil : URLRequest(url: url!) self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, content: content, placeholder: placeholder) } /// Loads and displays a modifiable image from the specified URL using /// a custom placeholder until the image loads. /// /// Until the image loads, SwiftUI displays the placeholder view that /// you specify. When the load operation completes successfully, SwiftUI /// updates the view to show content that you specify, which you /// create using the loaded image. For example, you can show a green /// placeholder, followed by a tiled version of the loaded image: /// /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in /// image.resizable(resizingMode: .tile) /// } placeholder: { /// Color.green /// } /// /// If the load operation fails, SwiftUI continues to display the /// placeholder. To be able to display a different view on a load error, /// use the ``init(url:scale:transaction:content:)`` initializer instead. /// /// - Parameters: /// - urlRequest: The URL request of the image to display. /// - urlCache: The URL cache for providing cached responses to requests within the session. /// - scale: The scale to use for the image. The default is `1`. Set a /// different value when loading images designed for higher resolution /// displays. For example, set a value of `2` for an image that you /// would name with the `@2x` suffix if stored in a file on disk. /// - content: A closure that takes the loaded image as an input, and /// returns the view to show. You can return the image directly, or /// modify it as needed before returning it. /// - placeholder: A closure that returns the view to show until the /// load operation completes successfully. public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in switch phase { case .success(let image): content(image) default: placeholder() } } } /// Loads and displays a modifiable image from the specified URL in phases. /// /// If you set the asynchronous image's URL to `nil`, or after you set the /// URL to a value but before the load operation completes, the phase is /// ``AsyncImagePhase/empty``. After the operation completes, the phase /// becomes either ``AsyncImagePhase/failure(_:)`` or /// ``AsyncImagePhase/success(_:)``. In the first case, the phase's /// ``AsyncImagePhase/error`` value indicates the reason for failure. /// In the second case, the phase's ``AsyncImagePhase/image`` property /// contains the loaded image. Use the phase to drive the output of the /// `content` closure, which defines the view's appearance: /// /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in /// if let image = phase.image { /// image // Displays the loaded image. /// } else if phase.error != nil { /// Color.red // Indicates an error. /// } else { /// Color.blue // Acts as a placeholder. /// } /// } /// /// To add transitions when you change the URL, apply an identifier to the /// ``CachedAsyncImage``. /// /// - Parameters: /// - url: The URL of the image to display. /// - urlCache: The URL cache for providing cached responses to requests within the session. /// - scale: The scale to use for the image. The default is `1`. Set a /// different value when loading images designed for higher resolution /// displays. For example, set a value of `2` for an image that you /// would name with the `@2x` suffix if stored in a file on disk. /// - transaction: The transaction to use when the phase changes. /// - content: A closure that takes the load phase as an input, and /// returns the view to display for the specified phase. public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) { let urlRequest = url == nil ? nil : URLRequest(url: url!) self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, transaction: transaction, content: content) } /// Loads and displays a modifiable image from the specified URL in phases. /// /// If you set the asynchronous image's URL to `nil`, or after you set the /// URL to a value but before the load operation completes, the phase is /// ``AsyncImagePhase/empty``. After the operation completes, the phase /// becomes either ``AsyncImagePhase/failure(_:)`` or /// ``AsyncImagePhase/success(_:)``. In the first case, the phase's /// ``AsyncImagePhase/error`` value indicates the reason for failure. /// In the second case, the phase's ``AsyncImagePhase/image`` property /// contains the loaded image. Use the phase to drive the output of the /// `content` closure, which defines the view's appearance: /// /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in /// if let image = phase.image { /// image // Displays the loaded image. /// } else if phase.error != nil { /// Color.red // Indicates an error. /// } else { /// Color.blue // Acts as a placeholder. /// } /// } /// /// To add transitions when you change the URL, apply an identifier to the /// ``CachedAsyncImage``. /// /// - Parameters: /// - urlRequest: The URL request of the image to display. /// - urlCache: The URL cache for providing cached responses to requests within the session. /// - scale: The scale to use for the image. The default is `1`. Set a /// different value when loading images designed for higher resolution /// displays. For example, set a value of `2` for an image that you /// would name with the `@2x` suffix if stored in a file on disk. /// - transaction: The transaction to use when the phase changes. /// - content: A closure that takes the load phase as an input, and /// returns the view to display for the specified phase. public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) { let configuration = URLSessionConfiguration.default configuration.urlCache = urlCache self.urlRequest = urlRequest self.urlSession = URLSession(configuration: configuration) self.scale = scale self.transaction = transaction self.content = content self._phase = State(wrappedValue: .loading) do { if let urlRequest = urlRequest, let image = try cachedGif(from: urlRequest, cache: urlCache) { self._phase = State(wrappedValue: .success(image)) } } catch { self._phase = State(wrappedValue: .error) } } @Sendable private func load() async { do { if let urlRequest = urlRequest { let (image, metrics) = try await remoteGif(from: urlRequest, session: urlSession) if metrics.transactionMetrics.last?.resourceFetchType == .localCache { // WARNING: This does not behave well when the url is changed with another phase = .success(image) } else { withAnimation(transaction.animation) { phase = .success(image) } } } else { withAnimation(transaction.animation) { phase = .loading } } } catch { withAnimation(transaction.animation) { phase = .error } } } } // MARK: - Helpers @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private extension CachedAsyncGiffy { private func remoteGif(from request: URLRequest, session: URLSession) async throws -> (Giffy, URLSessionTaskMetrics) { let (data, _, metrics) = try await session.data(for: request) if metrics.redirectCount > 0, let lastResponse = metrics.transactionMetrics.last?.response { let requests = metrics.transactionMetrics.map { $0.request } requests.forEach(session.configuration.urlCache!.removeCachedResponse) let lastCachedResponse = CachedURLResponse(response: lastResponse, data: data) session.configuration.urlCache!.storeCachedResponse(lastCachedResponse, for: request) } return (try gif(from: data), metrics) } private func cachedGif(from request: URLRequest, cache: URLCache) throws -> Giffy? { guard let cachedResponse = cache.cachedResponse(for: request) else { return nil } return try gif(from: cachedResponse.data) } private func gif(from data: Data) throws -> Giffy { #if os(macOS) if let nsImage = NSImage(data: data) { return Giffy(nsImage: nsImage) } else { throw AsyncGiffy.LoadingError() } #else return Giffy(imageData: data) #endif } } // MARK: - AsyncImageURLSession private class URLSessionTaskController: NSObject, URLSessionTaskDelegate { var metrics: URLSessionTaskMetrics? func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { self.metrics = metrics } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private extension URLSession { func data(for request: URLRequest) async throws -> (Data, URLResponse, URLSessionTaskMetrics) { let controller = URLSessionTaskController() let (data, response) = try await data(for: request, delegate: controller) return (data, response, controller.metrics!) } }