This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We look at SwiftUI's new AsyncImage API and reimplement it using async/await.

00:06 WWDC just gave us a few new SwiftUI goodies. One of them is the AsyncImage view, and we want to take a look at how it works by implementing it ourselves.

00:22 We only need to pass this view an image's URL, and it'll load and show the image:

struct ContentView: View {
    @State var url = URL(string: "https://via.placeholder.com/350x150")!
    var body: some View {
        AsyncImage(url: url)
    }
}

00:42 It might look very straightforward, but writing our own AsyncImage is trickier than it seems. First, we need to think about where we start loading the data. Doing this in the view's initializer or in the body property would be too eager. We should only start loading data when the view appears.

If the view goes away before the image is loaded, we need to stop loading it. If we're using URLSession, that means we have to keep the data task around so that we can cancel it. Or, if we're using Combine, we have to store a Cancellable instead.

01:37 We also need to keep track of changes to the URL, since onAppear is only executed when the view first appears. It's not hard to implement all this logic, but it's easy to overlook parts of it, especially if we're doing it for the first time.

Task

02:21 Let's create our own version to see how AsyncImage works. Our view gets an @State property to store the loaded image, along with a plain property for the image's URL. In the view's body, we render the image if it's loaded. Otherwise, we show a loading indicator:

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        if let i = image {
            i
        } else {
            ProgressView()
        }
    }
}

AsyncImage allows us to specify any view to be used as a placeholder while the image is being loaded, but we're fine with hardcoding a ProgressView instead.

03:07 Previously, we'd use onAppear to start loading the image and onChange(of:) to reload the image if the URL property changed:

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        ZStack {
            if let i = image {
                i
            } else {
                ProgressView()
            }
        }
        .onAppear { /*...*/ }
        .onChange(of: url) { /*...*/ }
    }
}

03:28 But there's another new API: the task modifier. Within the task closure, we can use the new async/await features of Swift 5.5, which are available on the newest beta OS versions (iOS 15, macOS 12, tvOS 15, or watchOS 8).

03:51 For example, URLSession now has a method for asynchronously loading data: data(from:). Because this method is async, we have to use the await when calling it. The method can also throw, so we have to use the try/catch syntax to handle an error:

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        ZStack {
            if let i = image {
                i
            } else {
                ProgressView()
            }
        }.task {
            do {
                let (data, _) = try await URLSession.shared.data(from: url)
                // use data
            } catch {
                // handle error
            }
        }
    }
}

04:12 The await keyword signals that our program can be suspended until the asynchronous work is done. The data is loaded on a different thread, and only when this is done does our program continue with the next statement. In other words, we await the loading of the data without blocking the thread.

04:52 After the data is loaded, we check if we can create a UIImage out of it. If we can, we assign the result to the image state variable:

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        ZStack {
            if let i = image {
                i
            } else {
                ProgressView()
            }
        }.task {
            do {
                let (data, _) = try await URLSession.shared.data(from: url)
                if let i = UIImage(data: data) {
                    self.image = Image(uiImage: i)
                }
            } catch {
                // handle error
            }
        }
    }
}

05:28 The task gets executed on the main thread, but the call to data(from:) makes it immediately branch off to another thread to load the image data. Afterward, it resumes back on the main thread.

06:00 If we force the task to sleep for three seconds, we can see that the interface doesn't update until the task finishes:

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        ZStack {
            if let i = image {
                i
            } else {
                ProgressView()
            }
        }.task {
            do {
                sleep(3)
                let (data, _) = try await URLSession.shared.data(from: url)
                if let i = UIImage(data: data) {
                    self.image = Image(uiImage: i)
                }
            } catch {
                // handle error
            }
        }
    }
}

06:15 Let's now try to change the URL to load a different image after the view has appeared:

struct ContentView: View {
    @State var url = URL(string: "https://via.placeholder.com/350x150")!
    var body: some View {
        VStack {
            MyAsyncImage(url: url)
            Button("Change") {
                url = URL(string: "https://via.placeholder.com/250")!
            }
        }
        .padding()
    }
}

06:43 Now we notice that we can't click the button while sleep is blocking the main thread. And there's no effect when we click the button after sleep stops blocking the thread, because we're not responding to changes of the url property.

07:08 By replacing sleep with an async version, we're no longer blocking the main thread:

await Task.sleep(1_000_000_000)

07:40 Unfortunately, this causes a crash, which might be a bug in the current beta. So we write our own version instead:

func mySleep(_ seconds: Int) async {
    sleep(UInt32(seconds))
}

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        ZStack {
            if let i = image {
                i
            } else {
                ProgressView()
            }
        }.task {
            do {
                await mySleep(1)
                let (data, _) = try await URLSession.shared.data(from: url)
                if let i = UIImage(data: data) {
                    self.image = Image(uiImage: i)
                }
            } catch {
                // handle error
            }
        }
    }
}

08:30 Now we're able to click the Change button during the async sleep task — since it runs on a different thread — and the main thread can continue.

Updating the URL

08:56 Next, we want to make the button actually work by loading the updated URL. Just like onAppear, the task we're using to load the image data only gets executed when the view appears. When we modify the url property, the view gets updated, but since it's already visible, an onAppear block or a task isn't executed again.

09:43 Luckily, there's another version of task that takes an id property. If this property changes, the task is considered to be a new one, and it gets executed again:

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        ZStack {
            if let i = image {
                i
            } else {
                ProgressView()
            }
        }.task(id: url) {
            do {
                await mySleep(1)
                let (data, _) = try await URLSession.shared.data(from: url)
                if let i = UIImage(data: data) {
                    self.image = Image(uiImage: i)
                }
            } catch {
                // handle error
            }
        }
    }
}

10:09 Now the image updates after we click the button to change the URL.

Canceling

10:17 When we change the URL, the first task is automatically canceled and a new one is started. If we log the task's isCancelled value and we run the app, we see that the task prints false after the sleep call. But when we click the Change button within the first second, i.e. during the sleep timeout, the first task prints true to the console because it has been canceled:

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        ZStack {
            if let i = image {
                i
            } else {
                ProgressView()
            }
        }.task(id: url) {
            do {
                await mySleep(1)
                print(Task.isCancelled)
                let (data, _) = try await URLSession.shared.data(from: url)
                if let i = UIImage(data: data) {
                    self.image = Image(uiImage: i)
                }
            } catch {
                // handle error
            }
        }
    }
}

10:58 We can react to this cancellation by not loading the image data:

struct MyAsyncImage: View {
    @State private var image: Image?
    var url: URL
    
    var body: some View {
        ZStack {
            if let i = image {
                i
            } else {
                ProgressView()
            }
        }.task(id: url) {
            do {
                await mySleep(1)
                guard !Task.isCancelled else { return }
                let (data, _) = try await URLSession.shared.data(from: url)
                if let i = UIImage(data: data) {
                    self.image = Image(uiImage: i)
                }
            } catch {
                // handle error
            }
        }
    }
}

11:12 Even though the task gets canceled, it doesn't mean that the code stops executing. It's up to us to decide how to deal with cancellation, since the system doesn't know what needs to be done to cancel the task.

11:50 We've now replicated most of AsyncImage, and we learned a little about async/await along the way. We're excited about these new features, and we're also happy that we no longer have to write a view like AsyncImage ourselves.

Resources

  • Sample Code

    Written in Swift 5.3

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

135 Episodes · 48h28min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes