Swift Talk # 327

Async Image: StateObject vs ObservedObject

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 create two versions of our async image implementation for more control over the data's lifetime.

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.

Resources

  • Sample Code

    Written in Swift 5.7

  • 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