Downloading and displaying images from a remote URL is a common task in iOS and macOS engineering. Although SwiftUI doesn’t provide a built-in solution for it, we can come up with own implementation by utilizing rich APIs available in Apple system frameworks. In this article, let’s implement an AsyncImage SwiftUI component that bridges this gap.

Basic knowledge of SwiftUI and Combine is required for this article. Getting Started with Combine and Apple SwiftUI tutorials will get you up to speed.

Preparing Initial Design

The purpose of the AsyncImage view is to display an image provided its URL. It depends on ImageLoader that fetches an image from the network and emits image updates via a Combine publisher.

Let’s begin with designing the loader:

import SwiftUI import Combine import Foundation class ImageLoader : ObservableObject { @Published var image : UIImage ? private let url : URL init ( url : URL ) { self . url = url } func load () {} func cancel () {} }

The Combine’s way of making a model observable is by conforming to the ObservableObject protocol. In order to bind image updates to a view, we add the @Published property wrapper.

Next, implement the AsyncImage view:

struct AsyncImage < Placeholder : View > : View { @ObservedObject private var loader : ImageLoader private let placeholder : Placeholder ? init ( url : URL , placeholder : Placeholder ? = nil ) { loader = ImageLoader ( url : url ) self . placeholder = placeholder } var body : some View { image . onAppear ( perform : loader . load ) . onDisappear ( perform : loader . cancel ) } private var image : some View { placeholder } }

Here are the takeaways:

We bind AsyncImage to image updates by means of the @ObservedObject property wrapper. This way, SwiftUI will automatically rebuild the view every time the image changes. In the body property, we start and cancel loading based on AsyncImage ’s lifecycle events. For now, the body contains a placeholder instead of an actual image.

Loading Image Asynchronously

Let’s implement image loading and cancellation. We’ll use the promise-based solution from the Combine framework:

Learn more about Futures and Promises in Combine here.

class ImageLoader : ObservableObject { // .. private var cancellable : AnyCancellable ? deinit { cancellable ? . cancel () } func load () { cancellable = URLSession . shared . dataTaskPublisher ( for : url ) . map { UIImage ( data : $0 . data ) } . replaceError ( with : nil ) . receive ( on : DispatchQueue . main ) . assign ( to : \ . image , on : self ) } func cancel () { cancellable ? . cancel () } }

Then update AsyncImage to display an image or a placeholder:

struct AsyncImage < Placeholder : View > : View { // ... private var image : some View { Group { if loader . image != nil { Image ( uiImage : loader . image ! ) . resizable () } else { placeholder } } } }

Lastly, to test our component, add the following code to ContentView :

struct ContentView : View { let url = URL ( string : "https://image.tmdb.org/t/p/original/pThyQovXQrw2m0s9x82twj48Jq4.jpg" ) ! var body : some View { AsyncImage ( url : url , placeholder : Text ( "Loading ..." ) ) . aspectRatio ( contentMode : . fit ) } }

The result looks next:

Caching Images

First, create a thin abstraction layer on top of NSCache :

protocol ImageCache { subscript ( _ url : URL ) -> UIImage ? { get set } } struct TemporaryImageCache : ImageCache { private let cache = NSCache < NSURL , UIImage > () subscript ( _ key : URL ) -> UIImage ? { get { cache . object ( forKey : key as NSURL ) } set { newValue == nil ? cache . removeObject ( forKey : key as NSURL ) : cache . setObject ( newValue ! , forKey : key as NSURL ) } } }

Second, add caching to ImageLoader :

class ImageLoader : ObservableObject { // .. private var cache : ImageCache ? init ( url : URL , cache : ImageCache ? = nil ) { self . url = url self . cache = cache } func load () { if let image = cache ?[ url ] { self . image = image return } cancellable = URLSession . shared . dataTaskPublisher ( for : url ) . map { UIImage ( data : $0 . data ) } . replaceError ( with : nil ) . handleEvents ( receiveOutput : { [ weak self ] in self ? . cache ( $0 ) }) . receive ( on : DispatchQueue . main ) . assign ( to : \ . image , on : self ) } private func cache ( _ image : UIImage ?) { image . map { cache ?[ url ] = $0 } } // .. }

Third, add image cache to the AsyncImage initializer:

struct AsyncImage < Placeholder : View > : View { // .. init ( url : URL , placeholder : Placeholder ? = nil , cache : ImageCache ? = nil ) { loader = ImageLoader ( url : url , cache : cache ) self . placeholder = placeholder } // .. }

Lastly, we need a way of making image cache accessible for any view that needs to load and display remote images. SwiftUI way of passing global dependencies is by means of an environment.

Environment is essentially a dictionary with app-wide preferences. SwiftUI passes it automatically from the root view to its children.

Here is how we can add an image cache to the environment:

struct ImageCacheKey : EnvironmentKey { static let defaultValue : ImageCache = TemporaryImageCache () } extension EnvironmentValues { var imageCache : ImageCache { get { self [ ImageCacheKey . self ] } set { self [ ImageCacheKey . self ] = newValue } } }

The default image cache will be created when we access it for the first time via the @Environment property wrapper.

Add this code to your ContentView to see caching in action:

struct ContentView : View { let url = URL ( string : "https://image.tmdb.org/t/p/original/pThyQovXQrw2m0s9x82twj48Jq4.jpg" ) ! @Environment(\.imageCache) var cache : ImageCache @State var numberOfRows = 0 var body : some View { NavigationView { list . navigationBarItems ( trailing : addButton ) } } private var list : some View { List ( 0 ..< numberOfRows , id : \ . self ) { _ in AsyncImage ( url : self . url , placeholder : Text ( "Loading ..." ), cache : self . cache ) . frame ( minHeight : 200 , maxHeight : 200 ) . aspectRatio ( 2 / 3 , contentMode : . fit ) } } private var addButton : some View { Button ( action : { self . numberOfRows += 1 }) { Image ( systemName : "plus" ) } } }

The result looks next:

Finalizing Image Loading

There are two subtle problems left:

ImageLoader ’s load() method is not idempotent. Image caching is not thread-safe.

Let’s solve the first issue this by adding a loading state:

class ImageLoader : ObservableObject { // .. // 1. private(set) var isLoading = false func load () { // 2. guard ! isLoading else { return } if let image = cache ?[ url ] { self . image = image return } cancellable = URLSession . shared . dataTaskPublisher ( for : url ) . map { UIImage ( data : $0 . data ) } . replaceError ( with : nil ) // 3. . handleEvents ( receiveSubscription : { [ weak self ] _ in self ? . onStart () }, receiveOutput : { [ weak self ] in self ? . cache ( $0 ) }, receiveCompletion : { [ weak self ] _ in self ? . onFinish () }, receiveCancel : { [ weak self ] in self ? . onFinish () }) . receive ( on : DispatchQueue . main ) . assign ( to : \ . image , on : self ) } private func onStart () { isLoading = true } private func onFinish () { isLoading = false } // .. }

Here is what we are doing:

Add isLoading property that indicates current loading status. Exit early if image loading is already in progress. Handle subscription lifecycle events and update isLoading accordingly.

Then we add a serial image processing queue that takes care of thread safery issue. In the load() method, we subscribe on that queue:

class ImageLoader : ObservableObject { // .. private static let imageProcessingQueue = DispatchQueue ( label : "image-processing" ) func load () { // .. cancellable = URLSession . shared . dataTaskPublisher ( for : url ) . subscribe ( on : Self . imageProcessingQueue ) // .. } }

As a finishing touch, let’s make AsyncImage more reusable by passing image configuration from the outside:

struct AsyncImage < Placeholder : View > : View { private let configuration : ( Image ) -> Image // .. init ( url : URL , cache : ImageCache ? = nil , placeholder : Placeholder ? = nil , configuration : @escaping ( Image ) -> Image = { $0 }) { // .. } private var image : some View { Group { if loader . image != nil { configuration ( Image ( uiImage : loader . image ! )) } // .. } } }

To see AsyncImage in action, add the following code, that displays a list of movie posters, to your ContentView :

let posters = [ "https://image.tmdb.org/t/p/original/pThyQovXQrw2m0s9x82twj48Jq4.jpg" , "https://image.tmdb.org/t/p/original/vqzNJRH4YyquRiWxCCOH0aXggHI.jpg" , "https://image.tmdb.org/t/p/original/6ApDtO7xaWAfPqfi2IARXIzj8QS.jpg" , "https://image.tmdb.org/t/p/original/7GsM4mtM0worCtIVeiQt28HieeN.jpg" ] . map { URL ( string : $0 ) ! } struct ContentView : View { @Environment(\.imageCache) var cache : ImageCache var body : some View { List ( posters , id : \ . self ) { url in AsyncImage ( url : url , cache : self . cache , placeholder : Text ( "Loading ..." ), configuration : { $0 . resizable () }) . frame ( idealHeight : UIScreen . main . bounds . width / 2 * 3 ) // 2:3 aspect ratio } } }

The result looks next:

Source code

You can find the final project here. It is published under the “Unlicense”, which allows you to do whatever you want with it.

Performance Note

Downloading large images with a data task may result in memory pressure, as measured by Tibor. In this case, I suggest looking into download task and downloadTaskPublisher. Make sure not to optimize prematurely and do your measurements before tweaking the performance.