diff --git a/apple/OmnivoreKit/Sources/Views/AsyncImage.swift b/apple/OmnivoreKit/Sources/Views/AsyncImage.swift index 8d808792a..454263ecf 100644 --- a/apple/OmnivoreKit/Sources/Views/AsyncImage.swift +++ b/apple/OmnivoreKit/Sources/Views/AsyncImage.swift @@ -4,114 +4,76 @@ import Models import SwiftUI import Utils -struct AsyncImage: View { - let isResizable: Bool - let url: URL? - @State private var isLoaded = false +enum AsyncImageStatus { + case loading + case loaded(image: Image) + case error +} + +struct AsyncImage: View { + let viewBuilder: (AsyncImageStatus) -> Content + let url: URL @StateObject private var imageLoader = ImageLoader() - init(url: URL?, isResizable: Bool = true) { - self.isResizable = isResizable + init(url: URL, @ViewBuilder viewBuilder: @escaping (AsyncImageStatus) -> Content) { self.url = url - } - - func load() { - if let url = url, !isLoaded { - imageLoader.load(fromUrl: url) - isLoaded = true - } + self.viewBuilder = viewBuilder } var body: some View { - Group { - #if os(iOS) - if isResizable { - Image(uiImage: imageLoader.image ?? imageLoader.placeholder) - .resizable() - } else { - Image(uiImage: imageLoader.image ?? imageLoader.placeholder) - } - #elseif os(macOS) - if isResizable { - Image(nsImage: imageLoader.image ?? imageLoader.placeholder) - .resizable() - } else { - Image(nsImage: imageLoader.image ?? imageLoader.placeholder) - } - #endif - } - .onAppear { - load() - } + viewBuilder(imageLoader.status) + .onAppear { + imageLoader.load(fromUrl: url) + } } } -#if os(iOS) - private final class ImageLoader: ObservableObject { - @Published var image: UIImage? - @Published var hasFailed = false +private final class ImageLoader: ObservableObject { + @Published var status: AsyncImageStatus = .loading + var loadStarted = false - let placeholder = UIImage() + private var subscriptions = Set() - private var subscriptions = Set() + func load(fromUrl url: URL) { + guard !loadStarted else { return } + loadStarted = true - func load(fromUrl url: URL) { - if let cachedImage = ImageCache.shared[url] { - image = cachedImage - return - } - - fetch(url: url).sink( - receiveCompletion: { [weak self] completion in - guard case .failure = completion else { return } - self?.hasFailed = true - }, receiveValue: { [weak self] data in - guard let fetchedImage = UIImage(data: data) else { - self?.hasFailed = true - return - } - ImageCache.shared[url] = fetchedImage - self?.image = fetchedImage - } - ) - .store(in: &subscriptions) + if let cachedImage = ImageCache.shared[url] { + #if os(iOS) + status = .loaded(image: Image(uiImage: cachedImage)) + #else + status = .loaded(image: Image(nsImage: cachedImage)) + #endif + return } - } -#elseif os(macOS) - private final class ImageLoader: ObservableObject { - @Published var image: NSImage? - @Published var hasFailed = false - - let placeholder = NSImage(systemSymbolName: "photo", accessibilityDescription: "photo-placeholder")! - - private var subscriptions = Set() - - func load(fromUrl url: URL) { - if let cachedImage = ImageCache.shared[url] { - image = cachedImage - return - } - - fetch(url: url).sink( - receiveCompletion: { [weak self] completion in - guard case .failure = completion else { return } - self?.hasFailed = true - }, receiveValue: { [weak self] data in - guard let fetchedImage = NSImage(data: data) else { - self?.hasFailed = true - return - } - ImageCache.shared[url] = fetchedImage - self?.image = fetchedImage + fetch(url: url).sink( + receiveCompletion: { [weak self] completion in + guard case .failure = completion else { return } + self?.status = .error + }, receiveValue: { [weak self] data in + #if os(iOS) + let fetchedImage = UIImage(data: data) + #else + let fetchedImage = NSImage(data: data)#endif + guard let fetchedImage = fetchedImage else { + self?.status = .error + return } - ) - .store(in: &subscriptions) - } - } -#endif + ImageCache.shared[url] = fetchedImage -func fetch(url: URL) -> AnyPublisher { + #if os(iOS) + self?.status = .loaded(image: Image(uiImage: fetchedImage)) + #else + self?.status = .loaded(image: Image(nsImage: fetchedImage)) + #endif + } + ) + .store(in: &subscriptions) + } +} + +private func fetch(url: URL) -> AnyPublisher { let request = URLRequest(url: url) return URLSession.DataTaskPublisher(request: request, session: .shared) diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index f42b0bfbe..30053fa59 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -133,10 +133,21 @@ public struct GridCard: View { Spacer() if let imageURL = item.imageURL { - AsyncImage(url: imageURL, isResizable: true) - .aspectRatio(contentMode: .fill) - .frame(width: geo.size.width / 3, height: (geo.size.width * 2) / 9) - .cornerRadius(3) + AsyncImage(url: imageURL) { imageStatus in + if case let AsyncImageStatus.loaded(image) = imageStatus { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geo.size.width / 3, height: (geo.size.width * 2) / 9) + .cornerRadius(3) + } else if case AsyncImageStatus.loading = imageStatus { + Color.appButtonBackground + .frame(width: geo.size.width / 3, height: (geo.size.width * 2) / 9) + .cornerRadius(3) + } else { + EmptyView() + } + } } } .padding(.horizontal) diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index 5f917ae30..5ae355eea 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -39,10 +39,21 @@ public struct FeedCard: View { Group { if let imageURL = item.imageURL { - AsyncImage(url: imageURL, isResizable: true) - .aspectRatio(1, contentMode: .fill) - .frame(width: 80, height: 80) - .cornerRadius(6) + AsyncImage(url: imageURL) { imageStatus in + if case let AsyncImageStatus.loaded(image) = imageStatus { + image + .resizable() + .aspectRatio(1, contentMode: .fill) + .frame(width: 80, height: 80) + .cornerRadius(6) + } else if case AsyncImageStatus.loading = imageStatus { + Color.appButtonBackground + .frame(width: 80, height: 80) + .cornerRadius(6) + } else { + EmptyView() + } + } } } } diff --git a/apple/OmnivoreKit/Sources/Views/ProfileCard.swift b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift index a27ad3857..f74e1d328 100644 --- a/apple/OmnivoreKit/Sources/Views/ProfileCard.swift +++ b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift @@ -23,13 +23,18 @@ public struct ProfileCard: View { HStack(alignment: .center) { Group { if let url = data.imageURL { - AsyncImage(url: url, isResizable: true) + AsyncImage(url: url) { imageStatus in + if case let AsyncImageStatus.loaded(image) = imageStatus { + image.resizable() + } else { + Image(systemName: "person.crop.circle").resizable() + } + } } else { Image(systemName: "person.crop.circle") .resizable() } } - .aspectRatio(contentMode: .fill) .frame(width: 70, height: 70, alignment: .center) .clipShape(Circle())