Search code examples
swiftpdfswiftuiios-pdfkitapple-pdfkit

Generating PDF from array of SwiftUI views


I'm building an identity app which is stored all locally within the app on device.

Part of the app is the creation of IdentityCard for each user - with the standard things, name, image, position.

The size of the card is a standard credit card size: let cardSize: CGSize = .init(width: 153.8, height: 242.1) in the vertical position.

I want to create a "generate PDF" button so that each user's ID card will be printed into a PDF. I have the ability to select the paper size (A4, Letter, or custom - where they can select their own width, height, and unit).

At the moment I have this to generate the PDF, but I am running into a few issues:

  1. It doesn't output anything in a .sheet
  2. It is slow to run
  3. It crashes when testing on larger sets of users
import SwiftUI
import PDFKit
class PDFDataManager {
  static let shared = PDFDataManager()
  private init() {}
  struct Item<Content : View> {
    let views: [Content]
    let width: CGFloat
    let height: CGFloat
  }
  func generate<Content : View>(
    from item: Item<Content>,
    paper: PageSize = .a4,
    margin: Double = 36,
    bleed: Double = 10
  ) -> PDFDocument {
    let usablePageSize: CGSize = .init(
      width: paper.size.width - margin - (2 * bleed),
      height: paper.size.height - margin - (2 * bleed)
    )
    let itemSize: CGSize = .init(
      width: item.width,
      height: item.height
    )
    let maxRows = Int(floor(usablePageSize.height / itemSize.height))
    let maxCols = Int(floor(usablePageSize.width / itemSize.width))
    let pdfDocument = PDFDocument()
    let pageSize = CGRect(
      x: 0, y: 0,
      width: paper.size.width,
      height: paper.size.height
    )
    var currentItem = 0
    var currentRow = 0
    var currentCol = 0
    DispatchQueue.global(qos: .userInitiated).async {
      while currentItem < item.views.count {
        let pdfPage = PDFPage()
        pdfPage.setBounds(pageSize, for: .trimBox)
        let pdfView = PDFView(frame: pageSize)
        pdfView.autoScales = true
        pdfView.displayDirection = .vertical
        pdfView.displayMode = .singlePageContinuous
        pdfView.document = pdfDocument
        pdfView.pageBreakMargins = .init(top: 0, left: 0, bottom: 0, right: 0)
        while currentItem < item.views.count && currentRow <= maxRows {
          autoreleasepool {
            DispatchQueue.main.async {
              if currentItem < item.views.count {
                let itemView = UIHostingController(rootView: item.views[currentItem])
                let x = CGFloat(currentCol) * (itemSize.width + (bleed * 2))
                let y = CGFloat(currentRow) * (itemSize.height + (bleed * 2))
                let itemRect = CGRect(
                  x: x, y: y,
                  width: itemSize.width,
                  height: itemSize.height
                )
                itemView.view.frame = itemRect
                pdfView.addSubview(itemView.view)
                currentCol += 1
                if currentCol >= maxCols {
                  currentCol = 0
                  currentRow += 1
                }
                currentItem += 1
              }
            }
          }
        }
        DispatchQueue.main.async {
          pdfDocument.insert(pdfPage, at: pdfDocument.pageCount)
        }
      }
    }
    return pdfDocument
  }
}

What I've aimed to do / What my aim is to do:

  1. Pass in all the items through a temp struct
  2. Have a page margin, and an item bleed spacer
  3. Calculate the max items in a row
  4. Calculate the max items in a column
  5. Loop over all the items and place them onto the page

Once I would have that working I would be able to share the PDF, print the PDF, or export the PDF. However, at the moment I cant do any of that and I'm kind of lost of where to go from here.

I am targeting iOS/iPadOS 15 and above.


Solution

  • This question was a bit harder to answer than what I thought, but I did manage to get it solved.

    I do want to flag that there would probably be smarter, more efficient, and better implementation - but this worked for me.

    I started with this framework which helped me build the PDF. It is fairly fleshed out and simple to use.

    Using that, I had this code as my even spreading across the pages:

    func generatePDF(_ items: [Item]) {
    
      let pageSize: PageSize = .a4
      let pageMargin: UIEdgeInsets = .equal(20)
      let imageSize = CGSize(width: 160, height: 250)
      let pdf = PDFMKit(pageSize: pageSize, pageMargin: pageMargin)
      let usablePageSize = CGSize(
        width: pageSize.size.width - pageMargin.left - pageMargin.right,
        height: pageSize.size.height - pageMargin.top - pageMargin.bottom
      )
    
      // -- get the max rows and columns per page
      let maxColumnsPerPage = Int(usablePageSize.width / imageSize.width)
      let maxRowsPerPage = Int(usablePageSize.height / imageSize.height)
      let maxItemsPerPage = maxRowsPerPage * maxColumnsPerPage
    
      // -- get the total item count
      let totalItems = users.count
    
      // -- create a chunked array
      let pageChunks = users.chunked(into: maxItemsPerPage)
    
      // -- run the operation in the background
      DispatchQueue.global(qos: .background).async {
    
        // -- loop over the chunks
        // -- this should be all the items per page
        for (pageIndex, pageChunk) in pageChunks.enumerated() {
    
          // -- rechunk the page items into the rows
          let pageItemChunks = pageChunk.chunked(into: maxColumnsPerPage)
    
          // -- loop over the rows
          for pageItemChunk in pageItemChunks {
    
            // -- get an array of the images
            let images = pageItemChunk.map { user in
              UIImage(
                data: user.cardImageData ?? .init(),
                scale: .screenScale
              ) ?? .init()
            }
    
            // -- start the horizontal alignment
            pdf.beginHorizontalArrangement()
    
            // -- add in the images
            for image in images {
              pdf.addImage(image)
            }
    
            // -- end the horizontal alignment
            pdf.endHorizontalArrangement()
    
            // -- update the progress bar
            if let lastItem = pageItemChunk.last,
               let lastIndex = users.firstIndex(of: lastItem) {
               DispatchQueue.main.async {
                 progress(Double(lastIndex + 1) / Double(totalItems))
               }
             }
           }
    
           // -- create a new page at the end
           if pageIndex < pageChunks.count - 1 {
             pdf.beginNewPage()
           }
         }
    
         // -- update the main thread data
         DispatchQueue.main.async {
           let title = UUID().uuidString
           let pdfData = pdf.generate(title,
                                      author: "Author Name",
                                      subject: Bundle.main.displayName)
           do {
             let savedURL = try self.savePDF(pdfData, name: title)
             completion(.success(savedURL))
           } catch {
             completion(.failure(.unableToGenerate))
           }
         }
       }
     }
    }
    

    My implementation is similar but I do have some customisations to the source code to make it fully work for me. But the above should help anyone who needs it!