Enhanced AsyncImage

Yanbo Sha
8 min readMar 13, 2023

--

Photo by Mike van den Bos on Unsplash

SwiftUI comes with the Image component when it’s published as the built-in framework of iOS13 in WWDC²⁰¹⁹, it’s roughly equivalent to UIImageView in UIKit or NSImageView in AppKit. 2 years later when iOS15 was unveiled in WWDC²⁰²¹, SwiftUI added support to the AsyncImage which we can use to load an image from a remote URL. But it’s not a perfect fit for our common demands or is not fully fledged.

For example. I found it requests the URL we passed each time it presents without any caching mechanism and developers can’t easily extend it to meet their requirements. So I thought about implementing an AsyncImage with a simple caching mechanism and people can update it to meet their requirements.

Analysis of the built-in AsyncImage

The built-in AsyncImage features 3 initializers:

AsyncImage initializers
  1. The first one is the simplest one which directly loads the image from the remote URL and shows it as an Image.
  2. The second one enhances it, people can provide a placeholder and make some changes to the image before display.
  3. The last one is the most flexible one, people can pass in a closure which will be called when the image loading starts and finishes. we not only provide placeholder or image loaded but are also able to provide images for the failure case.

Before implementing them, we need to take a second to review these 3 initializers. think about it, we commonly provide a designated initializer and multiple convenience initializers for one type, those convenience initializers will call another convenience initializer or whatever finally call the designated initializer. Based on this simple and common principle, we can find that the last one is the designated initializer, it covers all of the cases the first two initializers have. OK, we know how to start with, let’s GO.

Struct Definition

struct AsyncImage<Content> : View where Content: View {

private let url: URL

@ViewBuilder
private let content: (AsyncImagePhase) -> Content

init(url: URL, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
self.url = url
self.contentBuilder = content
}

var body: some View {
/// …
}
}

As the code above shows, we define the URL property to hold the URL passed in and define another property to hold the content closure. the detail of AsyncImagePhase will be explained below.

It’s worth noting that we have a generic type for this struct because we don’t want to require people to return Image only. Instead, we hope people can return any view as needed, like returning a progress view or color for the loading state or returning a Button for the failure state which allows people to retry or do other things. For this reason, we need to take the View Protocol as the returning type of the closure, so we define a generic type for it.

How to populate the Body getter?

AsyncImage is different from the Image, it’ll change its appearance. so we need to define a State property, when the image is loaded successfully or fails to load, we can update the State property to trigger the update of the underlying views.

Before choosing which property we need to define as State, we need to list all of the cases that require an update. the built-in AsyncImage could change itself in 3 cases: loading, success, and failure. so we can define a new enum that has these 3 cases and associate the success case with an Image, associate the failure case with an Error:

enum AsyncImagePhase {

case empty
case success(Image)
case failure(Error)

public var image: Image? {
if case .success(let image) = self {
return image
} else {
return nil
}
}

public var error: Error? {
if case .failure(let error) = self {
return error
} else {
return nil
}
}
}

And use it as a State property:

@State private let phase: AsyncImagePhase = .empty

We initialize it with the empty case which is the initial state for each AsyncImage.

Call the content closure with the phase property mentioned above inside the body getter:

struct AsyncImage<Content> : View where Content: View {

private let url: URL
@ViewBuilder
private let content: (AsyncImagePhase) -> Content
@State
private let phase: AsyncImagePhase = .empty

init(url: URL, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
self.url = url
self.contentBuilder = content
}

var body: some View {
content(phase)
}
}

In this way, when the phase updates, the content closure will be called to update the AsyncImage’s appearance.

How to load the image?

People can return views for all of the cases in the content closure, like:

AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit().frame(width:300, height:300)
case .failure( _):
Text("Failure")
case .empty:
Text("Loading")
}
}

So all of what we need to do is to update the phase and call the content closure with it to ask people what view they’d like to display. To achieve this goal, we need to load the image with network API and call the content closure when it’s finished and also we need to cancel the network connection when the view is disappeared. The loading code is easy to write, we can use URLSession to implement it:

let result = try? await URLSession.shared.data(from: url)

But where and when should this code be called? there’s a perfect location, that’s the task view modifier. the task will be executed when the view appeared and will be canceled when the view is destroyed. so we update the code like below:

var body: some View {
content(phase).task {
let result = try? await URLSession.shared.data(from: url)
/// ...
}
}

We don’t need to specify the loading state to empty here because it’s the initial state. so the remaining work we have to do is updating the phase to success if the response data is resolved to a valid image or failure if something is wrong:

var body: some View {
content(phase).task {
do {
let result = try await URLSession.shared.data(from: url)
if let result = result, let image = UIImage(data: result.0) {
self.phase = .success(Image(uiImage: image))
} else {
self.phase = .failure()
}
} catch (let error) {
self.phase = .failure(error)
}
}
}

The task is only executed when the view appears and will be canceled when the view disappeared, the URLSession’s API will check the Task’s canceled status inside to interrupt itself in time. Okay, we’re almost done as an available View, but there are a few things we need to do. check the code above, we haven’t enhanced it with a caching mechanism.

You can use anything to achieve this goal. For this sample, I’ll do it by using the simplest way, the steps are below:

  1. Generate a md5 string or other unique string according to the URL, and check if it exists as the filename in a specific cache directory.
  2. Write it back to the local disk if the remote image is loaded successfully.
var body: some View {
content(phase).task {

/// try cache
let cachedName = url.absoluteString.md5
let cacheURL = URL.cachesDirectory.appending(path: cachedName)
var cachePath = cacheURL.absoluteString
cachePath.replace("file://", with: "")
if FileManager.default.fileExists(atPath: cachePath) {
if let image = try? UIImage(data: Data(contentsOf: cacheURL)) {
self.phase = .success(Image(uiImage: image))
return
}
}

/// load from remote
do {
let result = try await URLSession.shared.data(from: url)
if let image = UIImage(data: result.0) {

/// disk cache
let cachedName = url.absoluteString.md5
let cachePath = URL.cachesDirectory.appending(path: cachedName)
try? result.0.write(to: cachePath)

self.phase = .success(Image(uiImage: image))
} else {
self.phase = .failure(Error.invalidData)
}
} catch (let error) {
self.phase = .failure(error)
}
}
}

Okay, we’ve completed the main initializers. Now, we can write a sample code to check if it works:

let url = URL(string: "https://miro.medium.com/v2/resize:fill:150:150/1*kOyC7Snkp0xpEFJCdIO4Yg.jpeg")!

AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
ZStack {
image
.resizable()
.scaledToFit()
.clipShape(Circle())
.frame(width: 150, height: 150)
Circle()
.stroke(.white)
.frame(width: 152, height: 152)
Circle()
.stroke(.gray)
.frame(width: 154, height: 154)
}
case .failure( _):
Button {
Swift.print("retry")
} label: {
Text("Retry")
.foregroundColor(Color.white)
.padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20))
.background(.pink)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
case .empty:
ProgressView()
@unknown default:
fatalError()
}
}
success
failure

The remaining initializers

The remaining two initializers are easy to write because we just need to assign correctly the content closure to make it work like what we expect.

init(url: URL) where Content == Image {
self.url = url
self.contentBuilder = { phase in
phase.image ?? Image("")
}
}

The simplest initializer is to show an Image only when it’s loaded successfully, so we return the loaded image for the success case and an empty image for other cases.

The second initializer is relatively a little bit more complex. people can pass in two closures to provide a placeholder and make some changes to the loaded image respectively. and both return types can be different, so we define two function-scoped generic types: I(loaded image), and P(placeholder)

init<I, P>(url: URL, content: @escaping (Image)->I, placeholder: @escaping () -> P) where I : View, P : View

But Swift can’t infer the Content type just according to this initializer’s definition, we need to specify it in the where clause. What should it be? Due to the placeholder and ultimately-presented view may be different, we have to write the body property like the below:

self.content = { phase in
if let image = phase.image {
content(image)
} else {
placeholder()
}
}

We have to use an if-else condition statement to determine which view should be presented. Also, don’t forget the content is a ViewBuilder, so the returning type of the closure would be _ConditionalContent<I, P>. Therefore, we can update the code as below:

init<I, P>(url: URL, content: @escaping (Image)->I, placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View {
self.url = url
self.content = { phase in
if let image = phase.image {
content(image)
} else {
placeholder()
}
}
}

This’s a completed initializer, it also explains why we need to define the two closures with @escaping (we need to use the two closures outside the function scope).

There’s an incident in my sample code that I don’t know why. the code above led to a compile error, the Swift can’t identify the closure assignment above as a ViewBuilder. so I fix it with a workaround, wrapping them with a Group:

init<I, P>(url: URL, content: @escaping (Image)->I, placeholder: @escaping () -> P) where Content == Group<_ConditionalContent<I, P>>, I : View, P : View {
self.url = url
self.content = { phase in
Group {
if let image = phase.image {
content(image)
} else {
placeholder()
}
}
}
}

Conclusion

Okay, we’ve completed the AsyncImge of our own, you can check the completed sample code in my GitHub gist. And I still need to mention that this is only a sample code, you can update it based on your own requirements, such as adding a retry mechanism, replacing the download, or caching with other robust code. I hope you can find it helpful and follow me to stay up to update.

--

--

Yanbo Sha

iOS Programmer with 10 years of experience, interested in the latest technique, previously working at Bilibili