How to render a SwiftUI view to an image or a PDF


Greetings, traveler!

SwiftUI offers a great tool to render your views into images or PDFs. I am talking about ImageRenderer.

Image

Let’s take a closer look at some examples then. Frist, let’s render an image. Create an extension for View to use ImageRenerer. Here, we should provide a display scale. To this, we can use a SwiftUI environment value displayScale.

extension View {
    @MainActor
    func renderImage(scale displayScale: CGFloat) -> UIImage? {
        let renderer = ImageRenderer(content: self)
        renderer.scale = displayScale
        
        return renderer.uiImage
    }
}

Then, let’s create a class to manage image rendering. To save an image to the pasteboard, we can create such a method:

@MainActor
final class ImageSaver {
    
    func saveToPasteboard(view: some View, scale: CGFloat) {
        guard let uiImage = view.renderImage(scale: scale) else { return }
        
        UIPasteboard.general.image = uiImage
    }
    
}

To save an image to the Photo Album, we can use the UIImageWriteToSavedPhotosAlbum function. To use its completion, we can create a separate method:

@MainActor
final class ImageSaver {
        
    private var completion: (() -> Void)?
    
    func saveToPhotoAlbum(view: some View, scale: CGFloat, _ completion: (() -> Void)?) {
        guard let uiImage = view.renderImage(scale: scale) else { return }
        
        self.completion = completion
        UIImageWriteToSavedPhotosAlbum(uiImage, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil)
    }
    
    @objc
    private func image(_ image: UIImage, didFinishSavingWithError error: NSError?, contextInfo: UnsafeRawPointer) {
        if let error {
            print(error.localizedDescription)
        }
        completion?()
    }
}

Usage

Let’s try it out!

struct ContentView: View {
    
    @Environment(\.displayScale) private var displayScale
    private let imageSaver = ImageSaver()
    
    var body: some View {
        Button(action: renderImage) {
            Text("Render Image")
        }
    }

    private func renderImage() {
        imageSaver.saveToPasteboard(view: body, scale: displayScale)
        
        imageSaver.saveToPhotoAlbum(view: body, scale: displayScale) {
            print("finished")
        }
    }
    
}

PDF

Now, let’s deal with PDFs. First, we should provide the view. The next step is to create a URL to store a file. Then, we can render an image and use a CGContext to generate the PDF page. As the last step, we can end the page and close the PDF file.

extension View {
    func renderPDF(scale displayScale: CGFloat) -> URL {
        let renderer = ImageRenderer(content: self)
        renderer.scale = displayScale
        
        let url = URL.documentsDirectory.appending(path: "myPDF.pdf")
        
        renderer.render { size, context in
            var container = CGRect(
                x: .zero,
                y: .zero,
                width: size.width,
                height: size.height
            )
            
            guard let pdf = CGContext(url as CFURL, mediaBox: &container, nil) else { return }
            
            pdf.beginPDFPage(nil)
            context(pdf)
            
            pdf.endPDFPage()
            pdf.closePDF()
        }
        
        return url
    }
}

Now, let’s create the PDFSaver class.

@MainActor
final class PDFSaver {
    func saveToDocuments(view: some View, scale: CGFloat) {
        let url = view.renderPDF(scale: scale)
        print(url)
    }
}

Usage

Let’s test it!

struct ContentView: View {
    
    @Environment(\.displayScale) private var displayScale
    private let pdfSaver = PDFSaver()
    
    var body: some View {
        Button(action: renderPDF) {
            Text("Render PDF")
        }
    }

    private func renderPDF() {
        pdfSaver.saveToDocuments(view: body, scale: displayScale)
    
}

Limitations

We can’t use view components made with UIKit, and we cannot use views created with the UIViewRepresentable protocol.

struct LabelView: UIViewRepresentable {
    var text: String

    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.text = text
        label.textAlignment = .center
        label.font = UIFont.systemFont(ofSize: 24)
        return label
    }

    func updateUIView(_ uiView: UILabel, context: Context) {
        uiView.text = text
    }
}

If we do, we will gain a yellow placeholder with the 🚫 mark instead of the desired view.

struct ContentView: View {
    
    @Environment(\.displayScale) private var displayScale
    private let imageSaver = ImageSaver()
    
    var body: some View {
        Button(action: renderImage) { // Normal rendering ✅ 
            Text("Render Image")
        }
        
        LabelView(text: "Some text") // Renders as a 🚫
    }

    private func renderImage() {
        imageSaver.saveToPasteboard(view: body, scale: displayScale)
    }
    
}