SwiftUI ImageRenderer is the framework's official way to convert any SwiftUI View into a UIImage, NSImage, CGImage, or PDF data at runtime, running on the main actor and using the exact same layout engine that draws your view on screen. Introduced in iOS 16 and materially improved through iOS 26, it replaced a decade of UIGraphicsImageRenderer and drawHierarchy hacks with a single Observable object. Honestly, I reach for it constantly (receipts, share cards, ticket exports, quick screenshots for support tickets) and it works on iPhone, iPad, Mac, and visionOS with only one platform-conditional line.
ImageRenderer is a SwiftUI-first API that snapshots any View to UIImage, NSImage, CGImage, or PDF on the main actor.
Always set renderer.scale = displayScale from the environment. The default is 1.0, which is why most first-time output looks blurry on Retina.
Use renderer.render { size, context in ... } with a CGContext for PDF, TIFF, or custom Core Graphics pipelines.
ImageRenderer is @Observable: mutating content, scale, or proposedSize re-renders automatically when observed inside a SwiftUI view.
On visionOS the renderer produces flat 2D images, not stereoscopic captures. For immersive content you still need RealityKit capture APIs.
ShareLink(item:preview:) composes cleanly with an ImageRenderer-produced Image for one-tap sharing and AirDrop.
What is SwiftUI ImageRenderer?
ImageRenderer is an @Observable class in the SwiftUI module whose job is to lay out and rasterize a SwiftUI view tree off-screen. You give it a content: some View, optionally set a proposedSize, scale, and colorMode, and then read one of its output properties: uiImage on iOS, tvOS, and Mac Catalyst; nsImage on macOS; cgImage everywhere; or you can render into your own CGContext for PDF and TIFF.
Under the hood it drives the same layout pass and rendering pipeline as a real on-screen SwiftUI hierarchy, which is why modifiers like .background, .shadow, .mask, SF Symbols, and even Canvas all reproduce faithfully. The catch, and this is stated plainly in the official Apple documentation, is that the class is main-actor bound. You cannot instantiate it on a background task, and you cannot read uiImage off the main actor. In iOS 26 the compiler enforces this with a Swift 6 error, not a warning, so pre-existing code that spun rendering off to Task.detached will refuse to build. I hit this exact wall migrating one of my apps and ended up rewriting three export helpers in an afternoon.
ImageRenderer is not the same thing as a UIView snapshot. It works on the SwiftUI graph directly, meaning the view you pass in does not need to be inserted anywhere. That means you can render a completely virtual "share card" that the user never sees, which is how most social-sharing features are actually built today. If you are new to Swift's actor model, the background on isolation in the Swift 6.2 approachable concurrency guide is worth a skim before you go further.
How do I convert a SwiftUI View to a UIImage?
The minimum viable path is three lines. Given any view, wrap it in ImageRenderer(content:) and read uiImage:
import SwiftUI
@MainActor
func snapshot<V: View>(_ view: V) -> UIImage? {
let renderer = ImageRenderer(content: view)
renderer.scale = UIScreen.main.scale // see next section, prefer @Environment
return renderer.uiImage
}
That works, but in real code you almost always want the renderer inside a SwiftUI view so that the display scale comes from the environment rather than a static UIScreen call (which is deprecated in scene-based apps and returns 1.0 in a lot of contexts, including previews and share extensions). Here's the pattern I ship:
import SwiftUI
struct ShareCardView: View {
@Environment(\.displayScale) private var displayScale
let title: String
let subtitle: String
var body: some View {
VStack(spacing: 8) {
Button("Export as PNG") { export() }
Preview(title: title, subtitle: subtitle)
.frame(width: 320, height: 180)
}
}
@MainActor
private func export() {
let renderer = ImageRenderer(
content: Preview(title: title, subtitle: subtitle)
.frame(width: 320, height: 180)
)
renderer.scale = displayScale
renderer.isOpaque = false // preserve transparent background
renderer.colorMode = .extendedLinear // wide-gamut P3 output
guard let uiImage = renderer.uiImage,
let data = uiImage.pngData() else { return }
let url = URL.documentsDirectory.appending(path: "share.png")
try? data.write(to: url)
}
struct Preview: View {
let title, subtitle: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.title.bold())
Text(subtitle).font(.subheadline)
}
.padding()
.background(.regularMaterial, in: .rect(cornerRadius: 16))
}
}
}
Three things worth noting. First, I re-create the Preview inside the export closure rather than trying to snapshot the on-screen instance. That's the recommended pattern because ImageRenderer owns its own layout pass and does not attach to an existing hierarchy. Second, isOpaque = false matters if you use materials, glass, or anything with alpha; the default is true and paints a black background under transparent regions. Third, colorMode = .extendedLinear gives you wide-gamut P3. The default .nonLinear is sRGB and will visibly desaturate content that already lives in Display P3 (Metal shaders, MeshGradient, HDR photos). If your export card uses the new Liquid Glass materials, my write-up on building a reusable glass effect component in SwiftUI shows how the alpha and opacity flags line up.
Why is my ImageRenderer output blurry (and how to fix it)
The single most common bug report I see is "the rendered image looks pixelated on a Retina device." The cause is always the same: ImageRenderer.scale defaults to 1.0, meaning a 320-point view renders as a 320-pixel bitmap. On a 3× iPhone that image is then upscaled 3× when you display it, which is exactly what pixelation looks like.
The fix is one line. Read displayScale from the environment and assign it to the renderer:
@Environment(\.displayScale) private var displayScale
let renderer = ImageRenderer(content: myView)
renderer.scale = displayScale // 2.0 on @2x devices, 3.0 on @3x
A subtler variant of the same bug happens in Xcode Previews and share extensions, where displayScale can legitimately be 1.0 because there is no attached screen. For share sheets that want a specific pixel count regardless of device (a common Open Graph 1200×630 requirement, say), skip the environment entirely and compute a scale from a target pixel size:
let targetPixels = CGSize(width: 1200, height: 630)
let logicalSize = CGSize(width: 600, height: 315) // your view's frame
let renderer = ImageRenderer(content: cardView.frame(width: 600, height: 315))
renderer.proposedSize = ProposedViewSize(logicalSize)
renderer.scale = targetPixels.width / logicalSize.width // 2.0
proposedSize is worth its own note: it tells the layout engine what size to propose to the root of the tree, exactly the way a parent view would. Views that use .frame(maxWidth: .infinity) or GeometryReader will size against this proposal. Without it, an unconstrained view can render at its intrinsic size, which for a Text with wrapping usually means "one line, however long the string is."
Exporting a SwiftUI View as a multi-page PDF
ImageRenderer also exposes a lower-level render(rasterizationScale:renderer:) method that hands you a CGSize and a CGContext. That's the entry point for PDF, TIFF, or anything else CGContext can draw into. For a single-page PDF:
import SwiftUI
import UniformTypeIdentifiers
@MainActor
func exportPDF<V: View>(_ view: V, to url: URL) throws {
let renderer = ImageRenderer(content: view)
renderer.render { size, drawInContext in
var mediaBox = CGRect(origin: .zero, size: size)
guard let consumer = CGDataConsumer(url: url as CFURL),
let pdfContext = CGContext(consumer: consumer,
mediaBox: &mediaBox,
nil) else { return }
pdfContext.beginPDFPage(nil)
drawInContext(pdfContext)
pdfContext.endPDFPage()
pdfContext.closePDF()
}
}
For multi-page output (invoices, reports, an itinerary), call ImageRenderer once per page and reuse the same CGContext. Because ImageRenderer is @Observable, you can also mutate its content in a loop:
@MainActor
func exportInvoicePDF(pages: [InvoicePage], to url: URL) throws {
guard let consumer = CGDataConsumer(url: url as CFURL) else { return }
var mediaBox = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter
guard let pdf = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return }
let renderer = ImageRenderer(content: EmptyView())
renderer.proposedSize = ProposedViewSize(width: 612, height: 792)
for page in pages {
renderer.content = AnyView(InvoicePageView(page: page)
.frame(width: 612, height: 792))
renderer.render { _, draw in
pdf.beginPDFPage(nil)
draw(pdf)
pdf.endPDFPage()
}
}
pdf.closePDF()
}
If you need finer control over the PDF metadata (title, author, creator string), pass an auxiliaryInfo dictionary to CGContext(consumer:mediaBox:_:). Apple's Quartz 2D PDF documentation covers the full list, but in practice the useful keys are kCGPDFContextTitle, kCGPDFContextAuthor, and kCGPDFContextCreator.
Saving to disk, Photos, and ShareLink integration
Once you have a UIImage, the ecosystem is well-worn. To save to the user's Photos library you go through PHPhotoLibrary. The API hasn't changed materially since iOS 14, and I covered the permissions flow in the SwiftUI PhotosPicker iOS 26 guide. To save to a file, write pngData() or jpegData(compressionQuality:) to a URL in the app's documents directory.
The cleanest integration in 2026 is ShareLink. It accepts anything Transferable, and both Image (SwiftUI) and UIImage conform through a conditional conformance. That means one-tap sharing looks like:
struct ExportButton: View {
@Environment(\.displayScale) private var displayScale
let cardView: some View
var body: some View {
let renderer = ImageRenderer(content: cardView)
let _ = { renderer.scale = displayScale }()
if let uiImage = renderer.uiImage {
ShareLink(
item: Image(uiImage: uiImage),
preview: SharePreview("My Card", image: Image(uiImage: uiImage))
)
}
}
}
The SharePreview is what shows up in the share sheet header. Omit it and iOS uses a generic placeholder that looks unfinished. If you want to support drag-and-drop, wrap the image in a Transferable-conforming type; the mechanics are identical to the ones in the SwiftUI Transferable protocol complete guide. The upshot is that ImageRenderer plus Transferable gives you drag, drop, copy, paste, share, AirDrop, and Universal Clipboard from a single source view, without ever writing a bridging UIViewController.
ImageRenderer on iPhone, iPad, Mac Catalyst, macOS, and visionOS
This is where cross-platform posts usually wave their hands. Here's what actually differs, having shipped this on all five.
Platform
Primary output
displayScale default
Notes
iOS / iPadOS 26
uiImage
2.0 or 3.0
Full support. Materials, MeshGradient, Metal shaders all render.
Mac Catalyst 26
uiImage
2.0
Same code as iOS. AppKit-native features unavailable.
macOS 26
nsImage
2.0 on Retina
Use nsImage, not uiImage. Otherwise identical.
visionOS 26
uiImage
2.0
Flat 2D snapshot only. No stereoscopic capture. Materials render as their fallback appearance.
tvOS 26
uiImage
1.0 (rendered)
Rare use case; works but no user-facing share sheet.
The uiImage versus nsImage split is the only real friction. I keep a one-liner alias so the calling code stays platform-agnostic:
On visionOS the surprise is that Material.regular, .glassBackgroundEffect(), and the whole Liquid Glass family render as their fallback appearance (a solid color, not a translucent blur). ImageRenderer is a 2D compositor; the glass effects are compositor-level in the RealityKit scene graph. If you need a screenshot that includes the glass, you have to use the RealityKit capture APIs instead, which I mentioned in the SwiftUI Canvas and TimelineView graphics guide. This trips up a lot of people migrating from iPad to visionOS.
On Mac Catalyst you get a UIImage even though the app is otherwise AppKit-hosted. If you need to hand the image to an AppKit API (rare in Catalyst but possible), convert via UIImage.cgImage and reconstruct an NSImage from that.
Performance tips and common pitfalls
A few things that will bite you at scale.
1. Rendering in a body computed property will loop
Because ImageRenderer is @Observable, reading uiImage inside a SwiftUI body subscribes the view to the renderer's outputs. If body then triggers a re-render (say, by producing a new Image that causes a layout change), you get an infinite render loop. Compute the image in an action closure (Button tap, .task, .onAppear), or in a separate @State-cached function, not inline in body. Ask me how I know.
2. It runs on the main actor, so respect it
Every call to renderer.uiImage blocks the main thread while the layout pass runs. For a small share card that's fine, a millisecond or two. For a 30-page PDF export, it will drop frames and can freeze your UI for a second or more. Show a ProgressView and, if the pages are independent, chunk the work: await Task.yield() between pages so the run loop drains.
3. Wide-gamut costs memory
colorMode = .extendedLinear doubles the per-pixel memory cost (16-bit half-float per channel vs 8-bit). A 3000×2000 pixel wide-gamut render is roughly 48 MB. If you're rendering thumbnails, stay on .nonLinear.
4. Fonts and Dynamic Type
ImageRenderer honors the Environment's sizeCategory and locale unless you override them. If your rendered image is shared out of app (an AirDrop or a save-to-file), you almost certainly want a fixed layout, not one that changes with the recipient's accessibility settings. Set .environment(\.sizeCategory, .large) on the view you hand to ImageRenderer to pin the output.
5. Layout is not always the same as on-screen
A rendered view that reads @Environment values (safe area, size class, dynamic type) will see the environment you provide, which by default is the enclosing view's environment when ImageRenderer is constructed inside a SwiftUI body, and an empty environment when constructed elsewhere. If your card looks fine on iPhone but the shared PNG has the wrong dark-mode appearance, inject the colorScheme explicitly:
let renderer = ImageRenderer(content: card.environment(\.colorScheme, .dark))
That single line has saved me hours across multiple apps.
Frequently Asked Questions
How do I convert a SwiftUI View to a UIImage without ImageRenderer?
You can wrap the view in a UIHostingController, attach it to a temporary UIWindow, and call drawHierarchy(in:afterScreenUpdates:). It works on iOS 15 and earlier but is significantly slower, requires the main thread, and has to briefly display the view. On iOS 16+ there is no reason to use it over ImageRenderer.
Why is ImageRenderer output pixelated on Retina devices?
The renderer's scale property defaults to 1.0. Read @Environment(\.displayScale) and assign it to renderer.scale. On a 3× iPhone that produces a 3× bitmap that displays at native resolution instead of being upscaled from 1×.
Can ImageRenderer produce a PDF instead of an image?
Yes. Use the render(rasterizationScale:renderer:) closure form, which hands you a CGContext. Create a CGContext backed by a CGDataConsumer, call beginPDFPage, invoke the closure, and call endPDFPage. Text and SF Symbols stay vector.
Does ImageRenderer work on macOS and visionOS?
Yes on both. On macOS, read nsImage instead of uiImage. On visionOS, the renderer produces flat 2D output, and Liquid Glass and material effects render as their solid fallback color rather than a translucent blur, because those effects live in the RealityKit compositor.
Is ImageRenderer thread-safe?
No. ImageRenderer is @MainActor-isolated. Construction, mutation, and reading output properties all have to happen on the main actor. In Swift 6 (Xcode 26) this is a compile error if you try to violate it from a background task.
A practical guide to Apple's unified logging in Swift for iOS 26: how Logger replaces print, when to use each log level, how privacy annotations protect PII, and how OSLogStore and OSSignposter power in-app diagnostics and performance tracing.
SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.
Profile SwiftUI apps with Xcode Instruments in Xcode 26. A real-device workflow for finding hangs, frame drops, and memory leaks, plus custom signposts with OSSignposter.