krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

App/Sources/RemoteImage.swift

main
moonrise/App/Sources/RemoteImage.swift history · blame · raw

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}