Kit Cross

Tags in Finder context menus

Finder on macOS has an ingenious color tag selector in the context menu. Find out how to make this and other custom views inside an NSMenu.

If you right click on a file in macOS you’ll see the context menu has a tag selector. As you mouse across the circles expand, contract and tick or cross to indicate selected state.

Finder has a context menu with a useful color tag selector

I love smart little UX details like this. This design removes the need for another submenu, doesn’t take precious vertical space and it looks great.

So I decided to break it down and recreate it.


AppKit uses the NSMenu class to manage an app’s menus. A menu consists of an array of NSMenuItems which are usually text options that may have a submenu.

In this case, we are adding a custom content view to the menu item.

The documentation says:

A menu item with a view does not draw its title, state, font, or other standard drawing attributes, and assigns drawing responsibility entirely to the view.

So we’ll need a container view. We’ll create a controller to manage this view. And then we’ll need a set of tag controls that go inside.

Drawing the icons

We have three possible states:

  • add: mouse over an unselected tag
  • cross: mouse over a selected tag
  • ticked: a tag is selected

I sketched these vectors out on a pixel grid. I’m going to draw these icons in code, but you could also use images. I prefer doing this in code because the paths will be crisp on retina and non–retina screens.

The pixel grid shapes

I’ll create three path drawing functions to return the path inside a bounding rectangle.

func addPath(_ rect: NSRect) -> NSBezierPath {
  let minX = NSMinX(rect)
  let minY = NSMinY(rect)

  let path = NSBezierPath()
  path.move(to: NSPoint(x: minX + 0.5, y: minY + 3.25))
  path.line(to: NSPoint(x: minX + 3.25, y: minY + 3.25))
  path.line(to: NSPoint(x: minX + 3.25, y: minY + 0.5))
  path.line(to: NSPoint(x: minX + 4.75, y: minY + 0.5))
  path.line(to: NSPoint(x: minX + 4.75, y: minY + 3.25))
  path.line(to: NSPoint(x: minX + 7.5, y: minY + 3.25))
  path.line(to: NSPoint(x: minX + 7.5, y: minY + 4.75))
  path.line(to: NSPoint(x: minX + 4.75, y: minY + 4.75))
  path.line(to: NSPoint(x: minX + 4.75, y: minY + 7.5))
  path.line(to: NSPoint(x: minX + 3.25, y: minY + 7.5))
  path.line(to: NSPoint(x: minX + 3.25, y: minY + 4.75))
  path.line(to: NSPoint(x: minX + 0.5, y: minY + 4.75))
  path.close()
  return path
}

func removePath(_ rect: NSRect) -> NSBezierPath {
  let minX = NSMinX(rect)
  let minY = NSMinY(rect)

  let path = NSBezierPath()
  path.move(to: NSPoint(x: minX, y: minY + 1))
  path.line(to: NSPoint(x: minX + 1, y: minY))
  path.line(to: NSPoint(x: minX + 4, y: minY + 3))
  path.line(to: NSPoint(x: minX + 7, y: minY))
  path.line(to: NSPoint(x: minX + 8, y: minY + 1))
  path.line(to: NSPoint(x: minX + 5, y: minY + 4))
  path.line(to: NSPoint(x: minX + 8, y: minY + 7))
  path.line(to: NSPoint(x: minX + 7, y: minY + 8))
  path.line(to: NSPoint(x: minX + 4, y: minY + 5))
  path.line(to: NSPoint(x: minX + 1, y: minY + 8))
  path.line(to: NSPoint(x: minX, y: minY + 7))
  path.line(to: NSPoint(x: minX + 3, y: minY + 4))
  path.close()
  return path
}

func tickPath(_ rect: NSRect) -> NSBezierPath {
  let minX = NSMinX(rect)
  let minY = NSMinY(rect)

  let path = NSBezierPath()
  path.move(to: NSPoint(x: minX + 2, y: minY))
  path.line(to: NSPoint(x: minX + 8, y: minY + 7))
  path.line(to: NSPoint(x: minX + 7, y: minY + 8))
  path.line(to: NSPoint(x: minX + 2.5, y: minY + 2.5))
  path.line(to: NSPoint(x: minX + 1.5, y: minY + 4.5))
  path.line(to: NSPoint(x: minX, y: minY + 4))
  path.close()
  return path
}

I constructed these paths by converting the vector points above into drawing code. Bear in mind, if you’re coming from the UIKit world, AppKit has a different coordinate system. The origin is in the bottom left and grows to the right and up.

Creating the tag circle control

I’m going to create a custom subclass of NSControl for the individual circles. Using NSControl means we inherit the target-action methods – so we can bind mouse clicks back to our controller.

We add an NSTrackingArea to the control in the init method, and if the mouse is inside the control we trigger a redraw using a property observer.

class TagControl: NSControl {
  var isSelected: Bool = false
  let color: NSColor

  var mouseInside: Bool = false {
    didSet {
      needsDisplay = true
    }
  }

  init (_ color: NSColor, frame: NSRect) {
    self.color = color
    super.init(frame: frame)

    if trackingAreas.isEmpty {
      let trackingArea = NSTrackingArea(
        rect: frame,
        options: [
          .activeInKeyWindow,
          .mouseEnteredAndExited,
          .inVisibleRect],
        owner: self,
        userInfo: nil)
      addTrackingArea(trackingArea)
    }
  }

  override func mouseEntered(with event: NSEvent) {
    mouseInside = true
  }

  override func mouseExited(with event: NSEvent) {
    mouseInside = false
  }

  override func mouseDown(with event: NSEvent) {
    if let action = action {
      NSApp.sendAction(action, to: target, from: self)
    }
  }

  override func draw(_ dirtyRect: NSRect) {
    color.set()

    let circleRect: NSRect

    if mouseInside {
      circleRect = dirtyRect
    } else {
      circleRect = NSInsetRect(dirtyRect, 3, 3)
    }

    let circle = NSBezierPath(ovalIn: circleRect)
    circle.fill()

    let strokeColor = color.shadow(withLevel: 0.2)
    let insetRect = NSInsetRect(circleRect, 1.0, 1.0)
    let insetCircle = NSBezierPath(ovalIn: insetRect)

    strokeColor?.set()
    insetCircle.fill()

    // Draw remove icon and return early
    if mouseInside && isSelected {
      let iconRect = NSInsetRect(dirtyRect, 6, 6)
      let iconPath = removePath(iconRect)
      NSColor.white.setFill()
      iconPath.fill()
      return
    }

    // Else draw the add icon
    if mouseInside {
      let iconRect = NSInsetRect(dirtyRect, 6, 6)
      let iconPath = addPath(iconRect)
      NSColor.white.setFill()
      iconPath.fill()
    }

    // Draw the tick icon
    if isSelected {
      let iconRect = NSInsetRect(dirtyRect, 6, 6)
      let iconPath = tickPath(iconRect)
      NSColor.white.setFill()
      iconPath.fill()
    }
  }
}

The tag view controller

There are many benefits to creating interfaces out of small, composable view controllers. You remove complexity, help to organise code and you have premade hooks to build and tear down your views.

Our tag view controller is going to construct the container view, add the tag controls and handle user actions. I’m programmatically creating this view controller, but you could just as easily load this from a NIB file.

class TagViewController: NSViewController {
  override func loadView() {
    view = NSView()
  }

  @objc func tagClicked(_ sender: AnyObject?) {
    guard let tag = sender as? TagControl else { return }
    print("Tag index clicked: \(tag.tag)")
    tag.isSelected.toggle()
  }

  override func viewDidLoad() {
    let redTag = TagControl(.red, frame: NSRect(x: 0, y: 0, width: 20, height: 20))
    let blueTag = TagControl(.blue, frame: NSRect(x: 24, y: 0, width: 20, height: 20))
    let greenTag = TagControl(.green, frame: NSRect(x: 48, y: 0, width: 20, height: 20))
    let yellowTag = TagControl(.yellow, frame: NSRect(x: 72, y: 0, width: 20, height: 20))
    let orangeTag = TagControl(.orange, frame: NSRect(x: 96, y: 0, width: 20, height: 20))
    let grayTag = TagControl(.gray, frame: NSRect(x: 120, y: 0, width: 20, height: 20))

    redTag.tag = 0
    redTag.target = self
    redTag.action = #selector(tagClicked(_:))

    blueTag.tag = 1
    blueTag.target = self
    blueTag.action = #selector(tagClicked(_:))

    greenTag.tag = 2
    greenTag.target = self
    greenTag.action = #selector(tagClicked(_:))

    yellowTag.tag = 3
    yellowTag.target = self
    yellowTag.action = #selector(tagClicked(_:))

    orangeTag.tag = 4
    orangeTag.target = self
    orangeTag.action = #selector(tagClicked(_:))

    grayTag.tag = 5
    grayTag.target = self
    grayTag.action = #selector(tagClicked(_:))

    view.addSubview(redTag)
    view.addSubview(blueTag)
    view.addSubview(greenTag)
    view.addSubview(yellowTag)
    view.addSubview(orangeTag)
    view.addSubview(grayTag)
  }
}

Putting it together

Now that we have the view controller, the tags and the icons we only need to add it to a new menu item. I recommend adding the tag controller as a child to your parent controller.

// Get the view from the tag view controller
let tagView = tagViewController.view
tagView.frame = NSRect(x: 0, y: 0, width: 280, height: 20)

// Create a menu item and add our tag view
let tagMenuItem = NSMenuItem()
tagMenuItem.target = self
tagMenuItem.view = tagView

contextMenu.addItem(tagMenuItem)

The results of our efforts are beautiful.

There is certainly more you could do with this. We haven’t added any accessibility features, and you probably wouldn’t have the same grudge I do against Interface Builder.

Finder also dismisses the menu on mouse down. For the purposes of demonstration, I haven’t added that to this version.

macos swift cocoa