mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1143 from omnivore-app/chore/use-swiftui-async-image
SwiftUI AsyncImage Adoption
This commit is contained in:
commit
b79f75c170
7 changed files with 21 additions and 183 deletions
|
|
@ -73,19 +73,21 @@ public struct ShareExtensionView: View {
|
|||
HStack {
|
||||
if let iconURLStr = viewModel.iconURL, let iconURL = URL(string: iconURLStr) {
|
||||
if !iconURL.isFileURL {
|
||||
AsyncLoadingImage(url: iconURL) { imageStatus in
|
||||
if case let AsyncImageStatus.loaded(image) = imageStatus {
|
||||
AsyncImage(
|
||||
url: iconURL,
|
||||
content: { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 61, height: 61)
|
||||
.clipped()
|
||||
} else {
|
||||
},
|
||||
placeholder: {
|
||||
Color.appButtonBackground
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 61, height: 61)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
if let localImage = localImage(from: iconURL) {
|
||||
localImage
|
||||
|
|
|
|||
|
|
@ -303,19 +303,6 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|||
]
|
||||
}
|
||||
|
||||
// if let imageURL = item?.imageURL, let cachedImage = ImageCache.shared[imageURL] {
|
||||
//// #if os(iOS)
|
||||
//// status = .loaded(image: Image(uiImage: cachedImage))
|
||||
//// #else
|
||||
//// status = .loaded(image: Image(nsImage: cachedImage))
|
||||
//// #endif
|
||||
// MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
||||
// // MPMediaItemPropertyArtwork: cachedImage,
|
||||
// MPMediaItemPropertyArtist: item?.author ?? "Omnivore",
|
||||
// MPMediaItemPropertyTitle: item?.title ?? "Your Omnivore Article"
|
||||
// ]
|
||||
// }
|
||||
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
public typealias PlatformImage = UIImage
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
|
||||
public typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
/// Reference: https://www.onswiftwings.com/posts/reusable-image-cache/
|
||||
|
||||
public final class ImageCache {
|
||||
public static let shared = ImageCache()
|
||||
|
||||
public subscript(_ key: URL) -> PlatformImage? {
|
||||
get {
|
||||
image(key)
|
||||
}
|
||||
set {
|
||||
insertImage(newValue, url: key)
|
||||
}
|
||||
}
|
||||
|
||||
public func removeAllObjects() {
|
||||
cache.removeAllObjects()
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "app.omnivore.image.cache.queue", attributes: .concurrent)
|
||||
private let cache = NSCache<NSString, PlatformImage>()
|
||||
|
||||
private init() {
|
||||
cache.totalCostLimit = 1024 * 1024 * 1024 * 50 // 50 MB
|
||||
}
|
||||
|
||||
private func image(_ url: URL) -> PlatformImage? {
|
||||
var cachedImage: PlatformImage?
|
||||
queue.sync {
|
||||
cachedImage = cache.object(forKey: NSString(string: url.absoluteString))
|
||||
}
|
||||
return cachedImage
|
||||
}
|
||||
|
||||
private func insertImage(_ image: PlatformImage?, url: URL) {
|
||||
guard let image = image else { return }
|
||||
queue.async(flags: .barrier) {
|
||||
self.cache.setObject(image, forKey: NSString(string: url.absoluteString), cost: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PlatformImage {
|
||||
var diskSize: Int {
|
||||
#if os(iOS)
|
||||
guard let cgImage = cgImage else { return 0 }
|
||||
return cgImage.bytesPerRow * cgImage.height
|
||||
#elseif os(macOS)
|
||||
// Instead of calculating the nsimage size just assume 250k
|
||||
// which will allow for up to 200 images in the cache
|
||||
(1024 * 1024) / 4
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
public enum AsyncImageStatus {
|
||||
case loading
|
||||
case loaded(image: Image)
|
||||
case error
|
||||
}
|
||||
|
||||
public struct AsyncLoadingImage<Content: View>: View {
|
||||
let viewBuilder: (AsyncImageStatus) -> Content
|
||||
let url: URL
|
||||
@StateObject private var imageLoader = ImageLoader()
|
||||
|
||||
public init(url: URL, @ViewBuilder viewBuilder: @escaping (AsyncImageStatus) -> Content) {
|
||||
self.url = url
|
||||
self.viewBuilder = viewBuilder
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder(imageLoader.status)
|
||||
.task { await imageLoader.load(fromUrl: url) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class ImageLoader: ObservableObject {
|
||||
@Published var status: AsyncImageStatus = .loading
|
||||
var loadStarted = false
|
||||
|
||||
func load(fromUrl url: URL) async {
|
||||
guard !loadStarted else { return }
|
||||
loadStarted = true
|
||||
|
||||
if let cachedImage = ImageCache.shared[url] {
|
||||
#if os(iOS)
|
||||
status = .loaded(image: Image(uiImage: cachedImage))
|
||||
#else
|
||||
status = .loaded(image: Image(nsImage: cachedImage))
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
if let imageData = try? await fetchImageData(url: url) {
|
||||
#if os(iOS)
|
||||
let fetchedImage = UIImage(data: imageData)
|
||||
#else
|
||||
let fetchedImage = NSImage(data: imageData)
|
||||
#endif
|
||||
|
||||
guard let fetchedImage = fetchedImage else {
|
||||
status = .error
|
||||
return
|
||||
}
|
||||
ImageCache.shared[url] = fetchedImage
|
||||
|
||||
#if os(iOS)
|
||||
status = .loaded(image: Image(uiImage: fetchedImage))
|
||||
#else
|
||||
status = .loaded(image: Image(nsImage: fetchedImage))
|
||||
#endif
|
||||
} else {
|
||||
status = .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchImageData(url: URL) async throws -> Data {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode {
|
||||
return data
|
||||
} else {
|
||||
throw BasicError.message(messageText: "failed")
|
||||
}
|
||||
} catch {
|
||||
throw BasicError.message(messageText: "failed")
|
||||
}
|
||||
}
|
||||
|
|
@ -133,19 +133,19 @@ public struct GridCard: View {
|
|||
Spacer()
|
||||
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncLoadingImage(url: imageURL) { imageStatus in
|
||||
if case let AsyncImageStatus.loaded(image) = imageStatus {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
if let image = phase.image {
|
||||
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 {
|
||||
} else if phase.error != nil {
|
||||
EmptyView()
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.frame(width: geo.size.width / 3, height: (geo.size.width * 2) / 9)
|
||||
.cornerRadius(3)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,19 +47,19 @@ public struct FeedCard: View {
|
|||
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncLoadingImage(url: imageURL) { imageStatus in
|
||||
if case let AsyncImageStatus.loaded(image) = imageStatus {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
} else if case AsyncImageStatus.loading = imageStatus {
|
||||
} else if phase.error != nil {
|
||||
EmptyView().frame(width: 80, height: 80, alignment: .top)
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
} else {
|
||||
EmptyView().frame(width: 80, height: 80, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,18 +22,11 @@ public struct ProfileCard: View {
|
|||
public var body: some View {
|
||||
HStack(alignment: .center) {
|
||||
Group {
|
||||
if let url = data.imageURL {
|
||||
AsyncLoadingImage(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()
|
||||
}
|
||||
AsyncImage(
|
||||
url: data.imageURL,
|
||||
content: { $0.resizable() },
|
||||
placeholder: { Image(systemName: "person.crop.circle").resizable() }
|
||||
)
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 70, height: 70, alignment: .center)
|
||||
|
|
|
|||
Loading…
Reference in a new issue