Files
Reflect/Shared/Services/ImageCache.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

182 lines
4.7 KiB
Swift

//
// ImageCache.swift
// Reflect
//
// In-memory image cache for thumbnail images to improve scrolling performance.
//
import UIKit
import SwiftUI
/// Thread-safe in-memory image cache
final class ImageCache {
static let shared = ImageCache()
private let cache = NSCache<NSString, UIImage>()
private let queue = DispatchQueue(label: "com.88oakapps.reflect.imagecache", qos: .userInitiated)
private var memoryWarningToken: NSObjectProtocol?
private init() {
// Configure cache limits
cache.countLimit = 100 // Max 100 images
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB max
// Clear cache on memory warning
memoryWarningToken = NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.clearCache()
}
}
deinit {
if let token = memoryWarningToken {
NotificationCenter.default.removeObserver(token)
}
}
// MARK: - Public API
/// Get image from cache
func image(forKey key: String) -> UIImage? {
queue.sync {
cache.object(forKey: key as NSString)
}
}
/// Store image in cache
func setImage(_ image: UIImage, forKey key: String) {
queue.async { [weak self] in
let cost = Int(image.size.width * image.size.height * 4) // Approximate memory cost
self?.cache.setObject(image, forKey: key as NSString, cost: cost)
}
}
/// Remove image from cache
func removeImage(forKey key: String) {
queue.async { [weak self] in
self?.cache.removeObject(forKey: key as NSString)
}
}
/// Clear all cached images
func clearCache() {
queue.async { [weak self] in
self?.cache.removeAllObjects()
}
}
// MARK: - Convenience Methods for Photo IDs
/// Get cached image for photo ID
func image(forPhotoID id: UUID) -> UIImage? {
image(forKey: id.uuidString)
}
/// Store image for photo ID
func setImage(_ image: UIImage, forPhotoID id: UUID) {
setImage(image, forKey: id.uuidString)
}
/// Remove cached image for photo ID
func removeImage(forPhotoID id: UUID) {
removeImage(forKey: id.uuidString)
}
}
// MARK: - Cached Photo Loading
extension PhotoManager {
/// Load thumbnail with caching
func loadCachedThumbnail(id: UUID) -> UIImage? {
// Check cache first
if let cached = ImageCache.shared.image(forPhotoID: id) {
return cached
}
// Load from disk
guard let image = loadThumbnail(id: id) else {
return nil
}
// Cache for future use
ImageCache.shared.setImage(image, forPhotoID: id)
return image
}
/// Load full image with caching
func loadCachedPhoto(id: UUID) -> UIImage? {
let cacheKey = "\(id.uuidString)-full"
// Check cache first
if let cached = ImageCache.shared.image(forKey: cacheKey) {
return cached
}
// Load from disk
guard let image = loadPhoto(id: id) else {
return nil
}
// Cache for future use
ImageCache.shared.setImage(image, forKey: cacheKey)
return image
}
}
// MARK: - SwiftUI Cached Image View
struct CachedAsyncImage: View {
let photoID: UUID?
let useThumbnail: Bool
@State private var image: UIImage?
@State private var isLoading = false
init(photoID: UUID?, useThumbnail: Bool = true) {
self.photoID = photoID
self.useThumbnail = useThumbnail
}
var body: some View {
Group {
if let image = image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else if isLoading {
ProgressView()
} else {
Color.gray.opacity(0.2)
}
}
.onAppear {
loadImage()
}
}
@MainActor
private func loadImage() {
guard let id = photoID else { return }
isLoading = true
Task {
let loadedImage = await Task.detached(priority: .userInitiated) {
if useThumbnail {
return await PhotoManager.shared.loadCachedThumbnail(id: id)
} else {
return await PhotoManager.shared.loadCachedPhoto(id: id)
}
}.value
await MainActor.run {
self.image = loadedImage
self.isLoading = false
}
}
}
}