-
Notifications
You must be signed in to change notification settings - Fork 69
/
Copy pathCachedAsyncImage.swift
405 lines (375 loc) · 18.9 KB
/
CachedAsyncImage.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//
// Created by Lorenzo Fiamingo on 04/11/20.
//
import SwiftUI
/// A view that asynchronously loads, cache and displays an image.
///
/// This view uses a custom default
/// <doc://com.apple.documentation/documentation/Foundation/URLSession>
/// instance to load an image from the specified URL, and then display it.
/// For example, you can display an icon that's stored on a server:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png"))
/// .frame(width: 200, height: 200)
///
/// Until the image loads, the view displays a standard placeholder that
/// fills the available space. After the load completes successfully, the view
/// updates to display the image. In the example above, the icon is smaller
/// than the frame, and so appears smaller than the placeholder.
///
/// 
///
/// You can specify a custom placeholder using
/// ``init(url:urlCache:scale:content:placeholder:)``. With this initializer, you can
/// also use the `content` parameter to manipulate the loaded image.
/// For example, you can add a modifier to make the loaded image resizable:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in
/// image.resizable()
/// } placeholder: {
/// ProgressView()
/// }
/// .frame(width: 50, height: 50)
///
/// For this example, SwiftUI shows a ``ProgressView`` first, and then the
/// image scaled to fit in the specified frame:
///
/// 
///
/// > Important: You can't apply image-specific modifiers, like
/// ``Image/resizable(capInsets:resizingMode:)``, directly to a `CachedAsyncImage`.
/// Instead, apply them to the ``Image`` instance that your `content`
/// closure gets when defining the view's appearance.
///
/// To gain more control over the loading process, use the
/// ``init(url:urlCache:scale:transaction:content:)`` initializer, which takes a
/// `content` closure that receives an ``AsyncImagePhase`` to indicate
/// the state of the loading operation. Return a view that's appropriate
/// for the current phase:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in
/// if let image = phase.image {
/// image // Displays the loaded image.
/// } else if phase.error != nil {
/// Color.red // Indicates an error.
/// } else {
/// Color.blue // Acts as a placeholder.
/// }
/// }
///
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public struct CachedAsyncImage<Content>: View where Content: View {
@State private var phase: AsyncImagePhase
private let urlRequest: URLRequest?
private let urlSession: URLSession
private let scale: CGFloat
private let transaction: Transaction
private let content: (AsyncImagePhase) -> Content
public var body: some View {
content(phase)
.task(id: urlRequest) { await load() }
}
/// Loads and displays an image from the specified URL.
///
/// Until the image loads, SwiftUI displays a default placeholder. When
/// the load operation completes successfully, SwiftUI updates the
/// view to show the loaded image. If the operation fails, SwiftUI
/// continues to display the placeholder. The following example loads
/// and displays an icon from an example server:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png"))
///
/// If you want to customize the placeholder or apply image-specific
/// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` ---
/// to the loaded image, use the ``init(url:scale:content:placeholder:)``
/// initializer instead.
///
/// - Parameters:
/// - url: The URL of the image to display.
/// - urlCache: The URL cache for providing cached responses to requests within the session.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Image {
let urlRequest = url == nil ? nil : URLRequest(url: url!)
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale)
}
/// Loads and displays an image from the specified URL.
///
/// Until the image loads, SwiftUI displays a default placeholder. When
/// the load operation completes successfully, SwiftUI updates the
/// view to show the loaded image. If the operation fails, SwiftUI
/// continues to display the placeholder. The following example loads
/// and displays an icon from an example server:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png"))
///
/// If you want to customize the placeholder or apply image-specific
/// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` ---
/// to the loaded image, use the ``init(url:scale:content:placeholder:)``
/// initializer instead.
///
/// - Parameters:
/// - urlRequest: The URL request of the image to display.
/// - urlCache: The URL cache for providing cached responses to requests within the session.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Image {
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in
#if os(macOS)
phase.image ?? Image(nsImage: .init())
#else
phase.image ?? Image(uiImage: .init())
#endif
}
}
/// Loads and displays a modifiable image from the specified URL using
/// a custom placeholder until the image loads.
///
/// Until the image loads, SwiftUI displays the placeholder view that
/// you specify. When the load operation completes successfully, SwiftUI
/// updates the view to show content that you specify, which you
/// create using the loaded image. For example, you can show a green
/// placeholder, followed by a tiled version of the loaded image:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in
/// image.resizable(resizingMode: .tile)
/// } placeholder: {
/// Color.green
/// }
///
/// If the load operation fails, SwiftUI continues to display the
/// placeholder. To be able to display a different view on a load error,
/// use the ``init(url:scale:transaction:content:)`` initializer instead.
///
/// - Parameters:
/// - url: The URL of the image to display.
/// - urlCache: The URL cache for providing cached responses to requests within the session.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
/// - content: A closure that takes the loaded image as an input, and
/// returns the view to show. You can return the image directly, or
/// modify it as needed before returning it.
/// - placeholder: A closure that returns the view to show until the
/// load operation completes successfully.
public init<I, P>(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View {
let urlRequest = url == nil ? nil : URLRequest(url: url!)
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, content: content, placeholder: placeholder)
}
/// Loads and displays a modifiable image from the specified URL using
/// a custom placeholder until the image loads.
///
/// Until the image loads, SwiftUI displays the placeholder view that
/// you specify. When the load operation completes successfully, SwiftUI
/// updates the view to show content that you specify, which you
/// create using the loaded image. For example, you can show a green
/// placeholder, followed by a tiled version of the loaded image:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in
/// image.resizable(resizingMode: .tile)
/// } placeholder: {
/// Color.green
/// }
///
/// If the load operation fails, SwiftUI continues to display the
/// placeholder. To be able to display a different view on a load error,
/// use the ``init(url:scale:transaction:content:)`` initializer instead.
///
/// - Parameters:
/// - urlRequest: The URL request of the image to display.
/// - urlCache: The URL cache for providing cached responses to requests within the session.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
/// - content: A closure that takes the loaded image as an input, and
/// returns the view to show. You can return the image directly, or
/// modify it as needed before returning it.
/// - placeholder: A closure that returns the view to show until the
/// load operation completes successfully.
public init<I, P>(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View {
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in
if let image = phase.image {
content(image)
} else {
placeholder()
}
}
}
/// Loads and displays a modifiable image from the specified URL in phases.
///
/// If you set the asynchronous image's URL to `nil`, or after you set the
/// URL to a value but before the load operation completes, the phase is
/// ``AsyncImagePhase/empty``. After the operation completes, the phase
/// becomes either ``AsyncImagePhase/failure(_:)`` or
/// ``AsyncImagePhase/success(_:)``. In the first case, the phase's
/// ``AsyncImagePhase/error`` value indicates the reason for failure.
/// In the second case, the phase's ``AsyncImagePhase/image`` property
/// contains the loaded image. Use the phase to drive the output of the
/// `content` closure, which defines the view's appearance:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in
/// if let image = phase.image {
/// image // Displays the loaded image.
/// } else if phase.error != nil {
/// Color.red // Indicates an error.
/// } else {
/// Color.blue // Acts as a placeholder.
/// }
/// }
///
/// To add transitions when you change the URL, apply an identifier to the
/// ``CachedAsyncImage``.
///
/// - Parameters:
/// - url: The URL of the image to display.
/// - urlCache: The URL cache for providing cached responses to requests within the session.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
/// - transaction: The transaction to use when the phase changes.
/// - content: A closure that takes the load phase as an input, and
/// returns the view to display for the specified phase.
public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
let urlRequest = url == nil ? nil : URLRequest(url: url!)
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, transaction: transaction, content: content)
}
/// Loads and displays a modifiable image from the specified URL in phases.
///
/// If you set the asynchronous image's URL to `nil`, or after you set the
/// URL to a value but before the load operation completes, the phase is
/// ``AsyncImagePhase/empty``. After the operation completes, the phase
/// becomes either ``AsyncImagePhase/failure(_:)`` or
/// ``AsyncImagePhase/success(_:)``. In the first case, the phase's
/// ``AsyncImagePhase/error`` value indicates the reason for failure.
/// In the second case, the phase's ``AsyncImagePhase/image`` property
/// contains the loaded image. Use the phase to drive the output of the
/// `content` closure, which defines the view's appearance:
///
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in
/// if let image = phase.image {
/// image // Displays the loaded image.
/// } else if phase.error != nil {
/// Color.red // Indicates an error.
/// } else {
/// Color.blue // Acts as a placeholder.
/// }
/// }
///
/// To add transitions when you change the URL, apply an identifier to the
/// ``CachedAsyncImage``.
///
/// - Parameters:
/// - urlRequest: The URL request of the image to display.
/// - urlCache: The URL cache for providing cached responses to requests within the session.
/// - scale: The scale to use for the image. The default is `1`. Set a
/// different value when loading images designed for higher resolution
/// displays. For example, set a value of `2` for an image that you
/// would name with the `@2x` suffix if stored in a file on disk.
/// - transaction: The transaction to use when the phase changes.
/// - content: A closure that takes the load phase as an input, and
/// returns the view to display for the specified phase.
public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
let configuration = URLSessionConfiguration.default
configuration.urlCache = urlCache
self.urlRequest = urlRequest
self.urlSession = URLSession(configuration: configuration)
self.scale = scale
self.transaction = transaction
self.content = content
self._phase = State(wrappedValue: .empty)
do {
if let urlRequest = urlRequest, let image = try cachedImage(from: urlRequest, cache: urlCache) {
self._phase = State(wrappedValue: .success(image))
}
} catch {
self._phase = State(wrappedValue: .failure(error))
}
}
private func load() async {
do {
if let urlRequest = urlRequest {
let (image, metrics) = try await remoteImage(from: urlRequest, session: urlSession)
if metrics.transactionMetrics.last?.resourceFetchType == .localCache {
// WARNING: This does not behave well when the url is changed with another
phase = .success(image)
} else {
withAnimation(transaction.animation) {
phase = .success(image)
}
}
} else {
withAnimation(transaction.animation) {
phase = .empty
}
}
} catch {
withAnimation(transaction.animation) {
phase = .failure(error)
}
}
}
}
// MARK: - LoadingError
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private extension AsyncImage {
struct LoadingError: Error {
}
}
// MARK: - Helpers
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private extension CachedAsyncImage {
private func remoteImage(from request: URLRequest, session: URLSession) async throws -> (Image, URLSessionTaskMetrics) {
let (data, _, metrics) = try await session.data(for: request)
if metrics.redirectCount > 0, let lastResponse = metrics.transactionMetrics.last?.response {
let requests = metrics.transactionMetrics.map { $0.request }
requests.forEach(session.configuration.urlCache!.removeCachedResponse)
let lastCachedResponse = CachedURLResponse(response: lastResponse, data: data)
session.configuration.urlCache!.storeCachedResponse(lastCachedResponse, for: request)
}
return (try image(from: data), metrics)
}
private func cachedImage(from request: URLRequest, cache: URLCache) throws -> Image? {
guard let cachedResponse = cache.cachedResponse(for: request) else { return nil }
return try image(from: cachedResponse.data)
}
private func image(from data: Data) throws -> Image {
#if os(macOS)
if let nsImage = NSImage(data: data) {
return Image(nsImage: nsImage)
} else {
throw AsyncImage<Content>.LoadingError()
}
#else
if let uiImage = UIImage(data: data, scale: scale) {
return Image(uiImage: uiImage)
} else {
throw AsyncImage<Content>.LoadingError()
}
#endif
}
}
// MARK: - AsyncImageURLSession
private class URLSessionTaskController: NSObject, URLSessionTaskDelegate {
var metrics: URLSessionTaskMetrics?
func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
self.metrics = metrics
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private extension URLSession {
func data(for request: URLRequest) async throws -> (Data, URLResponse, URLSessionTaskMetrics) {
let controller = URLSessionTaskController()
let (data, response) = try await data(for: request, delegate: controller)
return (data, response, controller.metrics!)
}
}