[SOLVED] SwiftUI ScrollView to PDF – Stack Overflow

Issue

This Content is from Stack Overflow. Question asked by user17864647

Currently, I am using this code to create a PDF, but it only creates a PDF of the part of the page the user is looking at, and not the entire scroll view. How can I create a multi-page PDF to get the entire scroll view?

func exportToPDF(width:CGFloat, height:CGFloat, airplane: String, completion: @escaping (String) -> Void) {
          let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
          let outputFileURL = documentDirectory.appendingPathComponent("smartsheet-(airplane.afterchar(first: "N")).pdf")
          //Normal with
          let width: CGFloat = width
          //Estimate the height of your view
          let height: CGFloat = height
    let charts = SmartSheet().environmentObject(Variables())
          let pdfVC = UIHostingController(rootView: charts)
          pdfVC.view.frame = CGRect(x: 0, y: 0, width: width, height: height)
          //Render the view behind all other views
          let rootVC = UIApplication.shared.windows.first?.rootViewController
          rootVC?.addChild(pdfVC)
          rootVC?.view.insertSubview(pdfVC.view, at: 0)
          //Render the PDF

          let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: width, height: height))
          DispatchQueue.main.async {
              do {
                  try pdfRenderer.writePDF(to: outputFileURL, withActions: { (context) in
                      context.beginPage()
                      rootVC?.view.layer.render(in: context.cgContext)
                  })
                  UserDefaults.standard.set(outputFileURL, forKey: "pdf")
                  UserDefaults.standard.synchronize()
                  print("wrote file to: (outputFileURL.path)")
                completion(outputFileURL.path)
              } catch {
                  print("Could not create PDF file: (error.localizedDescription)")
              }

          pdfVC.removeFromParent()
          pdfVC.view.removeFromSuperview()
      }
  }



Solution

I just posted this answer to another question as well.
https://stackoverflow.com/a/74033480/14076779

After attempting a number of solutions to creating a pdf from a SwiftUI ScrollView and landed on this implementation. It is by no means an implementation that is pretty, but it seems to work.

M use case is that I present a modal to capture a user’s signature and when the signature is collected, the content of the current screen is captured. There is no pagination, just a capture of the content currently rendered to the width of the screen and as much length as is required by the view.

The content consists of an array of cards that are dynamically generated, so the view has no awareness of the content and cannot calculate the height. I have not tested on a list, but I imagine it would provide similar results.

struct SummaryTabView: View {

// MARK: View Model
@ObservedObject var viewModel: SummaryTabViewModel

// MARK: States and Bindings
@State private var contentSize: CGSize = .zero // Updated when the size preference key for the overlay changes
@State private var contentSizeForPDF: CGSize = .zero // Set to match the content size when the signature modal is displayed.

// MARK: Initialization
init(viewModel: SummaryTabViewModel) {

    self.viewModel = viewModel
}

// MARK: View Body
var body: some View {

    ScrollView {
        content
            .background(Color.gray)
        // Placing the a geometry reader in the overlay here seems to work better than doing so in a background and updates the size value for the size preference key.
            .overlay(
                GeometryReader { proxy in
                    Color.clear.preference(key: SizePreferenceKey.self,
                                           value: proxy.size)
                }
            )
    }
    .onPreferenceChange(SizePreferenceKey.self) {
        // This keeps the content size up-to-date during changes to the content including rotating.
        contentSize = $0
    }

    // There are issues with the sizing the content when the orientation changes and a modal view is open such as when collecting the signature.  If the iPad is rotated when the modal is open, then the content size is not being set properly.  To avoid this, when the user opens the modal for signing, the content size for PDF generation is set to the current value since the signature placeholder is already present so the content size will not change.
    .onReceive(NotificationCenter.default.publisher(for: .openingSignatureModal)) { _ in
        self.contentSizeForPDF = contentSize
    }
}

// MARK: Subviews
var header: SceneHeaderView {
    SceneHeaderView(viewModel: viewModel)
}

// Extracting this view into a variable allows grabbing the screenshot when the customer signature is collected.
var content: some View {
    LazyVStack(spacing: 16) {
        header
        ForEach(viewModel.cardViewModels) { cardViewModel in
            // Each card needs to be in a VStack with no spacing or it will end up having a gap between the header and the card body.
            VStack(spacing: 0) {
                UICardView(viewModel: cardViewModel)
            }
        }
    }

    .onReceive(viewModel.signaturePublisher) {
        saveSnapshot(content: content, size: contentSizeForPDF)
    }
}

// MARK: View Functions
/// Creates and saves a pdf data file to the inspection's report property.  This is done asynchronously to avoid odd behavior with the graphics renering process.
private func saveSnapshot<Content: View>(content: Content, size: CGSize) {

    let pdf = content
            .frame(width: size.width, height: size.height)
            .padding(.bottom, .standardEdgeSpacing)  // Not sure why this padding needs to be added but for some reason without it the pdf is tight to the last card on the bottom.  It appears that extra padding is being added above the header, but not sure why.
            .toPDF(format: self.pdfFormat)
        self.saveReport(data: pdf)
}

private var pdfFormat: UIGraphicsPDFRendererFormat {
    let format = UIGraphicsPDFRendererFormat()
    // TODO: Add Author here
    let metadata = [kCGPDFContextCreator: "Mike",
                     kCGPDFContextAuthor: viewModel.userService.userFullName]
    format.documentInfo = metadata as Dictionary<String, Any>
    return format
}

private func saveReport(data: Data?) {
    guard let data = data else {
        log.error("No data generated for pdf.")
        return
    }

    do {
        try viewModel.saveReport(data)

        print("Report generated for inspection")
    } catch {
        print("Failed to create pdf report due to error: \(error)")
    }

//             Used to verify report in simulator
        let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        var filename = "\(UUID().uuidString.prefix(6)).pdf"
        let bodyPath = documentDirectory.appendingPathComponent(filename)
        do {
            let value: Data? = viewModel.savedReport
            try value?.write(to: bodyPath)
            print("pdf directory: \(documentDirectory)")
        } catch {
            print("report not generated")
        }
    }

    // MARK: Size Preference Key
    private struct SizePreferenceKey: PreferenceKey {
        static var defaultValue: CGSize = .zero

        static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
            value = nextValue()
        }
    }
}

// MARK: Get image from SwiftUI View
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)
        }
    }

    func toPDF(format: UIGraphicsPDFRendererFormat) -> Data {
        let controller = UIHostingController(rootView: self)
        let view = controller.view

        let contentSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: contentSize)
        view?.backgroundColor = .clear

        let renderer = UIGraphicsPDFRenderer(bounds: controller.view.bounds, format: format)

        return renderer.pdfData { context in
            context.beginPage()
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

I landed on using a size preference key because a lot of other options did not perform as expected. For instance, setting the content size in .onAppear ended up yielding a pdf with a lot of space above and below it. At one point I was creating the pdf using a boolean state that when changed would use the geometry reader’s current geometry. That generally worked, but would provide unexpected results when the user would rotate the device while a modal was open.

References (as best I can recall):
SizePreferenceKey: https://swiftwithmajid.com/2020/01/15/the-magic-of-view-preferences-in-swiftui/
Snapshot View extension: https://www.hackingwithswift.com/quick-start/swiftui/how-to-convert-a-swiftui-view-to-an-image


This Question was asked in StackOverflow by user17864647 and Answered by Mike R It is licensed under the terms of CC BY-SA 2.5. - CC BY-SA 3.0. - CC BY-SA 4.0.

people found this article helpful. What about you?