App/Sources/RemoteImage.swift
88 lines · 3819 bytes
10 symbols in this file
1import ImageIO
2import SwiftUI
3import UIKit
4
5/// Images from the network, decoded at the size they are shown rather than full size, and kept in memory.
6/// Downloads go through URLSession.shared, so the disk cache set up in `configureURLCache` applies.
7enum ImageLoader {
8 // NSCache is thread-safe.
9 nonisolated(unsafe) private static let cache: NSCache<NSString, UIImage> = {
10 let c = NSCache<NSString, UIImage>()
11 c.totalCostLimit = 60 * 1024 * 1024
12 return c
13 }()
14
15 static func cached(_ url: URL, maxPixels: Int) -> UIImage? {
16 cache.object(forKey: key(url, maxPixels))
17 }
18
19 static func load(_ url: URL, maxPixels: Int) async throws -> UIImage {
20 if let hit = cached(url, maxPixels: maxPixels) { return hit }
21 let (data, _) = try await URLSession.shared.data(from: url)
22 let image = try await Task.detached(priority: .userInitiated) { try downsample(data, maxPixels: maxPixels) }.value
23 let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4)
24 cache.setObject(image, forKey: key(url, maxPixels), cost: cost)
25 return image
26 }
27
28 private static func key(_ url: URL, _ maxPixels: Int) -> NSString { "\(maxPixels)|\(url.absoluteString)" as NSString }
29
30 /// Decodes straight to a thumbnail no larger than `maxPixels` on its long side.
31 nonisolated static func downsample(_ data: Data, maxPixels: Int) throws -> UIImage {
32 guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
33 let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, [
34 kCGImageSourceCreateThumbnailFromImageAlways: true,
35 kCGImageSourceCreateThumbnailWithTransform: true,
36 kCGImageSourceShouldCacheImmediately: true,
37 kCGImageSourceThumbnailMaxPixelSize: maxPixels,
38 ] as CFDictionary)
39 else { throw URLError(.cannotDecodeContentData) }
40 return UIImage(cgImage: cg)
41 }
42
43 /// A bigger shared cache than the default (512 KB memory, about 20 MB disk), so Library photos and
44 /// feed responses survive between visits. Responses over 5% of the disk size are never stored.
45 static func configureURLCache() {
46 URLCache.shared = URLCache(memoryCapacity: 24 * 1024 * 1024, diskCapacity: 400 * 1024 * 1024)
47 }
48}
49
50/// A remote image decoded for `points` (the longer side it is drawn at), with a placeholder while loading.
51struct RemoteImage<Placeholder: View>: View {
52 var url: URL?
53 var points: CGFloat
54 var contentMode: ContentMode = .fill
55 @ViewBuilder var placeholder: () -> Placeholder
56 @Environment(\.displayScale) private var scale
57 @State private var image: UIImage?
58
59 private var maxPixels: Int { Int(points * scale) }
60
61 var body: some View {
62 Group {
63 if let image = image ?? url.flatMap({ ImageLoader.cached($0, maxPixels: maxPixels) }) {
64 Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode)
65 } else {
66 placeholder()
67 }
68 }
69 .task(id: url) {
70 guard let url, image == nil else { return }
71 image = try? await ImageLoader.load(url, maxPixels: maxPixels)
72 }
73 }
74}
75
76/// Recent feed results kept in memory, so switching tabs or views doesn't refetch them.
77/// Pull to refresh bypasses it.
78@MainActor
79enum FeedMemo {
80 private static var store: [String: (at: Date, value: Any)] = [:]
81
82 static func get<T>(_ key: String, maxAge: TimeInterval) -> T? {
83 guard let hit = store[key], Date.now.timeIntervalSince(hit.at) < maxAge else { return nil }
84 return hit.value as? T
85 }
86
87 static func set(_ key: String, _ value: Any) { store[key] = (.now, value) }
88}