Daily coverage of WWDC21.
A Swift by Sundell spin-off.

Using SwiftUI’s AsyncImage to render remote images from URLs

Published at 09:00 GMT, 09 Jun 2021
Written by: John Sundell

The 2021 release of SwiftUI introduces a new, built-in view called AsyncImage, which offers a simple way to download and render a remote image from a URL. At the very basic level, if all that we wanted to do was to simply render a downloaded image as-is, then that can now be done like this:

struct ProductView: View {
    var product: Product

    var body: some View {
        VStack {
            AsyncImage(url: product.imageURL)
            ...
        }
    }
}

By default, AsyncImage will render the downloaded image according to its pixel size, without cropping or resizing it in any way. That might be what we want in certain situations, but in some contexts that could result in our image being drawn way too large — perhaps even outside the bounds of the screen.

An initial idea on how to address that, for example by constraining our image to a certain max width and height, might be to use the frame modifier that’s very commonly used in SwiftUI in general:

struct ProductView: View {
    var product: Product

    var body: some View {
        VStack {
            AsyncImage(url: product.imageURL)
                .frame(maxWidth: 300, maxHeight: 100)
            ...
        }
    }
}

However, the above only changes the frame of the container used to render our image, it doesn’t actually resize our image itself (which can still end up being drawn out of bounds). To actually change the size of our image, we instead have to use another AsyncImage initializer which gives us more precise control over how both the image, and its placeholder, should be rendered:

struct ProductView: View {
    var product: Product

    var body: some View {
        VStack {
            AsyncImage(
                url: product.imageURL,
                content: { image in
                    image.resizable()
                         .aspectRatio(contentMode: .fit)
                         .frame(maxWidth: 300, maxHeight: 100)
                },
                placeholder: {
                    ProgressView()
                }
            )
            ...
        }
    }
}

To learn more about SwiftUI image resizing, and the framework’s overall layout system, check out this three-part guide over on Swift by Sundell.

What’s really neat is that, since the above content closure gives us the Image that was downloaded, and enables us to return any View that we wish to use to represent it, we now have complete control over how each image gets rendered.

One thing to keep in mind, though, is that the view returned from the placeholder closure isn’t just used while the image is being loaded — it’s also used as a fallback in case some form of error was encountered. Again, that might be fine for certain use cases, but above we wanted to display a ProgressView while our image is being loaded, but we likely don’t want to keep displaying that loading spinner if the download failed.

Thankfully, there’s a way to address that too, using the third type of initializer that AsyncImage currently ships with — which lets us implement a closure that constructs the view’s content based on what AsyncImagePhase that the download operation is currently in. Here’s how we could use that API to decide whether to render the image itself, a loading spinner, or a placeholder in case the download failed:

struct ProductView: View {
    var product: Product

    var body: some View {
        VStack {
            AsyncImage(url: product.imageURL) { phase in
                switch phase {
                case .empty:
                    ProgressView()
                case .success(let image):
                    image.resizable()
                         .aspectRatio(contentMode: .fit)
                         .frame(maxWidth: 300, maxHeight: 100)
                case .failure:
                    Image(systemName: "photo")
                @unknown default:
                    // Since the AsyncImagePhase enum isn't frozen,
                    // we need to add this currently unused fallback
                    // to handle any new cases that might be added
                    // in the future:
                    EmptyView()
                }
            }
            ...
        }
    }
}

There’s also a few other parameters that we can pass as well, including what scale that we expect the download image to have. One thing that doesn’t seem to be possible, however, is for us to control the actual download operation itself. The documentation for AsyncImage states that it will always use the app’s default URLSession.shared instance to perform each network call, and besides the caching that comes built into URLSession itself, there doesn’t seem to be a way to add any additional caching layers on top of that.

But, regardless, I think that this is a super welcome addition to SwiftUI’s suite of built-in views, and I think AsyncImage will be incredibly useful within a wide range of projects. It might not be suitable for apps that have very custom image downloading pipelines, at least not for now, but if all that we’re looking to do is to simply render images that were fetched over the network, then this looks to be just what we needed.

For more information on AsyncImage, I recommend watching the “What’s new in SwiftUI” session, as well as checking out its official documentation.

Thanks for reading!

Written by: John Sundell
RevenueCatRevenueCat

Easily build and manage iOS and Android in-app purchases. With just a few lines of code RevenueCat provides IAP infrastructure, customer analytics, data integrations, and gives you time back from dealing with edge cases and updates across all platforms.