Asynchronous Image Loading from URL in SwiftUI
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
}
deinit {
cancel()
}
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 {
@StateObject private var loader: ImageLoader
private let placeholder: Placeholder
init(url: URL, @ViewBuilder placeholder: () -> Placeholder) {
self.placeholder = placeholder()
_loader = StateObject(wrappedValue: ImageLoader(url: url))
}
var body: some View {
content
.onAppear(perform: loader.load)
}
private var content: some View {
placeholder
}
}
Here are the takeaways:
- We bind
AsyncImage
to image updates by means of the@StateObject
property wrapper. This way, SwiftUI will automatically rebuild the view every time the image changes. We pick@StateObject
over@ObservedObject
and@EnvironmentObject
since we want the view to manage image loader’s lifecycle. SwiftUI automatically keeps image loader alive as long asAsyncImage
remains visible, and releases the image loader when the view is not needed anymore. - In the
body
property, we start image loading whenAsyncImage
’s body appears. There is no need to cancel image loading explicitly in view’sonDisappear()
since SwiftUI does this automatically for@StateObject
s. - 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?
func load() {
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.replaceError(with: nil)
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.image = $0 }
}
func cancel() {
cancellable?.cancel()
}
}
Then update AsyncImage
to display an image or a placeholder:
struct AsyncImage<Placeholder: View>: View {
// ...
private var content: 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)
.sink { [weak self] in self?.image = $0 }
}
private func cache(_ image: UIImage?) {
image.map { cache?[url] = $0 }
}
}
Third, 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.
Fourth, add the image cache to the AsyncImage
initializer:
struct AsyncImage<Placeholder: View>: View {
// ...
init(url: URL, @ViewBuilder placeholder: () -> Placeholder) {
self.placeholder = placeholder()
_loader = StateObject(wrappedValue: ImageLoader(url: url, cache: Environment(\.imageCache).wrappedValue))
}
// ...
}
The important takeaway from the above snippet is that we read the image cache from the AsyncImage
’s environment, and pass it directly to the ImageLoader
’s initializer.
Lastly, 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")!
@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 ...") })
.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
’sload()
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)
.sink { [weak self] in self?.image = $0 }
}
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 image: (UIImage) -> Image
init(
url: URL,
@ViewBuilder placeholder: () -> Placeholder,
@ViewBuilder image: @escaping (UIImage) -> Image = Image.init(uiImage:)
) {
self.placeholder = placeholder()
self.image = image
_loader = StateObject(wrappedValue: ImageLoader(url: url, cache: Environment(\.imageCache).wrappedValue))
}
// ...
private var content: some View {
Group {
if loader.image != nil {
image(loader.image!)
} else {
placeholder
}
}
}
}
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 {
var body: some View {
List(posters, id: \.self) { url in
AsyncImage(
url: url,
placeholder: { Text("Loading ...") },
image: { Image(uiImage: $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.
Thanks for reading!
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content. There I write daily on iOS development, programming, and Swift.