How to Print on macOS With An NSTableView and Customize the Result

null

So a client of mine recently asked how he could customize his app’s printing.

The app’s main window there mostly showed tabular data, so when you’re inside the NSTableView and hit ⌘P to print, the table view will redirect its draw(_:) output to the print dialog. That’s how you get printing for free.

Note: Since NSView.draw(_:) is the basis for the printed output, CALayer settings will be effectively ignored.

How to customize how a view is drawn when printed

If you want to customize the printed output, you can check which NSGraphicsContext is active when you override the drawing routine. Adapted from the old Apple docs:

class MyView: NSView {
    override func draw(_ dirtyRect: NSRect) {
        if NSGraphicsContext.currentContextDrawingToScreen() {
            // draw for screen
        } else {
            // draw for print
        }
    }
}

How to customize the view hierarchy that’s printed

My client wanted to customize the printed output beyond the drawing routine. The default table view drawing is fine. But he wanted to prepend a descriptive text before the table. The page headers won’t do because they repeat on every page. The printed page should contain the table view and a multi-line label that’s only visible when printing.

Printing relies on NSView, so we can use NSStackView to lay out contents on the page for printing. This requires a rebinding of the print shortcut from the system default to any custom @IBAction function so we can handle the command ourselves.

Create a NSPrintOperation programmatically

That changes the question from “how can we customize drawing a view” to “how can we define the content on the page”. This in turn boils down to bringing up the printing panel with custom content like this:

let sheetHostingWindow: NSWindow = ...
let viewToPrint = NSStackView()
// ... prepare the stack view ...
let printOperation = NSPrintOperation(view: viewToPrint)
printOperation.runModal(
    for: sheetHostingWindow,
    delegate: nil,
    didRun: nil,
    contextInfo: nil)

That’s it – create a NSPrintOperation and then show the system printing panel for the print operation.

I whipped up an example project with the resulting code. Check it out to see everything.

So if you ever created a view hierarchy programmaically, you can easily fill in the gaps to prepare the view to be printed. Note that you can also load a view hierarchy from Nib and print that. Either way, make sure the Auto Layout constraints fill the page.

Prepare programmatically created views for printing

I found that the main view that’s going to be printed produces the best output if you set its frame to the page size, excluding the page margins. This information can be changed in the system default “Print Setup” dialog, for example. Again, I got the idea from the archived docs:

extension NSPrintInfo {
    var availableContentSize: NSSize {
        NSSize(width:  paperSize.width  - leftMargin - rightMargin,
               height: paperSize.height - topMargin  - bottomMargin)
    }
}

// ...

let initialFrameForPrinting = NSRect(
    origin: .zero,
    size: pageContentSize)
let stackView = NSStackView(frame: initialFrameForPrinting)
// set up view and then create NSPrintOperation as seen above

I’m not sure why setting the initial frame to the available page size helps, since the view easily resizes and is printed onto multiple pages as needed. But for smaller stack views, this will try to fill the available page without zooming-in on the content. This could be a quirk in how the printed view is embedded into the page, maybe Auto Layout isn’t used when printing and that affects the result, maybe it’s a stupid oversight somewhere else in the code – but if you run into a similar problem, here you go.

Armed with this knowledge, you can now prepare your custom view hierarchy for printing and start the print sheet with it.

How to print a NSTableView

Print dialog with a wrapping label that's only part of the page, not the app's window

In the sample project, I’ve added a multi-line label just before the table view. And to print the actual table view, I added a new table view for printing. So the stack view that’s being printed has 2 arranged subviews.

Printing a custom view hierarchy with the same table data that you see on screen won’t work if you move the on-screen table view over. I tried, that’s hacky and looks weird. When you take the tableView from your app window and put it into the stack view for printing, it’ll be removed from the app window. Any view can only have 1 parent view.

Programmatically creating a new NSTableView instance worked best. This required a couple of adjustments to the approach.

The client wants to keep Storyboards. So the table view on screen is set up in a Storyboard, the table view on the page is set up in a Nib or programmatically. (I chose the programmatical route to mix things up and show how it’s done.)

In order to have the on-screen and the for-print table views populate from the same data source, we can actually reuse the view controller that is the NSTableView.delegate and dataSource. That will prepare the table data, but we do have to move the creation of NSTableViewCells all into the code. The cells cannot be configured in the Storyboard alone: the Storyboard references aren’t known to the programmatically created table view, and adding this connection sounded like more trouble than it was worth.

Instead of pointing the programmatically created table view to the cells in the Storyboard, I made the table view from the Storyboard obtain its cells programmatically. To get an experience that’s pretty close to the stuff you see in Nibs or Storyboards, use this, cobbled together from docs and StackOverflow of course:

extension NSUserInterfaceItemIdentifier {
    static let cell = NSUserInterfaceItemIdentifier("my_custom_cell")
}

class TableCellView: NSTableCellView {
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        self.identifier = .cell

        let textField = NSTextField.label()
        textField.autoresizingMask = [.width, .height]
        self.textField = textField  // is an 'unsafe' reference
        self.addSubview(textField)  // creates a 'strong' reference

        textField.bind(
            NSBindingName.value,
            to: self,
            withKeyPath: "objectValue",
            options: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension NSTextField {
    /// Return an `NSTextField` configured like one created
    /// by dragging a “Label” into a storyboard.
    class func label(title: String = "") -> NSTextField {
        let label = NSTextField()
        label.isEditable = false
        label.isSelectable = false
        label.textColor = .labelColor
        label.backgroundColor = .controlColor
        label.drawsBackground = false
        label.isBezeled = false
        label.alignment = .natural
        label.controlSize = .regular
        label.font = NSFont.systemFont(
            ofSize: NSFont.systemFontSize(for: .regular))
        label.lineBreakMode = .byClipping
        label.cell?.isScrollable = true
        label.cell?.wraps = false
        label.stringValue = title
        return label
    }
}

The view controller in turn relies on the Cocoa Bindings of NSTableCellView.objectValue to the text field and has very simple data source and delegate implementations:

extension ViewController: NSTableViewDelegate, NSTableViewDataSource {
    func numberOfRows(in tableView: NSTableView) -> Int {
        return items.count
    }

    func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
        return items[row]
    }

    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        return tableView.makeView(withIdentifier: .cell, owner: self)
            ?? TableCellView()
    }
}

This works for the table view from the Storyboard and the custom table view for printing. If you have multiple columns, you have to adjust everything, of course.

To top things off and document everything in the example project, here’s the table view creation code as well:

let tableViewForPrint = NSTableView(frame: .zero)
let soleColumn = NSTableColumn()
soleColumn.resizingMask = .autoresizingMask
tableViewForPrint.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle
tableViewForPrint.allowsColumnSelection = false
tableViewForPrint.allowsColumnResizing = true
tableViewForPrint.addTableColumn(soleColumn)
tableViewForPrint.selectionHighlightStyle = .none
tableViewForPrint.allowsEmptySelection = true
if #available(macOS 11.0, *) {
    // Avoid Big Sur's default horizontal padding in print-outs
    tableViewForPrint.style = .plain
}

tableViewForPrint.dataSource = self
tableViewForPrint.delegate = self
tableViewForPrint.reloadData()

Some alternative approaches to creating a new table view for print:

  • Use the on-screen table view’s draw(_:) method and call it where appropriate. Think of it like a canvas: this could be a custom NSView subclass that forwards intrinsicContentSize and draw(_:) to the table view to replicate the pixel output.
  • Create a copy of the on-screen table view. I tried copy() and serializing and deserializing to NSData, but that didn’t work, and it was so weird I abandoned it. But maybe this could work?
  • Add everything you want to print to the NSWindow, but set isHidden = true, and then un-hide when you print. Could be a cheap cop-out that produces visual glitches.

Conclusion

To wrap things up:

  • If you want to customize the drawing routine of a custom view when it’s being printed, you can check NSGraphicsContext.currentContextDrawingToScreen().
  • If you want to print different content than is shown in the app, you may want to create a custom view hierarchy for print. NSStackView is an excellent choice for vertical layouts.
    • If you also want to share the table date on screen and on paper, a good choice is to create a new NSTableView with the same data source and add this to the printed view hierarchy.

See also:

Receive new .