Using the SwiftUI 4 ImageRenderer

Jun 20, 2022 · Follow on Twitter and Mastodon

SwiftUI 4 introduces a new ImageRenderer that can be used to render any SwiftUI view as an image in iOS 16, macOS 13, tvOS 16 & watchOS 9. Let’s take a look at how it works.

Using the ImageRenderer

The SwiftUI ImageRenderer takes any view as input and outputs a UIImage on iOS, tvOS and watchOS, and an NSImage on macOS.

Using the renderer is very easy and can look something like this:

struct ContentView: View {

    @State
    var snapshot: UIImage?

    var body: some View {
        VStack(spacing: 20) {
            viewToSnapshot("Original")
            if let image = snapshot {
                Image(uiImage: image)
            }
            Button(action: generateSnapshot) {
                Text("Create snapshot")
            }
            .buttonStyle(.bordered)
        }
    }
}

extension ContentView {
    
    func viewToSnapshot(_ title: String) -> some View {
        VStack(spacing: 5) {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text(title)
        }
    }

    func generateSnapshot() {
        Task {
            let renderer = await ImageRenderer(
                content: viewToSnapshot("ImageRenderer"))
            if let image = await renderer.uiImage {
                self.snapshot = image
            }
        }
    }
}

The code is pretty straightforward. We show a viewToSnapshot together with a button that takes a snapshot of the same view, then shows it below the original view.

We also have to wrap the rendering in a Task, since the ImageRenderer initializer and its uiImage property are both async and must be called using await.

I’m still struggling with using async functionality in SwiftUI, so if you know of a better way to do this, I’d love to hear it.

If we now the code above and tap the button, the generated snapshot image looks like this:

A screenshot that shows an original SwiftUI view and a low-resolution snapshot

As you can see, the snapshot is quite blurry. This is because the renderer uses a rendering scale of 1 by default, while the device has a retina screen resolution or greater.

Improving the snapshot resolution

To fix the snapshot resolution, we can specify a scale for the renderer, but this is where the async started biting me. Defining a scale in generateSnapshot doesn’t work:

extension ContentView {

    func generateSnapshot() {
        Task {
            let renderer = await ImageRenderer(
                content: viewToSnapshot("ImageRenderer"))
            renderer.scale = await UIScreen.main.scale  // <-- This doesn't work
            if let image = await renderer.uiImage {
                self.snapshot = image
            }
        }
    }
}

If we try to specify a scale as above, we get the error Property 'scale' isolated to global actor 'MainActor' can not be mutated from a non-isolated context.

To fix this, we can move the rendering to a @MainActor annotated extension:

extension ContentView {

    func generateSnapshot() {
        Task {
            await generateSnapshotAsync()
        }
    }
}

@MainActor
extension ContentView {

    func generateSnapshotAsync() async {
        let renderer = ImageRenderer(content: viewToSnapshot("ImageRenderer"))
        renderer.scale = UIScreen.main.scale
        if let image = renderer.uiImage {
            self.snapshot = image
        }
    }
}

This allows us to remove the await from the code and just having to use a single await when we call the main actor function.

We can now run the app again and see that the result is much sharper:

A screenshot that shows an original SwiftUI view and a high-resolution snapshot

Just note that watchOS has no access to UIScreen.scale and that must to use nsImage and NSScreen.main?.backingScaleFactor on macOS.

I’m still struggling with using async functionality in SwiftUI, so if you know of a better way to do this, I’d love to hear it and will update the post with any new information I find.

Simplifying the code

I find the @MainActor annotation quite messy. To simplify things, I therefore created two convenience initializers that let me create an image renderer in a more convenient way:

import SwiftUI

@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
public extension ImageRenderer {

    @MainActor
    convenience init(content: Content, scale: CGFloat) {
        self.init(content: content)
        self.scale = scale
    }

    #if os(iOS) || os(macOS) || os(tvOS)
    @MainActor
    convenience init(contentWithScreenScale content: Content) {
        #if os(iOS) || os(tvOS)
        let scale = UIScreen.main.scale
        #elseif os(macOS)
        let scale = NSScreen.main?.backingScaleFactor ?? 2
        #endif

        self.init(content: content, scale: scale)
    }
    #endif
}

The first lets us provide the image renderer with a custom scale, while the second lets us omit the scale altogether and use the screen resolution on iOS, macOS and tvOS.

Since these two initializers allow us to specify a scale without modifying the renderer, we no longer have to perform the rendering operation within a main actor annotated extension.

This means that we can put all the rendering code back into the snapshot button action:

extension ContentView {

    func generateSnapshot() {
        Task {
            let renderer = await ImageRenderer(
                contentWithScreenScale: viewToSnapshot("ImageRenderer"))
            if let image = await renderer.uiImage {
                self.snapshot = image
            }
        }
    }
}

To avoid having to use uiImage in UIKit (iOS, tvOS & watchOS) and nsImage in AppKit (macOS), we can create a bridging typealias and extend the image renderer to return a platform-agnostic image.

Here’s an ImageRepresentable that resolves to UIImage in UIKit and NSImage in AppKit:

#if os(macOS)
import class AppKit.NSImage

public typealias ImageRepresentable = NSImage
#endif

#if os(iOS) || os(tvOS) || os(watchOS)
import class UIKit.UIImage

public typealias ImageRepresentable = UIImage
#endif

We can now extend the ImageRenderer with a platform-agnostic image property:

extension ImageRenderer {

    @MainActor
    var image: ImageRepresentable? {
        #if os(macOS)
        return nsImage
        #else
        return uiImage
        #endif
    }
}

With this in, we can use the same code to generate an image, regardless of which platform we’re on. The only limitation is that we can’t use the resolution-based init in watchOS.

Conclusion

The new SwiftUI ImageRenderer is great for rendering snapshots of any view. It’s available for iOS 16.0, macOS 13.0, tvOS 16.0 & watchOS 9.0 and can be tested in Xcode 14 beta.

I’m currently struggling with async in SwiftUI. I have therefore added a few ImageRenderer extensions to SwiftUIKit. Feel free to try them out and let me know what you think.

Discussions & More

If you found this interesting and would like to share your thoughts, please comment in the Disqus section below or reply to this tweet.

Follow on Twitter and Mastodon to be notified when new content & articles are published.