How to implement NSTableViewRowlView like Xcode IB object selector

124 views Asked by At

I am trying to implement a NSTableView that looks similar to the Xcode IB object selector (bottom right panel). As shown below when a row is selected a full width horizontal line is draw above and below the selected row.

I have successfully created a subclass of NSTableRowView and have used the isNextRowSelected property to determine whether to draw a full width separator and this almost works.

The issue is the row above the selected row is not being redrawn unless you happened to select a row and then select the row below it immediately afterwards.

How can I efficiently get the NSTableView to redraw the row above the selected row every time ?

enter image description here

Here is my implementation when a single row is selected enter image description here

And another if a the row immediately below is now selected - which is what I want. enter image description here

/// This subclass draws a partial line as the separator for unselected rows and a full width line above and below for selected rows
/// |    ROW     |
/// | ---------- |  unselected separator

/// |------------|  selected separator on row above selected row
/// |    ROW     |
/// |------------|  selected separator
///
/// Issue: Row above selected row does not get redrawn when selected row is deselected
class OSTableRowView: NSTableRowView {

    let separatorColor  = NSColor(calibratedWhite: 0.35, alpha: 1)
    let selectedSeparatorColor  = NSColor(calibratedWhite: 0.15, alpha: 1)
    let selectedFillColor       = NSColor(calibratedWhite: 0.82, alpha: 1)


    override func drawSeparator(in dirtyRect: NSRect) {
        let yBottom = self.bounds.height
        let gap: CGFloat = 4.0
        let xLeft: CGFloat = 0.0
        let xRight = xLeft + self.bounds.width

        let lines = NSBezierPath()

        /// Draw a full width separator if the item is selected or if the next row is selected
        if self.isSelected || self.isNextRowSelected {
            selectedSeparatorColor.setStroke()
            lines.move(to: NSPoint(x: xLeft, y: yBottom))
            lines.line(to: NSPoint(x: xRight, y: yBottom))
            lines.lineWidth = 1.0
        } else {
            separatorColor.setStroke()
            lines.move(to: NSPoint(x: xLeft+gap, y: yBottom))
            lines.line(to: NSPoint(x: xRight-gap, y: yBottom))
            lines.lineWidth = 0.0
        }

        lines.stroke()
    }

    override func drawSelection(in dirtyRect: NSRect) {
        if self.selectionHighlightStyle != .none {
            let selectionRect = self.bounds
            selectedSeparatorColor.setStroke()
            selectedFillColor.setFill()
            selectionRect.fill()
        }
    }
}

After reading a few other posts I tried adding code to cause the preceding row to be redraw. This appears to have not effect.

func selectionShouldChange(in tableView: NSTableView) -> Bool {
        let selection = tableView.selectedRow
        if selection > 0 {
            tableView.setNeedsDisplay(tableView.rect(ofRow: selection-1))
            tableView.displayIfNeeded()
        }
        return true
    }

And nor does this.

func tableViewSelectionDidChange(_ notification: Notification) {
        guard let tableView = self.sidebarOutlineView else {
            return
        }
        let row = tableView.selectedRow
            if row > 0 {
                tableView.setNeedsDisplay(tableView.rect(ofRow: row-1))
                print("row-1 update rect: \(tableView.rect(ofRow: row-1))")
            }

    }

Seems odd that neither of these trigger redrawing of the row - am I missing something here!

EDIT: OK I found something that seems to work OKish - there is still a visible lag in the redrawing of the row above the deselected row which is not present in the XCode tableView.

var lastSelectedRow = -1 {
        didSet {
            guard let tableView = self.sidebarOutlineView else {
                return
            }
            if oldValue != lastSelectedRow {

                if oldValue > 0 {
                    if let view = tableView.rowView(atRow: oldValue-1, makeIfNecessary: false) {
                        view.needsDisplay = true
                    }
                }
                if lastSelectedRow > 0 {
                    if let view = tableView.rowView(atRow: lastSelectedRow-1, makeIfNecessary: false) {
                        view.needsDisplay = true
                    }
                }
            }
        }
    }

and then simply set the value of the variable lastSelectedRow = tableView.selectedRow in the tableViewSelectionDidChange(:) method.

I think perhaps the tableView needs to be subclassed to make sure that both rows are redrawn in the same update cycle.

1

There are 1 answers

0
Duncan Groenewald On

This NSTableRowView subclass seems to work fine with no visible lag in redrawing the row above any more.

The solution was to override the isSelected properly and set needsDisplay on the row above each time.

/// This subclass draws a partial line as the separator for unselected rows and a full width line above and below for selected rows
/// |    ROW     |
/// | ---------- |  unselected separator

/// |------------|  selected separator on row above selected row
/// |    ROW     |
/// |------------|  selected separator
///
/// Issue: Row above selected row does not get redrawn when selected row is deselected
class OSTableRowView: NSTableRowView {

    let separatorColor  = NSColor(calibratedWhite: 0.35, alpha: 1)
    let selectedSeparatorColor  = NSColor(calibratedWhite: 0.15, alpha: 1)
    let selectedFillColor       = NSColor(calibratedWhite: 0.82, alpha: 1)

    /// Override this and whenever it is changed set the previous row to be updated
    override var isSelected: Bool {
        didSet {
            if let tableView = self.superview as? NSTableView {
                let row = tableView.row(for: self)
                if row > 0 {
                    tableView.rowView(atRow: row-1, makeIfNecessary: false)?.needsDisplay = true
                }
            }
        }
    }


    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
    }

    override func drawSeparator(in dirtyRect: NSRect) {
        let yBottom = self.bounds.height
        let gap: CGFloat = 4.0
        let xLeft: CGFloat = 0.0
        let xRight = xLeft + self.bounds.width

        let lines = NSBezierPath()

        /// Draw a full width separator if the item is selected or if the next row is selected
        if self.isSelected || self.isNextRowSelected {
            selectedSeparatorColor.setStroke()
            lines.move(to: NSPoint(x: xLeft, y: yBottom))
            lines.line(to: NSPoint(x: xRight, y: yBottom))
            lines.lineWidth = 1.0
        } else {
            separatorColor.setStroke()
            lines.move(to: NSPoint(x: xLeft+gap, y: yBottom))
            lines.line(to: NSPoint(x: xRight-gap, y: yBottom))
            lines.lineWidth = 0.0
        }

        lines.stroke()
    }

    override func drawSelection(in dirtyRect: NSRect) {
        if self.selectionHighlightStyle != .none {
            let selectionRect = self.bounds
            selectedSeparatorColor.setStroke()
            selectedFillColor.setFill()
            selectionRect.fill()
        }
    }
}