Export SwiftUI views as images in macOS

Sponsored

Codemagic logo
Codemagic CI/CD for mobile teams

What do you get when you put love for iOS and DevOps together? Answer: Codemagic CI/CD

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:

QRCodeEditor.swift
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 MenuButtons that allow the user to export the QR code as an image in different formats:

QRCodeEditor.swift
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:

QRCodeEditorViewModel.swift
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:

  1. Save an image to MacOS file system with SwiftUI by Eric Callanan.
  2. How to convert a SwiftUI view to an image by Paul Hudson.
  3. Bringing platform-specific types together in SwiftUI by Daniel Saidi.