Visual glitch with NSTableView using alternating row colors under macOS Big Sur

Viewed 321

I'm using an NSTableView with usesAlternatingRowBackgroundColors set to true.

As soon as I

  1. Add many columns, e.g., 15 and
  2. Set the Cell Spacing height to something > 0

the table shows a visual glitch, where the alternating rows are not equally distributed in height:

enter image description here

This happens only under macOS Big Sur. macOS Mojave and macOS Catalina work fine. I've experimented with almost any combination of settings and styles, using latest Xcode 12.4.

My ViewController is fairly simple:

class ViewController: NSViewController {

    @IBOutlet weak var tableView: NSTableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        for columnIndex in 0..<15 {
            let tableColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "\(columnIndex)"))
            tableColumn.title = "CustomColumn \(columnIndex)"
            tableColumn.width = 150
            tableView.addTableColumn(tableColumn)
        }
    }
}

And the configuration of the NSTableView in Interface Builder is also quite boring, except for the adjusted cell spacing height:

enter image description here

It would be great if someone could confirm the issue and possibly share any workaround for that.

You can find a demo project at https://github.com/fheidenreich/table-test

3 Answers

I tested the sample project on MacOS 11.0 and MacOS 11.2.1 but was unable to reproduce the glitch. But that's not unexpected as the code itself looks fine.

Could be a problem with an intermediate MacOS version or a particular hardware software combination. If the problem persists one could try to draw the background by hand by subclassing NSTableView.

class TableViewEx: NSTableView {
    
    public override func drawBackground(inClipRect clipRect: NSRect) {
        
        guard usesAlternatingRowBackgroundColors else {
            super.drawBackground(inClipRect: clipRect)
            return
        }
        
        backgroundColor.setFill()
        clipRect.fill()
        let effectiveRowHeight = rowHeight + self.intercellSpacing.height
        
        // This color requires 10.14+. There is an older, deprecated property on NSColor to get this value if needed.
        let altColor = NSColor.alternatingContentBackgroundColors.last ?? backgroundColor
        altColor.setFill()
        
        let rowIndex = Int((clipRect.minY / effectiveRowHeight).rounded(.down))
        
        for i in rowIndex..<Int.max {
            if i % 2 == 0 { continue }
            let rect = NSRect(
                x: clipRect.minX,
                y: CGFloat(i) * effectiveRowHeight,
                width: clipRect.width,
                height: effectiveRowHeight
            )
            rect.fill()
            if rect.maxY >= clipRect.maxY { break }
        }
    }
}

But I would not be surprised when it's still there even in overriding draw. I don't think that Cocoa code is that different from the proposed one so it might as well be rendering bug lower in the stack.

For those who elect to do their own drawing in the ClipRect, here's the best approach for that. It has several advantages:

  1. It draws rows only in that portion of the tableView where actual NSRowView instances do not appear, so it's much more efficient.

  2. When you scroll "past the edge" of the tableView (so that the edges begin to "rubber band"), modern NSTableView styles do not draw anything in the "rubber banding area". This approach follows that convention. Without this, the ACTUAL table rows will stop drawing as you drag off the edge, but the FAKE table rows we're drawing won't, which will look weird.

override func drawBackground(inClipRect clipRect: NSRect)
{
    backgroundColor.setFill()
    clipRect.fill()
        
    let effectiveRowHeight: CGFloat = rowHeight + self.intercellSpacing.height
        
    let altColor: NSColor = NSColor.alternatingContentBackgroundColors.last ?? backgroundColor
    altColor.setFill()
    
    // Don't draw rows that the tableView already has
    let rowIndex = max(Int((clipRect.minY / effectiveRowHeight).rounded(.down)), numberOfRows)
        
    // How many rows do we need to draw?
    // Don't draw rows in the "rubber banding" zone if the user scrolls down past the end of the table, or horizontally off the side.
    // That matches how modern NSTableView draws NSRowView instances, so the table will look uniform during "rubber-banding"
    let maxRows = Int((self.bounds.size.height/effectiveRowHeight).rounded(.up))
    let tableOrigin: CGFloat = bounds.origin.x
    let tableWidth: CGFloat = bounds.size.width
        
    guard rowIndex < maxRows else {
        return
    }
        
    for i in rowIndex ..< maxRows
    {
        if i % 2 != 0
        {
            let rect = NSRect(x: tableOrigin, y: (CGFloat(i) * effectiveRowHeight), width: tableWidth, height: effectiveRowHeight)
            rect.fill()
        }
    }
}

I've talked to an Apple engineer over this issue during WWDC'21 and he confirmed the issue and proposed an interesting workaround:

override func drawBackground(inClipRect clipRect: NSRect)
{
   super.drawBackground(inClipRect: clipRect)
}

This triggers a different code path with less optimizations inside AppKit and also prevents the issue from appearing.

I've also updated my feedback to Apple with more examples and it seems that the issue is finally resolved with macOS Monterey 12.0 Beta 5 (21A5304g).

Related