Customize right click highlight on view-based NSTableView
Asked Answered
J

5

9

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.

Jewry answered 8/3, 2012 at 14:59 Comment(0)
M
-3

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.

Mariejeanne answered 8/3, 2012 at 16:22 Comment(4)
I know! I have seen that and I can't find any clue to how I might do this. Some of the properties I don't really understand and there's no single method for drawing that highlight...Jewry
I believe in your case, drawDraggingDestinationFeedbackInRect: will be your main override point for drawing that highlight.Mariejeanne
I retract my previous statement. I poked around on cocoabuilder and found this post that seems to indicate you can only suppress the built in right-click highlight not customize it :(Mariejeanne
Well, I did make it round by making the tableview a source list, and that seems it will be as far as I can go... ThanksJewry
C
11

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.

Chaeta answered 2/4, 2014 at 22:49 Comment(8)
Could you please provide a Swift solution since I wasn’t able to convert your solution :(Spiegleman
@Spiegleman I've added the Swift version. Let me know if that doesn't work. It should, but you never know.Chaeta
Dear @Ben, thanks for your swift translation :) However, I couldn’t get it work. Essentially everything draws as expected, but it doesn’t overwrites the regular highlights (like the borders), so everything is mixed up.Spiegleman
@ixany, Oh yeah, there's a trick I forgot to mention! In Interface Builder, select your table/outline view, and set Highlight to None. By default, AppKit does some crazy drawing stuff, and it's super hard to override without screwing everything up. If that doesn't work, I'll see if I can figure out how I got my sidebar working right in my current project. I do remember it threw a few curveballs. Good luck! Let me know if the above trick fixes stuff.Chaeta
I was reading the top part of your answer and translating it into Swift 3 simultaneously, unaware of the edit below XDEnrich
The outline is drawn correctly, but there is some distracting off-color band right above and below the disclosure indicator of that row. Anyone experiencing this?Enrich
This does not work with Swift 4 on 10.13. Even when highlightStyle is set to "none", the original highlight is draw by AppKit on top of the custom drawing we're doing.Aspergillosis
I'm not convinced that NSTableRowView is where that ring is drawn. If I override ALL drawing methods in my custom rowView class and do NOTHING, that ring still gets drawn. I know the table is using my custom rowView class because it's just a black void when I override all drawing methods to be no-ops. But the ring persists. There are days I just want to punch Apple engineers.Aspergillosis
A
5

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.

Aspergillosis answered 17/8, 2020 at 7:12 Comment(1)
Thanks for the writeup! It seems that instead of using -menuDidClose:, the NSView method -didCloseMenu:withEvent: is even better.Yourself
P
4

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() {

    }
}
Pamulapan answered 15/11, 2018 at 9:9 Comment(0)
L
0

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().

Latia answered 12/6, 2019 at 8:37 Comment(0)
M
-3

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.

Mariejeanne answered 8/3, 2012 at 16:22 Comment(4)
I know! I have seen that and I can't find any clue to how I might do this. Some of the properties I don't really understand and there's no single method for drawing that highlight...Jewry
I believe in your case, drawDraggingDestinationFeedbackInRect: will be your main override point for drawing that highlight.Mariejeanne
I retract my previous statement. I poked around on cocoabuilder and found this post that seems to indicate you can only suppress the built in right-click highlight not customize it :(Mariejeanne
Well, I did make it round by making the tableview a source list, and that seems it will be as far as I can go... ThanksJewry

© 2022 - 2024 — McMap. All rights reserved.