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)
} catch {
}
}
}
}
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 {
}
}
}
}
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 {
}
}
}
}
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 {
}
}
}
}
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 {
}
}
}
}
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 {
}
}
}
}
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 {
}
}
}
}
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.