1+ //
2+ // AsyncImageAttachment.swift
3+ // V2er
4+ //
5+ // Created by RichView on 2025/1/19.
6+ //
7+
8+ import SwiftUI
9+ import Kingfisher
10+
11+ /// AsyncImage view for RichView with Kingfisher integration
12+ @available ( iOS 15 . 0 , * )
13+ public struct AsyncImageAttachment : View {
14+
15+ // MARK: - Properties
16+
17+ /// Image URL
18+ let url : URL ?
19+
20+ /// Alt text / description
21+ let altText : String
22+
23+ /// Image style configuration
24+ let style : ImageStyle
25+
26+ /// Image quality
27+ let quality : RenderConfiguration . ImageQuality
28+
29+ /// Loading state
30+ @State private var isLoading = true
31+
32+ /// Error state
33+ @State private var hasError = false
34+
35+ // MARK: - Initialization
36+
37+ public init (
38+ url: URL ? ,
39+ altText: String = " " ,
40+ style: ImageStyle ,
41+ quality: RenderConfiguration . ImageQuality = . medium
42+ ) {
43+ self . url = url
44+ self . altText = altText
45+ self . style = style
46+ self . quality = quality
47+ }
48+
49+ // MARK: - Body
50+
51+ public var body : some View {
52+ Group {
53+ if let url = url {
54+ KFImage ( url)
55+ . placeholder { _ in
56+ placeholderView
57+ }
58+ . retry ( maxCount: 3 , interval: . seconds( 2 ) )
59+ . onSuccess { _ in
60+ isLoading = false
61+ hasError = false
62+ }
63+ . onFailure { _ in
64+ isLoading = false
65+ hasError = true
66+ }
67+ . resizable ( )
68+ . aspectRatio ( contentMode: . fit)
69+ . frame ( maxWidth: style. maxWidth, maxHeight: style. maxHeight)
70+ . cornerRadius ( style. cornerRadius)
71+ . overlay (
72+ RoundedRectangle ( cornerRadius: style. cornerRadius)
73+ . stroke ( style. borderColor, lineWidth: style. borderWidth)
74+ )
75+ . accessibilityLabel ( altText. isEmpty ? " Image " : altText)
76+ } else {
77+ errorView
78+ }
79+ }
80+ }
81+
82+ // MARK: - Subviews
83+
84+ private var placeholderView : some View {
85+ VStack ( spacing: 8 ) {
86+ ProgressView ( )
87+ . progressViewStyle ( CircularProgressViewStyle ( ) )
88+
89+ if !altText. isEmpty {
90+ Text ( altText)
91+ . font ( . caption)
92+ . foregroundColor ( . secondary)
93+ . multilineTextAlignment ( . center)
94+ . padding ( . horizontal, 8 )
95+ }
96+ }
97+ . frame ( maxWidth: style. maxWidth, maxHeight: min ( style. maxHeight, 200 ) )
98+ . background ( Color . gray. opacity ( 0.1 ) )
99+ . cornerRadius ( style. cornerRadius)
100+ }
101+
102+ private var errorView : some View {
103+ VStack ( spacing: 8 ) {
104+ Image ( systemName: " photo.fill " )
105+ . font ( . largeTitle)
106+ . foregroundColor ( . secondary)
107+
108+ Text ( altText. isEmpty ? " Image unavailable " : altText)
109+ . font ( . caption)
110+ . foregroundColor ( . secondary)
111+ . multilineTextAlignment ( . center)
112+ . padding ( . horizontal, 8 )
113+
114+ Text ( " Tap to retry " )
115+ . font ( . caption2)
116+ . foregroundColor ( . blue)
117+ }
118+ . frame ( maxWidth: style. maxWidth, maxHeight: min ( style. maxHeight, 200 ) )
119+ . background ( Color . gray. opacity ( 0.1 ) )
120+ . cornerRadius ( style. cornerRadius)
121+ . onTapGesture {
122+ // Retry loading
123+ isLoading = true
124+ hasError = false
125+ }
126+ }
127+ }
128+
129+ // MARK: - Image Info Model
130+
131+ /// Information about an image in content
132+ public struct ImageInfo : Equatable {
133+ /// Image URL
134+ public let url : URL ?
135+
136+ /// Alt text / description
137+ public let altText : String
138+
139+ /// Original HTML img tag attributes
140+ public let attributes : [ String : String ]
141+
142+ /// Width if specified
143+ public var width : CGFloat ? {
144+ if let widthStr = attributes [ " width " ] ,
145+ let width = Double ( widthStr) {
146+ return CGFloat ( width)
147+ }
148+ return nil
149+ }
150+
151+ /// Height if specified
152+ public var height : CGFloat ? {
153+ if let heightStr = attributes [ " height " ] ,
154+ let height = Double ( heightStr) {
155+ return CGFloat ( height)
156+ }
157+ return nil
158+ }
159+
160+ public init ( url: URL ? , altText: String , attributes: [ String : String ] = [ : ] ) {
161+ self . url = url
162+ self . altText = altText
163+ self . attributes = attributes
164+ }
165+ }
166+
167+ // MARK: - Image Cache Manager
168+
169+ /// Manager for image caching configuration
170+ public class ImageCacheManager {
171+
172+ public static let shared = ImageCacheManager ( )
173+
174+ private init ( ) {
175+ configureKingfisher ( )
176+ }
177+
178+ private func configureKingfisher( ) {
179+ // Set cache limits
180+ let cache = KingfisherManager . shared. cache
181+
182+ // Memory cache: 100 MB
183+ cache. memoryStorage. config. totalCostLimit = 100 * 1024 * 1024
184+
185+ // Disk cache: 500 MB
186+ cache. diskStorage. config. sizeLimit = 500 * 1024 * 1024
187+
188+ // Cache expiration: 7 days
189+ cache. diskStorage. config. expiration = . days( 7 )
190+ }
191+
192+ /// Clear all image caches
193+ public func clearCache( ) {
194+ KingfisherManager . shared. cache. clearMemoryCache ( )
195+ KingfisherManager . shared. cache. clearDiskCache ( )
196+ }
197+
198+ /// Clear memory cache only
199+ public func clearMemoryCache( ) {
200+ KingfisherManager . shared. cache. clearMemoryCache ( )
201+ }
202+
203+ /// Get cache size in MB
204+ public func getCacheSize( completion: @escaping ( Double ) -> Void ) {
205+ KingfisherManager . shared. cache. calculateDiskStorageSize { result in
206+ switch result {
207+ case . success( let size) :
208+ let sizeInMB = Double ( size) / ( 1024 * 1024 )
209+ completion ( sizeInMB)
210+ case . failure:
211+ completion ( 0 )
212+ }
213+ }
214+ }
215+ }
216+
217+ // MARK: - Preview
218+
219+ @available ( iOS 15 . 0 , * )
220+ struct AsyncImageAttachment_Previews : PreviewProvider {
221+ static var previews : some View {
222+ VStack ( spacing: 20 ) {
223+ // Valid image
224+ AsyncImageAttachment (
225+ url: URL ( string: " https://www.v2ex.com/static/img/logo.png " ) ,
226+ altText: " V2EX Logo " ,
227+ style: ImageStyle ( )
228+ )
229+
230+ // Invalid URL (error state)
231+ AsyncImageAttachment (
232+ url: URL ( string: " https://invalid.url/image.png " ) ,
233+ altText: " Error Image " ,
234+ style: ImageStyle ( )
235+ )
236+
237+ // Nil URL
238+ AsyncImageAttachment (
239+ url: nil ,
240+ altText: " No URL Provided " ,
241+ style: ImageStyle ( )
242+ )
243+ }
244+ . padding ( )
245+ }
246+ }
0 commit comments