00:06 Today we'll take another look at loading images in SwiftUI, as we've
done from time to time over the years. When SwiftUI was first introduced, we
were still doing a lot of stuff wrong, but when the async APIs were introduced,
we had an episode with an
update about how we could use
them for image loading. Today, we want to take this further and create a
flexible API that gives us the choice of either letting SwiftUI manage the image
loading or taking full control over the lifetime of the image data ourselves.
AsyncImage
00:51 Let's start by using AsyncImage
to display a grid of sample
images we got from Unsplash:
struct ContentView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 100))]) {
ForEach(Photo.sample) { photo in
AsyncImage(url: photo.urls.thumb)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
02:31 We need to make the images resizable to fit them in the grid
cells. We can't call resizable
directly on AsyncImage
; instead, we need to
use the initializer that takes a content
closure. In this closure, we're given
the image view used to display the image, and we can modify it. We also apply
the aspectRatio
modifier so that we don't stretch the images to the shape of
the grid cells:
struct ContentView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 100))]) {
ForEach(Photo.sample) { photo in
AsyncImage(url: photo.urls.thumb, content: {
$0.resizable()
}, placeholder: {
Color.gray
})
.aspectRatio(contentMode: .fit)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

03:53 This isn't the prettiest image gallery, but we aren't focusing on
the UI today. A few episodes ago, we
showed
how to create a photo grid layout similar to the one in the stock Photos app.
04:18 The first question now is: why would we need anything other than
AsyncImage
? For one, we might want to have more control over the image view's
loading state. As the state is completely managed by AsyncImage
, there's no
way for us to easily reset the state or to share the state with multiple views
if we wanted to, for example, show the same loaded image somewhere else in our
app.
05:20 Other reasons for wanting more control could be that we want to
customize how we load data over the network, change the cache settings, or use
our own cache entirely. There have been libraries out there for years — even
from before SwiftUI — that enable us to do these things, examples being
Kingfisher and
SDWebImage. We obviously won't get
to their level of quality, but we'll explore how we could start to build a
reusable and sophisticated API for image loading.
MyAsyncImage
06:00 We start building our own version of AsyncImage
in roughly the
same way as when we reimplemented AsyncImage
using the async/await APIs. The
view takes a URL and a placeholder view builder, and it holds an optional image
as its state:
struct MyAsyncImage<Placeholder: View>: View {
var url: URL
@ViewBuilder var placeholder: Placeholder
@State private var image: Image?
var body: some View {
if let image {
image
} else {
placeholder
}
}
}
08:26 To start loading, we can add a task on the placeholder view. For
now, we just let the shared URLSession
load the data. This gives us back a
tuple of URLResponse
and Data
. We ignore the URLResponse
value, and we try
to convert the data into an NSImage
. If that works, we wrap it in an Image
,
and we assign it to the view's state property:
struct MyAsyncImage<Placeholder: View>: View {
var url: URL
@ViewBuilder var placeholder: Placeholder
@State private var image: Image?
var body: some View {
if let image {
image
} else {
placeholder
.task(id: url) {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let nsImage = NSImage(data: data) else { return }
image = Image(nsImage: nsImage)
} catch {
print(error)
}
}
}
}
}
10:31 We swap out AsyncImage
for MyAsyncImage
in the content view to
see if it works:
struct ContentView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 100))]) {
ForEach(Photo.sample) { photo in
MyAsyncImage(url: photo.urls.thumb, placeholder: {
Color.gray
})
.aspectRatio(contentMode: .fit)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
10:54 It does work, but we've lost the resizable
modifier in the
process, so the images are drawn out of bounds. To bring this modifier back, we
could do the same thing as AsyncImage
and pass the image into a content
closure — using the same API would be good to meet the expectations of our users
— but just to show how it might work, we write a resizable
method directly on
MyAsyncImage
. In this method, we return a copy of self
, and we overwrite a
private property to make the underlying image view resizable:
struct MyAsyncImage<Placeholder: View>: View {
var url: URL
@ViewBuilder var placeholder: Placeholder
private var _resizable = false
@State private var image: Image?
var body: some View {
}
func resizable() -> Self {
var copy = self
copy._resizable = true
return copy
}
}
13:28 We can't conditionally apply the resizable
modifier to the image
view, so we have to return different views for either case:
struct _MyAsyncImage<Placeholder: View>: View {
var url: URL
@ViewBuilder var placeholder: Placeholder
private var _resizable = false
@State private var image: Image?
var body: some View {
if let image {
if _resizable {
image.resizable()
} else {
image
}
} else {
placeholder
.task(id: url) {
}
}
}
}
Task Placement
13:44 There's a mistake in our code. Our intention with using the URL as
the task's identifier was to reload the image when the URL changes. But we
attached the data loading task to the placeholder view, and once the first image
is loaded, the placeholder disappears and the task won't be in our view
hierarchy anymore. Therefore, changing the URL won't do anything.
14:25 So, we need to move the task one level up; outside the if
statement. We also pull the data loading code into a method:
struct MyAsyncImage<Placeholder: View>: View {
var url: URL
@ViewBuilder var placeholder: Placeholder
private var _resizable = false
@State private var image: Image?
var body: some View {
ZStack {
if let image {
if _resizable {
image.resizable()
} else {
image
}
} else {
placeholder
}
}
.task(id: url) {
await load()
}
}
func load() async {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let nsImage = NSImage(data: data) else { return }
image = Image(nsImage: nsImage)
} catch {
print(error)
}
}
func resizable() -> Self {
var copy = self
copy._resizable = true
return copy
}
}
Using an ObservableObject
15:10 Next, we can split MyAsyncImage
into two versions. The first one
will be the same as what we have now: a view that takes a URL and loads the
image automatically. The other version can be used if we want to have more
control, because it lets us pass in any ObservableObject
that retrieves data
from the network. This is more flexible because we control the lifetime and
storage of this object.
16:13 The plan is to factor the task logic out into an
ObservableObject
, which we can use as an @StateObject
in the first version
of the view, and as an @ObservedObject
in the second version:
final class ImageLoader: ObservableObject {
@Published var image: Image?
func load(url: URL) async {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let nsImage = NSImage(data: data) else { return }
image = Image(nsImage: nsImage)
} catch {
print(error)
}
}
}
17:40 This loader object needs an image URL, but we don't want to store
it as a property of the object, because this would mean we'd have to replace the
object each time the URL changes. But if the object is stored as a view's
@StateObject
, it can't be replaced. So instead, we pass the URL in as a
parameter of the load
method.
18:34 In MyAsyncImage
, we replace the image
state property with an
image loader state object:
struct MyAsyncImage<Placeholder: View>: View {
var url: URL
@ViewBuilder var placeholder: Placeholder
private var _resizable = false
@StateObject private var loader = ImageLoader()
var body: some View {
ZStack {
if let image = loader.image {
if _resizable {
image.resizable()
} else {
image
}
} else {
placeholder
.task(id: url) {
await loader.load()
}
}
}
}
func resizable() -> Self {
var copy = self
copy._resizable = true
return copy
}
}
19:45 Next, we create the other, "unmanaged" version of MyAsyncImage
,
which allows us to pass in a loader object from the outside:
struct MyUnmanagedAsyncImage<Placeholder: View>: View {
var url: URL
@ViewBuilder var placeholder: Placeholder
private var _resizable = false
@ObservedObject private var loader: ImageLoader
init(url: URL, loader: ImageLoader, @ViewBuilder placeholder: () -> Placeholder) {
self.url = url
self.placeholder = placeholder()
self.loader = loader
self._resizable = resizable
}
var body: some View {
ZStack {
if let image = loader.image {
if _resizable {
image.resizable()
} else {
image
}
} else {
placeholder
}
}.task(id: url) {
await loader.load(url: url)
}
}
func resizable() -> Self {
var copy = self
copy._resizable = true
return copy
}
}
MyAsyncImage
, which controls the loading itself, can actually use the
unmanaged version under the hood:
struct MyAsyncImage<Placeholder: View>: View {
var url: URL
@ViewBuilder var placeholder: Placeholder
private var _resizable = false
@StateObject private var loader = ImageLoader()
init(url: URL, @ViewBuilder placeholder: () -> Placeholder) {
self.url = url
self.placeholder = placeholder()
}
var body: some View {
MyUnmanagedAsyncImage(url: url, loader: loader, resizable: _resizable, placeholder: { placeholder })
}
func resizable() -> Self {
var copy = self
copy._resizable = true
return copy
}
}
23:19 Before, the load method was inside the view hierarchy, and
therefore implicitly isolated to the MainActor
. But now, we have to be
explicit and add the @MainActor
attribute to ImageLoader
to safely update
the @Published
property:
@MainActor
final class ImageLoader: ObservableObject {
@Published var image: Image?
func load(url: URL) async {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let nsImage = NSImage(data: data) else { return }
image = Image(nsImage: nsImage)
} catch {
print(error)
}
}
}
23:33 The image loading has now been completely factored out of our
view. It's time to come up with an example where MyUnmanagedAsyncImage
can be
used and where it makes sense to store the data outside the view. We'll leave
that for another episode.