8
votes

I have a view-based NSTableView with a custom NSTableCellView and a custom NSTableRowView. I customized both of those classes because I want to change the appearance of each row. By implementing the [NSTableRowView draw...] methods I can change the background, the selection, the separator and the drag destination highlight.

My question is: how can I change the highlight that appears when the row is right clicked and a menu appears?

For example, this is the norm:

And I want to change the square highlight to a round one, like this:

I'd imagine this would be done in NSTableRowView by calling a method like drawMenuHighlightInRect: or something, but I can't find it. Also, how can the NSTableRowView class be doing this if I customized, in my subclass, all of the drawing methods, and I don't call the superclass? Is this drawn by the table itself?

EDIT:

After some more experimenting I found out that the round highlight can be achieved by setting the tableview as a source list. Nonetheless, I want to know how to customize it if possible.

5

5 Answers

-2
votes

I'd take a look at the NSTableRowView documentation. It's the class that is responsible for drawing selection and drag feedback in a view-based NSTableView.

11
votes

I know I'm a bit late to offer any help to the OP, but hopefully this can spare some other folks a little bit of time. I subclassed NSTableRowView to achieve the right-click contextual menu highlight (why Apple doesn't have a public drawing method to override this is beyond me). Here it is in all its glory:

BSDSourceListRowView.h

#import <Cocoa/Cocoa.h>

@interface BSDSourceListRowView : NSTableRowView

// This needs to be set when a context menu is shown.
@property (nonatomic, assign, getter = isShowingMenu) BOOL showingMenu;

@end

BSDSourceListRowView.m

#import "BSDSourceListRowView.h"

@implementation BSDSourceListRowView

- (void)drawBackgroundInRect:(NSRect)dirtyRect
{
    [super drawBackgroundInRect:dirtyRect];

    // Context menu highlight:
    if ( self.isShowingMenu ) {
        [self drawContextMenuHighlight];
    }
}

- (void)drawContextMenuHighlight
{
    BOOL selected = self.isSelected;
    CGFloat insetY = ( selected ) ? 2.f : 1.f;
    NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds, 2.f, insetY) xRadius:6.f yRadius:6.f];
    NSColor *fillColor, *strokeColor;

    if ( selected ) {
        fillColor = [NSColor clearColor];
        strokeColor = [NSColor whiteColor];
    } else {
        fillColor = [NSColor colorWithCalibratedRed:95.f/255.f green:159.f/255.f blue:1.f alpha:0.12f];
        strokeColor = [NSColor alternateSelectedControlColor];
    }

    [fillColor setFill];
    [strokeColor setStroke];

    [path setLineWidth:2.f];
    [path fill];
    [path stroke];
}

- (void)drawSelectionInRect:(NSRect)dirtyRect
{
    [super drawSelectionInRect:dirtyRect];
    if ( self.isShowingMenu ) {
        [self drawContextMenuHighlight];
    }
}

- (void)setShowingMenu:(BOOL)showingMenu
{
    if ( showingMenu == _showingMenu )
        return;
    _showingMenu = showingMenu;
    [self setNeedsDisplay:YES];
}

@end

Feel free to use any of it, change any of it, or do whatever you want with any of it. Have fun!


Updated for Swift 3.x:

SourceListRowView.swift

import Cocoa

open class SourceListRowView : NSTableRowView {

    open var isShowingMenu: Bool = false {
        didSet {
            if isShowingMenu != oldValue {
                needsDisplay = true
            }
        }
    }

    override open func drawBackground(in dirtyRect: NSRect) {
        super.drawBackground(in: dirtyRect)
        if isShowingMenu {
            drawContextMenuHighlight()
        }
    }

    override open func drawSelection(in dirtyRect: NSRect) {
        super.drawSelection(in: dirtyRect)
        if isShowingMenu {
            drawContextMenuHighlight()
        }
    }

    private func drawContextMenuHighlight() {

        let insetY: CGFloat = isSelected ? 2 : 1
        let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 2, dy: insetY), xRadius: 6, yRadius: 6)
        let fillColor, strokeColor: NSColor

        if isSelected {
            fillColor = .clear
            strokeColor = .white
        } else {
            fillColor = NSColor(calibratedRed: 95/255, green: 159/255, blue: 1, alpha: 0.12)
            strokeColor = .alternateSelectedControlColor
        }

        fillColor.setFill()
        strokeColor.setStroke()

        path.lineWidth = 2
        path.fill()
        path.stroke()
    }

}

Note: I haven't actually run this, but I'm pretty sure this should do the trick in Swift.

3
votes

This is already a bit old, but I've wasted on it quite a bit of time, so posting my solution in case it could help anyone:

  1. In my case, I wanted to remove the lines completely
  2. Lines are not "Focus" rings, they are some stuff Apple is doing in undocument API
  3. The ONLY way I found to remove them (Without using Undocumented API) is by opening NSMenu programmatically, without Interface Builder.
  4. For that, I had to cache "right-click" event on TableViewRow, which has some issue since not always called, so I've dealt with that issue too.

A. Subclass NSTableView: Overriding right click event, calculating the location of click to get a correct row, and transferring it to my custom NSTableRowView!

class TableView: NSTableView {
    override func rightMouseDown(with event: NSEvent) {
        let location = event.locationInWindow
        let toMyOrigin = self.superview?.convert(location, from: nil)
        let rowIndex = self.row(at: toMyOrigin!)
        if (rowIndex < 0 || self.numberOfRows < rowIndex) {
            return
        }
        if let isRowExists = self.rowView(atRow: rowIndex, makeIfNecessary: false) {
            if let isMyTypeRow = isRowExists as? MyNSTableRowView {
                isMyTypeRow.costumRightMouseDown(with: event)
            }
        }
    }

}

B. Subclass MyNSTableRowView Presenting NSMenu programmatically

class MyNSTableRowView: NSTableRowView {
    //My custom selection colors, don't have to implement this if you are ok with the default system highlighted background color
    override func drawSelection(in dirtyRect: NSRect) {
        if self.selectionHighlightStyle != .none {
            let selectionRect = NSInsetRect(self.bounds, 0, 0)
            Colors.tabSelectedBackground.setStroke()
            Colors.tabSelectedBackground.setFill()
            let selectionPath = NSBezierPath.init(roundedRect: selectionRect, xRadius: 0, yRadius: 0)
            selectionPath.fill()
            selectionPath.stroke()
        }
    }

    func costumRightMouseDown(with event: NSEvent) {
        let menu = NSMenu.init(title: "Actions:")
        menu.addItem(NSMenuItem.init(title: "Some", action: #selector(foo), keyEquivalent: "a"))
        NSMenu.popUpContextMenu(menu, with: event, for: self)
    }

    @objc func foo() {

    }
}
2
votes

Stop Default Drawing

Several answers describe how to draw a custom contextual-click highlight. However, AppKit will continue to draw the default one. There is an easy trick to stop that and I didn't want it to get lost in a comment: subclass NSTableView and override -menuForEvent:

// NSTableView subclass
override func menu(for event: NSEvent) -> NSMenu?
{
    // DO NOT call super's implementation.
    return self.menu
}

Here, I assume that you've assigned a menu to the tableView in IB or have set the tableView's menu property programatically. NSTableView's default implementation of -menuForEvent: is what draws the contextual menu highlight.


Solve Bad Apple Engineering

Now that we're not calling super's implementation of menuForEvent:, the clickedRow property of our tableView will always be -1 when we right-click, which means our menuItems won't target the correct row of our tableView.

But fear not, we can do Apple Engineering's job for them. On our custom NSTableView subclass, we override the clickedRow property:

class MyTableView: NSTableView
{
    private var _clickedRow: Int = -1
    override var clickedRow: Int {
        get { return _clickedRow }
        set { _clickedRow = newValue }
    }
}

Now we update the -menuForEvent: method:

// NSTableView subclass
override func menu(for event: NSEvent) -> NSMenu?
{
    let location: CGPoint = convert(event.locationInWindow, from: nil)
    clickedRow = row(at: location)

    return self.menu
}

Great. We solved that problem. Onwards to the next thing:


Tell Your RowView To Do Custom Drawing

As others have suggested, add a custom Bool property to your NSTableRowView subclass. Then, in your drawing code, inspect that value to decide whether to draw your custom contextual highlight. However, the correct place to set that value is in the same NSTableView method:

// NSTableView subclass
override func menu(for event: NSEvent) -> NSMenu?
    {
        let location: CGPoint = convert(event.locationInWindow, from: nil)
        clickedRow = row(at: location)
        
        if clickedRow > 0,
           let rowView: MyCustomRowView = rowView(atRow: tableRow, makeIfNecessary: false) as? MyCustomRowView
        {
            rowView.isContextualMenuTarget = true
        }
        
        return self.menu
    }

Above, I've created MyCustomRowView (a subclass of NSTableRowView) and have added a custom property: isContextualMenuTarget. That custom property looks like this:

// NSTableRowView subclass
var isContextualMenuTarget: Bool = false {
    didSet {
        needsDisplay = true
    }
}

In my drawing method, I inspect the value of that property and, if it's true, draw my custom highlight.


Clean Up When The Menu Closes

You have a controller that implements the datasource and delegate methods for your tableView. That controller is also likely the delegate for the tableView's menu. (You can assign that in IB or programatically.)

Whatever object is your menu's delegate, implement the menuDidClose: method. Here, I'm working in Objective-C because my controller is still ObjC:

// NSMenuDelegate object
- (void) menuDidClose:(NSMenu *)menu
{
    // We use a custom flag on our rowViews to draw our own contextual menu highlight, so we need to reset that.
    [_outlineView enumerateAvailableRowViewsUsingBlock:^(__kindof MyCustomRowView * _Nonnull rowView, NSInteger row) {
        
        rowView.isContextualMenuTarget = NO;
            
    }];
}

Performance Note: My tableView will never have more than about 50 entries. If you have a table with THOUSANDS of visible rows, you would be better served to save the rowView that you set isContextualMenuTarget=true on, then access that rowView directly in -menuDidClose: so you don't have to enumerate all rowViews.

Single-Column: This example assumes a single column tableView that has the same NSMenu for each row. You could adapt the same technique for multi-column and/or varying NSMenus per row.

And that's how you beat AppKit in the face until it does what you want.

0
votes

I agree with MCMatan that this is not something you can tweak by changing any draw calls. The box will remain.

I took his approach of bypassing the default menu launch, but left the context menu setup as default in my NSTableView. I think this is a simpler way.

I derive from NSTableView and add the following:

public private(set) var rightClickedRow: Int = -1

override func rightMouseDown(with event: NSEvent)
{
    guard let menu = self.menu else { return }

    let windowClickLocation = event.locationInWindow
    let outlineClickLocation = convert(windowClickLocation, from: nil)
    rightClickedRow = row(at: outlineClickLocation)

    menu.popUp(positioning: nil, at: outlineClickLocation, in: self)
}

override func rightMouseUp(with event: NSEvent) {
    rightClickedRow = -1
}

My rightClickedRow is analogous to clickedRow for the table view. I have an NSViewController that looks after my table, and it is set as the table's menu delegate. I can use rightClickedRow in the delegate calls, such as menuNeedsUpdate().