Merge pull request #237 from omnivore-app/feature/handle-async-image-errors

Apple async image errors
This commit is contained in:
Satindar Dhillon
2022-03-15 10:04:12 -07:00
committed by GitHub
5 changed files with 111 additions and 92 deletions

View File

@ -73,11 +73,11 @@ struct PrimaryContentSidebar: View {
label: { category.listLabel }
)
#if os(iOS)
.listRowBackground(
category == selectedCategory
? Color.appGraySolid.opacity(0.4).cornerRadius(8)
: Color.clear.cornerRadius(8)
)
.listRowBackground(
category == selectedCategory
? Color.appGraySolid.opacity(0.4).cornerRadius(8)
: Color.clear.cornerRadius(8)
)
#endif
}
.listStyle(.sidebar)

View File

@ -1,99 +1,91 @@
import Combine
import Foundation
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<Content: View>: 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?
private final class ImageLoader: ObservableObject {
@Published var status: AsyncImageStatus = .loading
var loadStarted = false
let placeholder = UIImage()
private var subscriptions = Set<AnyCancellable>()
private var cancellable: AnyCancellable?
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
}
cancellable = URLSession.shared
.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.handleEvents(receiveOutput: {
ImageCache.shared[url] = $0
})
.replaceError(with: nil)
.receive(on: DispatchQueue.main)
.assign(to: \.image, on: self)
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?
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
}
ImageCache.shared[url] = fetchedImage
let placeholder = NSImage(systemSymbolName: "photo", accessibilityDescription: "photo-placeholder")!
private var cancellable: AnyCancellable?
func load(fromUrl url: URL) {
if let cachedImage = ImageCache.shared[url] {
image = cachedImage
return
#if os(iOS)
self?.status = .loaded(image: Image(uiImage: fetchedImage))
#else
self?.status = .loaded(image: Image(nsImage: fetchedImage))
#endif
}
cancellable = URLSession.shared
.dataTaskPublisher(for: url)
.map { NSImage(data: $0.data) }
.handleEvents(receiveOutput: {
ImageCache.shared[url] = $0
})
.replaceError(with: nil)
.receive(on: DispatchQueue.main)
.assign(to: \.image, on: self)
}
)
.store(in: &subscriptions)
}
#endif
}
private func fetch(url: URL) -> AnyPublisher<Data, BasicError> {
let request = URLRequest(url: url)
return URLSession.DataTaskPublisher(request: request, session: .shared)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
throw BasicError.message(messageText: "failed")
}
return data
}
.mapError { _ in
BasicError.message(messageText: "failed")
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

View File

@ -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)

View File

@ -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()
}
}
}
}
}

View File

@ -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())