Categories
Swift

AsyncPhoto for displaying large photos in SwiftUI

While working on one of my private projects which deals with showing large photos as small thumbnails in a list, I found myself needing something like AsyncImage but for any kind of data sources. AsyncImage looks pretty great, but sad it is limited to loading images from URL. It has building blocks like providing placeholder and progress views. In my case, I needed something where instead of the URL argument, I would have an async closure which returns image data. This would give me enough flexibility for different cases like loading a photo from a file or even doing what AsyncImage is doing, loading image data from a server. I would love to know why Apple decided to go for a narrow use-case of loading images from URL but not for more generic approach. In addition, I would like to pre-define the target image size which allows me to scale the image to smaller size and therefore saving memory usage which would increase a lot when dealing with large photos. Enough talk, let’s jump in.

struct AsyncPhoto<ID, Content, Progress, Placeholder>: View where ID: Equatable, Content: View, Progress: View, Placeholder: View {
@State private var phase: Phase = .loading
let id: ID
let data: (ID) async -> Data?
let scaledSize: CGSize
@ViewBuilder let content: (Image) -> Content
@ViewBuilder let placeholder: () -> Placeholder
@ViewBuilder let progress: () -> Progress
init(id value: ID = "",
scaledSize: CGSize,
data: @escaping (ID) async -> Data?,
content: @escaping (Image) -> Content,
progress: @escaping () -> Progress = { ProgressView() },
placeholder: @escaping () -> Placeholder = { Color.secondary }) {
// …
}

The AsyncPhoto type is a generic over 4 types: ID, Content, Progress, Placeholder. Last three are SwiftUI views and the ID is equatable. This allows us for notifying the AsyncPhoto when to reload the photo by calling the data closure. Basically the same way as the task(id:priority:_:) is working – if the id changes, work item is run again. Since we expect to deal with large photos, we want to scale images before displaying them. Since the idea is that the view does not change the size while it is loading, or displaying a placeholder, we’ll require to pre-define the scaled size. Scaled size is used for creating a thumbnail image and also setting the AsyncPhoto’s frame view modifier to equal to that size. We use a data closure here for giving a full flexibility on how to provide the large image data.

AsyncImage has a separate type AsyncImagePhase for defining different states of the loading process. Since we need to do the same then, let’s add AsyncPhoto.Phase.

extension AsyncPhoto {
enum Phase {
case success(Image)
case loading
case placeholder
}
}

This allows us to use a switch statement in the view body and defining a local state for keeping track of in which phase we currently are. The view body implementation is pretty simple since we use view builders for content, progress and placeholder states. Since we want to have a constant size here, we use the frame modifier and the task view modifier is the one managing scheduling the reload when id changes.

var body: some View {
VStack {
switch phase {
case .success(let image):
content(image)
case .loading:
progress()
case .placeholder:
placeholder()
}
}
.frame(width: scaledSize.width, height: scaledSize.height)
.task(id: id, {
await self.load()
})
}

The load function is updating the phase state and triggering the heavy load of scaling the image.

@MainActor func load() async {
phase = .loading
if let image = await prepareScaledImage() {
phase = .success(image)
}
else {
phase = .placeholder
}
}

The prepareScaledImage is another function which wraps the work of fetching the image data and scaling it.

private func prepareScaledImage() async -> Image? {
guard let photoData = await data(id) else { return nil }
guard let originalImage = UIImage(data: photoData) else { return nil }
let scaledImage = await originalImage.scaled(toFill: scaledSize)
guard let finalImage = await scaledImage.byPreparingForDisplay() else { return nil }
return Image(uiImage: finalImage)
}

I am using an UIImage extension for scaling the image data. The implementation goes like this:

extension UIImage {
func scaled(toFill targetSize: CGSize) async -> UIImage {
let scaler = UIGraphicsImageRenderer(size: targetSize)
let finalImage = scaler.image { context in
let drawRect = size.drawRect(toFill: targetSize)
draw(in: drawRect)
}
return await finalImage.byPreparingForDisplay() ?? finalImage
}
}
private extension CGSize {
func drawRect(toFill targetSize: CGSize) -> CGRect {
let aspectWidth = targetSize.width / width
let aspectHeight = targetSize.height / height
let scale = max(aspectWidth, aspectHeight)
let drawRect = CGRect(x: (targetSize.width – width * scale) / 2.0,
y: (targetSize.height – height * scale) / 2.0,
width: width * scale,
height: height * scale)
return drawRect.integral
}
}

Here is an example of using AsyncPhoto from my test app, where I replaced photos with generated image data.

// Example of returning large image with a constant color for simulating loading a photo.
AsyncPhoto(id: selectedColor,
scaledSize: CGSize(width: 48, height: 48),
data: { selectedColor in
guard let selectedColor else { return nil }
return await Task.detached {
UIImage.filled(size: CGSize(width: 5000, height: 5000),
fillColor: selectedColor).pngData()
}.value
},
content: { image in
image.clipShape(Circle())
},
placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
})

SwiftUIAsyncPhotoExample (GitHub, Xcode 15.0.1)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

One reply on “AsyncPhoto for displaying large photos in SwiftUI”