How to take a snapshot image of 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)
}
}
}
}
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
}
}
}
}
Now, let’s hit Capture and see what happens.
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
}
}
}
}
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.