Created
January 14, 2026 14:00
-
-
Save jacobsapps/df056d70040d46608d793c2d3184028f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| import UIKit | |
| final class ImageCache { | |
| static let shared = ImageCache() | |
| private let cache = NSCache<NSURL, UIImage>() | |
| private init() {} | |
| func image(for url: URL) -> UIImage? { | |
| cache.object(forKey: url as NSURL) | |
| } | |
| func insert(_ image: UIImage, for url: URL) { | |
| cache.setObject(image, forKey: url as NSURL) | |
| } | |
| } | |
| struct CachedAsyncImage: View { | |
| enum Phase { | |
| case empty | |
| case loading | |
| case success(UIImage) | |
| case failure | |
| } | |
| let url: URL? | |
| let contentMode: ContentMode | |
| let cornerRadius: CGFloat | |
| let palette: DrillPalette | |
| @State private var phase: Phase = .empty | |
| @State private var loadTask: Task<Void, Never>? | |
| init(url: URL?, contentMode: ContentMode = .fill, cornerRadius: CGFloat = 18, palette: DrillPalette) { | |
| self.url = url | |
| self.contentMode = contentMode | |
| self.cornerRadius = cornerRadius | |
| self.palette = palette | |
| } | |
| var body: some View { | |
| ZStack { | |
| RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) | |
| .fill(palette.card) | |
| content | |
| .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) | |
| } | |
| .overlay( | |
| RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) | |
| .stroke(palette.outline, lineWidth: 1) | |
| ) | |
| .onAppear { | |
| loadIfNeeded() | |
| } | |
| .onChange(of: url) { _ in | |
| loadIfNeeded() | |
| } | |
| .onDisappear { | |
| cancelLoad() | |
| } | |
| } | |
| @ViewBuilder | |
| private var content: some View { | |
| switch phase { | |
| case .empty, .loading: | |
| VStack(spacing: 8) { | |
| ProgressView() | |
| .tint(palette.accent) | |
| Text("Loading") | |
| .font(DrillTheme.bodyFont) | |
| .foregroundStyle(palette.secondaryText) | |
| } | |
| case .success(let image): | |
| Image(uiImage: image) | |
| .resizable() | |
| .aspectRatio(contentMode: contentMode) | |
| case .failure: | |
| VStack(spacing: 8) { | |
| Image(systemName: "exclamationmark.triangle.fill") | |
| .font(.system(size: 24)) | |
| .foregroundStyle(palette.accent) | |
| Text("Failed to load") | |
| .font(DrillTheme.bodyFont) | |
| .foregroundStyle(palette.secondaryText) | |
| } | |
| } | |
| } | |
| private func loadIfNeeded() { | |
| guard let url else { | |
| cancelLoad() | |
| phase = .empty | |
| return | |
| } | |
| if let cached = ImageCache.shared.image(for: url) { | |
| phase = .success(cached) | |
| return | |
| } | |
| cancelLoad() | |
| phase = .loading | |
| loadTask = Task { | |
| do { | |
| let (data, response) = try await URLSession.shared.data(from: url) | |
| guard !Task.isCancelled else { return } | |
| guard let httpResponse = response as? HTTPURLResponse, | |
| (200...299).contains(httpResponse.statusCode) else { | |
| throw URLError(.badServerResponse) | |
| } | |
| guard let image = UIImage(data: data) else { | |
| throw URLError(.cannotDecodeContentData) | |
| } | |
| ImageCache.shared.insert(image, for: url) | |
| await MainActor.run { | |
| phase = .success(image) | |
| } | |
| } catch { | |
| guard !Task.isCancelled else { return } | |
| await MainActor.run { | |
| phase = .failure | |
| } | |
| } | |
| } | |
| } | |
| private func cancelLoad() { | |
| loadTask?.cancel() | |
| loadTask = nil | |
| } | |
| } | |
| struct AsyncImageComponentView: View { | |
| private let palette = DrillTheme.component | |
| private let samples: [ImageSample] = [ | |
| ImageSample(title: "Cliffside", url: URL(string: "https://picsum.photos/id/1011/600/600")), | |
| ImageSample(title: "Studio", url: URL(string: "https://picsum.photos/id/1005/600/600")), | |
| ImageSample(title: "Landscape", url: URL(string: "https://picsum.photos/id/1002/600/600")), | |
| ImageSample(title: "Market", url: URL(string: "https://picsum.photos/id/1035/600/600")), | |
| ImageSample(title: "Rainy", url: URL(string: "https://picsum.photos/id/1027/600/600")), | |
| ImageSample(title: "Amber", url: URL(string: "https://picsum.photos/id/1024/600/600")) | |
| ] | |
| private var columns: [GridItem] { | |
| [GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14)] | |
| } | |
| var body: some View { | |
| NavigationStack { | |
| ScrollView { | |
| VStack(alignment: .leading, spacing: 20) { | |
| SectionHeader( | |
| "Async Image Component", | |
| subtitle: "Build your own image loader with caching, cancellation, and error states.", | |
| palette: palette | |
| ) | |
| DrillCard( | |
| title: "Component Goals", | |
| bullets: [ | |
| "No AsyncImage - manage URLSession and state yourself.", | |
| "Cache successful images to avoid repeat work.", | |
| "Cancel in-flight requests when views disappear.", | |
| "Surface loading and error states in the UI." | |
| ], | |
| palette: palette | |
| ) | |
| LazyVGrid(columns: columns, spacing: 14) { | |
| ForEach(samples) { sample in | |
| VStack(alignment: .leading, spacing: 8) { | |
| CachedAsyncImage( | |
| url: sample.url, | |
| contentMode: .fill, | |
| cornerRadius: 18, | |
| palette: palette | |
| ) | |
| .frame(height: 150) | |
| Text(sample.title) | |
| .font(DrillTheme.bodyStrongFont) | |
| .foregroundStyle(palette.title) | |
| } | |
| } | |
| } | |
| } | |
| .padding(24) | |
| } | |
| .navigationTitle("Component") | |
| .navigationBarTitleDisplayMode(.inline) | |
| } | |
| .drillBackground(palette) | |
| } | |
| } | |
| struct ImageSample: Identifiable { | |
| let id = UUID() | |
| let title: String | |
| let url: URL? | |
| } | |
| #Preview { | |
| AsyncImageComponentView() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment