diff --git a/Pocket Poster/Extensions/CachedAsyncGiffy.swift b/Pocket Poster/Extensions/CachedAsyncGiffy.swift new file mode 100644 index 0000000..85ceb48 --- /dev/null +++ b/Pocket Poster/Extensions/CachedAsyncGiffy.swift @@ -0,0 +1,408 @@ +// +// 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!) + } +} diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index 04f879f..10ddd3d 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -57,22 +57,33 @@ struct ExploreView: View { }) { VStack(spacing: 0) { if wallpaper.previewIsGif() { - AsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper)) { phase in - switch phase { - case .loading: - Color.gray - .frame(height: MIN_SIZE) - case .error: - Color.red - .frame(height: MIN_SIZE) - case .success(let giffy): - giffy - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) - .cornerRadius(10, corners: .topLeft) - .cornerRadius(10, corners: .topRight) - } + CachedAsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { giffy in + giffy + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .cornerRadius(10, corners: .topLeft) + .cornerRadius(10, corners: .topRight) + } placeholder: { + Color.gray + .frame(height: MIN_SIZE) } + // Non-cached code: +// AsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper)) { phase in +// switch phase { +// case .loading: +// Color.gray +// .frame(height: MIN_SIZE) +// case .error: +// Color.red +// .frame(height: MIN_SIZE) +// case .success(let giffy): +// giffy +// .aspectRatio(contentMode: .fill) +// .frame(maxWidth: .infinity) +// .cornerRadius(10, corners: .topLeft) +// .cornerRadius(10, corners: .topRight) +// } +// } } else { CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in image