Categories
AppKit ImageIO iOS macOS Swift

CoreGraphics based image resizer for multiplatform apps in Swift

Just a couple of weeks ago I wrote about resizing images on iOS: Resizing UIImages with aspect fill on iOS. As I am currently building a macOS, iOS, watchOS app I realized that I need a multiplatform image resizer. As this app deals with full size photos then I need to resize those photos for avoiding excessive memory usage. Apple’s CoreGraphics framework provides APIs what are compatible with all the before mentioned platforms. Therefore, let’s revisit the image scaler created in Resizing UIImages with aspect fill on iOS and let’s refactor it to use purely multiplatform supported CGImages.

We’ll skip the part which discusses calculating a rectangle in the original image which is then resized for assuring the image is resized equally on both axes. It is important because the aspect ratio of the original image most of the time does not equal to the aspect ratio of the target size. Let’s now take a look at cropping and resizing the original image. Cropping is easy to do because CGImage already has a convenient cropping(to:) method. On the other hand, resizing requires setting up a CGContext with the same parameters as the cropped image but the pixel size must be set to the target size. When we now draw the cropped image to the context, then CoreGraphics takes care of resizing the image.


import CoreGraphics
struct ImageScaler {
static func scaleToFill(_ image: CGImage, from fromRect: CGRect = .zero, in targetSize: CGSize) -> CGImage? {
let imageSize = CGSize(width: image.width, height: image.height)
let rect = fromRect.isEmpty ? CGRect(origin: .zero, size: imageSize) : fromRect
let scaledRect = rect.scaled(toFillSize: targetSize)
return scale(image, fromRect: scaledRect, in: targetSize)
}
private static func scale(_ image: CGImage, fromRect: CGRect = .zero, in targetSize: CGSize) -> CGImage? {
let makeCroppedCGImage: (CGImage) -> CGImage? = { cgImage in
guard !fromRect.isEmpty else { return cgImage }
return cgImage.cropping(to: fromRect)
}
guard let croppedImage = makeCroppedCGImage(image) else { return nil }
let context = CGContext(data: nil,
width: Int(targetSize.width),
height: Int(targetSize.height),
bitsPerComponent: croppedImage.bitsPerComponent,
bytesPerRow: croppedImage.bytesPerRow,
space: croppedImage.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: croppedImage.bitmapInfo.rawValue)
context?.interpolationQuality = .high
context?.draw(croppedImage, in: CGRect(origin: .zero, size: targetSize))
return context?.makeImage()
}
}

I have created a multiplatform sample app which just has a single SwiftUI view shared with iOS and macOS (it is not a Mac Catalyst app). Because we use CGImages then all the code can be shared. The view just loads a full size photo, scales it, and displays it. In the example view we have resized the image by taking account the current displayScale. Depending on the displayScale value we need to make the pixel size of the CGImage larger by the factor of the displayScale. For example, if the point size is 200×200 points, displayScale is 3.0, then the pixel size of the CGImage needs to be 600×600. This will give us a nice and crisp end result when the image is rendered.


struct ContentView: View {
@Environment(\.displayScale) var displayScale: CGFloat
@StateObject var viewModel = ViewModel()
var body: some View {
VStack {
if viewModel.cgImage != nil {
Image(viewModel.cgImage!,
scale: displayScale,
orientation: .up,
label: Text("photo")
)
}
}
.padding()
.onAppear(perform: { viewModel.prepareImage(withScale: displayScale) })
}
}
extension ContentView {
final class ViewModel: ObservableObject {
static let queue = DispatchQueue(label: "com.augmentedcode.imageloader")
@Published var cgImage: CGImage?
func prepareImage(withScale displayScale: CGFloat) {
Self.queue.async {
guard let url = Bundle.main.url(forResource: "ExamplePhoto", withExtension: "jpeg") else { return }
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return }
guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return }
let targetSize = CGSize(width: CGFloat(200.0) * displayScale, height: CGFloat(200.0) * displayScale)
let scaledImage = ImageScaler.scaleToFill(image, in: targetSize)
DispatchQueue.main.async {
self.cgImage = scaledImage
}
}
}
}
}

Summary

CoreGraphics is available on multiple Apple platforms and it provides tools for cropping and resizing images. With just a little bit of refactoring we have turned UIImage based image scaler into CoreGraphics backed scaler which can be used on multiple platforms.

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.

Project

CoreGraphicsImageScaler (Xcode 12.2)

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)