Oskar Groth
Oskar GrothJune 11, 2022

How to take a snapshot image of a view in SwiftUI

Learn how to use the new ImageRenderer to take a screenshot image of a SwiftUI view, and which fallbacks you can use for older versions.
Screenshot a view in SwiftUI

ImageRenderer in SwiftUI 4

At WWDC 2022, Apple introduced the new ImageRenderer API in SwiftUI 4. You can now easily create an image from a view in SwiftUI. Let’s try it out.

First, let’s create a simple view that we want to save as an image. We’ll create a test view that presents some basic SwiftUI content using shapes, colors, text, and SF Symbols.

struct TestView: View {
    var body: some View {
        ZStack {
            Rectangle().fill(Color(red: 4/255, green: 5/255, blue: 15/255).gradient)
            VStack {
                Image(systemName: "photo")
                    .font(.system(size: 80))
                    .background(in: Circle().inset(by: -40))
                    .backgroundStyle(.blue.gradient)
                    .foregroundStyle(.white.shadow(.drop(radius: 1, y: 1.5)))
                    .padding(60)
                Text("Hello, world!")
                    .font(.largeTitle)
            }
        }
    }
}
Screenshot Test View

Now, let’s try to take a capture of our view using the new ImageRenderer. We must first initialise our ImageRenderer by passing it the view we want to render: ImageRenderer(content: <View>). We can then access the image via the property .nsImage (or .uiImage on iOS).

It's important to note that ImageRenderer is not a view or a view modifier, so we must call it inside of some sort of action block or other part of our code.

Let’s try it out by constructing a content view that can display our view and the captured image side by side.

struct ContentView: View {
    @State var capture: NSImage?
 
    var body: some View {
        HStack {
            ZStack(alignment: .bottom) {
                TestView()
                Button("Capture", action: {
                    capture = ImageRenderer(content: TestView()).nsImage
                })
                .padding()
            }
            if let image = capture {
                Image(nsImage: image)
            } else {
                Color.clear
            }
        }
    }
}
ImageRenderer Test

Now, let’s hit Capture and see what happens.

ImageRenderer Test First

It worked! Sort of… Our capture is not an identical screenshot of the original view. The reason for this is that we are creating a separate, completely new TestView() when passing it as a parameter to ImageRenderer in the button action block. Because we are no longer declaring it in the scope of a SwiftUI body, it is disconnected from the whole SwiftUI layout system. Our captured view exists in it’s own context, and has no parent to receive layout or environment information from.

There is no way around this, but we can resolve some of the issues. For example, notice how the captured view is rendering the font with a black color. This is because it has no knowledge about the surrounding environment data, including the color scheme passed down by our app. But we can manually inject the missing environment value for the color scheme.

struct ContentView: View {
    @Environment(\.colorScheme) var colorScheme
    @State var capture: NSImage?
 
    var body: some View {
        HStack {
            ZStack(alignment: .bottom) {
                TestView()
                Button("Capture", action: {
                    capture = ImageRenderer(content: TestView().environment(\.colorScheme, colorScheme)).nsImage
                })
                .padding()
            }
            if let image = capture {
                Image(nsImage: image)
            } else {
                Color.clear
            }
        }
    }
}
ImageRenderer Test Final

That's a bit better! We could further improve this by applying explicit view sizes as well, depending on your use case.

The most important takeaway here is that ImageRenderer is not perfect out of the box. If your view depends on a lot of other external data (for example, an @EnvironmentObject), you will want to ensure that your captured view receives all the necessary instances of these to render correctly.

Fallbacks for older versions

ImageRenderer requires SwiftUI 4, which is only available on macOS 13 and iOS/iPadOS 16. If you still need to support older versions, we’ve got you covered! We’ve included alternatives for AppKit and UIKit below that will work in a simlar way to ImageRenderer on SwiftUI 2 and 3.

Note that the same recommendations regarding environment/layout data applies for these solutions as well.

AppKit

These extensions will allow you to call SomeView().snapshot() to create an image from a view in Mac apps.

extension View {
    func snapshot() -> NSImage? {
        let view = NoInsetHostingView(rootView: self)
        view.setFrameSize(view.fittingSize)
        return view.bitmapImage()
    }
}
 
/* We're not exactly sure why this is needed, but with just a normal
   NSHostingView the image has undesired insets, and this fixes it. */
class NoInsetHostingView<V>: NSHostingView<V> where V: View {
    override var safeAreaInsets: NSEdgeInsets {
        return .init()
    }
}
 
extension NSView {
    func bitmapImage() -> NSImage? {
        guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else {
            return nil
        }
        cacheDisplay(in: bounds, to: rep)
        guard let cgImage = rep.cgImage else {
            return nil
        }
        return NSImage(cgImage: cgImage, size: bounds.size)
    }
}

UIKit

This extension will allow you to call SomeView().snapshot() to create an image from a view in iOS apps.

extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self)
        let view = controller.view
 
        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear
 
        let renderer = UIGraphicsImageRenderer(size: targetSize)
 
        return renderer.image { _ in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

That was a quick look at how to take a screenshot image capture of a SwiftUI view with the new ImageRenderer, as well as using fallbacks for older versions.

Thanks for reading, and be sure to check out our other SwiftUI articles.