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.

Categories
Combine ImageIO iOS Swift SwiftUI

Animating GIFs and APNGs with CGAnimateImageAtURLWithBlock in SwiftUI

This year Apple added CGAnimateImageAtURLWithBlock and CGAnimateImageDataWithBlock for animating GIFs and APNGs on all the platforms to the ImageIO framework. We can pass in URL or data and get callbacks when animation changes the current frame. In Xcode 11 beta 7 implicit bridging to Swift is disabled for those APIs and therefore we need to create a small wrapper around it in Objective-C.

Creating ImageFrameScheduler for managing CGAnimateImageAtURLWithBlock in Objective-C

Calling CGAnimateImageAtURLWithBlock starts the animation immediately. When animation frame changes, the handler block is called with frame index, current animation frame image and stop argument. When setting stop to YES, we can stop the animation. With this in mind we can create ImageFrameScheduler what takes in URL and has methods for starting and stopping the animation. Then we can expose this class to Swift and use it for managing the animation.

#import <CoreGraphics/CoreGraphics.h>
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface ImageFrameScheduler: NSObject
– (instancetype)initWithURL:(NSURL *)imageURL;
@property (readonly) NSURL *imageURL;
– (BOOL)startWithFrameHandler:(void (^)(NSInteger, CGImageRef))handler;
– (void)stop;
@end
NS_ASSUME_NONNULL_END
#import "ImageFrameScheduler.h"
#import "ImageIO/CGImageAnimation.h" // Xcode 11 beta 7 – CGImageAnimation.h is not in umbrella header IOImage.h
@interface ImageFrameScheduler()
@property (readwrite) NSURL *imageURL;
@property (getter=isStopping) BOOL stopping;
@end
@implementation ImageFrameScheduler
– (instancetype)initWithURL:(NSURL *)imageURL {
if (self = [super init]) {
self.imageURL = imageURL;
}
return self;
}
– (BOOL)startWithFrameHandler:(void (^)(NSInteger, CGImageRef))handler {
__weak ImageFrameScheduler *weakSelf = self;
OSStatus status = CGAnimateImageAtURLWithBlock((CFURLRef)self.imageURL, nil, ^(size_t index, CGImageRef _Nonnull image, bool* _Nonnull stop) {
handler(index, image);
*stop = weakSelf.isStopping;
});
// See CGImageAnimationStatus for errors
return status == noErr;
}
– (void)stop {
self.stopping = YES;
}
@end

ImageAnimator conforming to ObservableObject in Swift

When updating views in SwiftUI, we can use ObservableObject protocol and @Published property wrapper what enables SwiftUI to get notified when the ObservableObject changes. This means that we need a model object written in Swift what stores our Objective-C class ImageFrameScheduler and exposes the current animation frame when animation is running. Whenever we update the property internally, property wrapper will take care of notifying SwiftUI to update the view.

import Combine
import UIKit
final class ImageAnimator: ObservableObject {
private let scheduler: ImageFrameScheduler
init(imageURL: URL) {
self.scheduler = ImageFrameScheduler(url: imageURL)
}
@Published var image: CGImage?
func startAnimating() {
let isRunning = scheduler.start { [weak self] (index, image) in
self?.image = image
}
if isRunning == false {
print("Failed animate image at url \(scheduler.imageURL)")
}
}
func stopAnimating() {
scheduler.stop()
}
}

ContentView displaying animation frames in SwiftUI

Integrating ImageAnimator with ContentView is now pretty straight-forward, we check if animation frame image is available and display it. Animation is started when SwiftUI appears and stopped when it disappears.

import SwiftUI
struct ContentView: View {
@ObservedObject var imageAnimator: ImageAnimator
var body: some View {
ZStack {
if imageAnimator.image != nil {
Image(imageAnimator.image!, scale: 1.0, label: Text("Gif"))
}
else {
Text("Paused")
}
}.onAppear {
self.imageAnimator.startAnimating()
}.onDisappear {
self.imageAnimator.stopAnimating()
}
}
}

Summary

Although CGAnimateImageAtURLWithBlock and CGAnimateImageDataWithBlock are not directly usable in Swift, we can get away from it by adding a simple wrapper class in Objective-C. ImageFrameScheduler could be used in non-SwiftUI views by updating UIImageView when frame changes. In SwiftUI, views can use ImageAnimator for storing the current animation frame and using @Published property wrapper for letting SwiftUI view to know when to refresh.

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.

Example Project

AnimateImageData (Xcode 11b7)