Export SwiftUI views as images in macOS
Helm Pro yearly subscribers now get a 30% discount on RocketSim thanks to contingent pricing on the App Store.
I have recently been working on an app which allows users to create, edit and export QR codes as images.
The app renders QR codes as SwiftUI components and then uses SwiftUI’s ImageRenderer to export them as PNG or JPEG images to the file system.
The app also gives users the option to copy QR codes directly as images to the clipboard, which is something that I always find particularily useful.
Showing an NSSavePanel from SwiftUI
As it stands, SwiftUI does not have a native way to allow the user to save a file to disk from a macOS app using a save panel. However, you can still use AppKit
to create an NSSavePanel
and present it from a SwiftUI view:
private func savePanel(for type: UTType) -> URL? {
let savePanel = NSSavePanel()
savePanel.allowedContentTypes = [type]
savePanel.canCreateDirectories = true
savePanel.isExtensionHidden = false
savePanel.title = "Save the QR Code as an image"
savePanel.message = "Choose a folder and a name to store the image."
savePanel.nameFieldLabel = "Image file name:"
return savePanel.runModal() == .OK ? savePanel.url : nil
}
I want to give a shoutout to this awesome article from Eric Callanan which helped me figure out how to create and show an
NSSavePanel
from a SwiftUI view.
The function above creates an NSSavePanel
, sets the allowed content types, shows the panel to the user using the runModal
method and, if the response is valid, the savePanel
method then returns the URL where the new file should be saved.
You can then use this private function directly from a SwiftUI
view. In my case, I have a ToolbarItem
with a set of MenuButton
s that allow the user to export the QR code as an image in different formats:
struct QRCodeEditor: View {
@ObservedObject var viewModel: QRCodeEditorViewModel
var body: some View {
HStack {
QRCodeCanvas(qrCode: viewModel.qrCode)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
Divider()
Sidebar(qrCode: $viewModel.qrCode)
.frame(idealWidth: 250, maxWidth: 250, maxHeight: .infinity)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
MenuButton(image: "photo", title: "Save as PNG", action: {
// Show panel with PNG as the allowed content type
if let url = savePanel(for: .png) {
viewModel.save(at: url, with: .png)
}
})
MenuButton(image: "photo", title: "Save as JPEG", action: {
// Show panel with PNG as the allowed content type
if let url = savePanel(for: .jpeg) {
viewModel.save(at: url, with: .jpeg)
}
})
MenuButton(image: "doc.on.clipboard", title: "Copy to clipboard", action: { viewModel.copyToClipboard() })
} label: {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
⚠️ It is important to note that you will need to set some read/write capabilities in your target’s configuration to be able to show the save panel and write to the file system. Failing to set these capabilities results in a runtime crash when trying to present an
NSSavePanel
. Please refer to Eric’s article to learn how to do so.
Rendering an image from a SwiftUI view
Now that you have a URL you can save the image to, you can use SwiftUI’s ImageRenderer
to render an image from a SwiftUI view and then write it to said URL:
enum ContentType {
case jpeg
case png
}
@MainActor func save(_ qrCode: QRCodeModel, with contentType: ContentType, at url: URL) {
let qrCodeView = QRCodeCanvas(qrCode: qrCode).frame(width: 1024, height: 1024)
guard let cgImage = ImageRenderer(content: qrCodeView).cgImage else {
return
}
let image = NSImage(cgImage: cgImage, size: .init(width: 1024, height: 1024))
guard let representation = image?.tiffRepresentation else { return }
let imageRepresentation = NSBitmapImageRep(data: representation)
let imageData: Data?
switch contentType {
case .jpeg: imageData = imageRepresentation?.representation(using: .jpeg, properties: [:])
case .png: imageData = imageRepresentation?.representation(using: .png, properties: [:])
}
try? imageData?.write(to: url)
}
Note that if you want to support multiple formats like in the snippet above, you will need to create an NSBitmapImageRep
from the image’s tiffRepresentation
and then use the representation(using:properties:)
method to convert the image to the desired format before writing it to a file URL.
Bonus track: Saving an image to the clipboard
As I mentioned in the introduction, the app also allows users to save the image directly to the clipboard by using a combination of ImageRenderer
and NSPaseteboard
calls:
@MainActor func copyToClipboard(qrCode: QRCodeModel) {
let qrCodeView = QRCodeCanvas(qrCode: qrCode).frame(width: 1024, height: 1024)
guard let cgImage = ImageRenderer(content: qrCodeView).cgImage else {
return
}
let image = NSImage(cgImage: cgImage, size: .init(width: 1024, height: 1024))
let pasteboard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([image])
}
Prior art and considerations
Using ImageRenderer
in SwiftUI is a topic that has been extensively covered in the past. Despite this, I wanted to share my own take on the topic and what worked for me when developing this feature in my app.
Here’s a list of amazing prior art that I used as a reference when developing this feature: