Fix NSSegmentedControl Action Forwarding from Your NSToolbar

null

Two years ago, I wrote about how I implemented a toolbar with NSSegmentedControl, much like Apple's own apps have them since macOS 10.11 Yosemite. Last week I discovered my implementation was buggy: it did not work at all when you customize the toolbar to show "Icon Only", i.e. hide the label text.

  1. Original Approach
  2. Enabling/Disabling Segments
  3. Fixing the action dispatching bug (this post)

NSToolbarItems are not activated without labels

montage of two toolbars
NSToolbarItem handles clicks only when the label is visible

My interpretation of the situation is this: when you show labels, the NSToolbarItem is responsible for providing the label below the segments of the control. It also is responsible for dispatching an action on click. This works with "Icon and Text" and "Text Only". But once you hide the labels, the NSToolbarItems do not do anything at all – neither display, nor action dispatching.

The nice thing about splitting a single NSSegmentedControl into multiple NSToolbarItems was that you were able to bind each item to its own action, which magically corresponded to the segments of your control. Unlike other NSView-based controls, NSSegmentedControl's segments are not NSView based themselves, and thus cannot be NSControl types which handle their own action dispatching. Instead, they are NSCell subclasses that mostly handle drawing. NSSegmentedControl is the only one in the setup that has an action property that takes a selector. It's the only one responding to events.

Change the setup to make the NSSegmentedControl respond to events

So let's change everything and let the NSSegmentedControl do all the work.

Why transfer ownership of action dispatch instead of mix both approaches? – Because then you have two approaches to maintain. And I don't want to manually test all buttons in all toolbar configurations.

The course of action is thus:

  1. Make the universally functional NSSegmentedControl.action the main click handler.
  2. Leave NSToolbarItem.action = nil so the control doesn't get overridden.
  3. From the NSSegmentedControl.action handler, dispatch the real segment's action, depending on which the user did click.

If you're lazy, you'd be implementing the toolbar actions in your NSWindowController since that's guaranteed to be somewhere in the responder chain.

But we ain't no lazy folk in this here web space!

Also, the app I'm using this for, TableFlip, was creating the old setup in a factory outside of any view or window controller. And I wasn't going to sacrifice this one good design choice by pasting everything back into the window controller.

Questions raised:

There was no meaningful name I could give a general-purpose action that was going to respond to clicks to any segment. segmentSelected(_:) was the best name I could come up with: it is not an expression of a user intent, it's an implementation detail. The user intent, like "addRow(_:) to this table", is bound to the segments; the NSSegmentedControl grouping does not carry any meaning except the grouping in the UI. Again, an implementation detail. That's a good reason to stick to a method name that would otherwise be considered code smell.

Which object shall become the dispatcher? In my opinion, this setup is a deficit of AppKit's current API. So I think the best place to handle this is an NSSegmentedControl itself:

class DispatchingSegmentedControl: NSSegmentedControl {
    func wireActionToSelf() {
        self.target = self
        self.action = #selector(segmentSelected(_:))
    }

    @IBAction func segmentSelected(_ sender: Any) {
        // Dispatch according to `self.selectedSegment`
    }
}

The implementation for this could instead be part of a controller object. But this is not an event I want to respond to; it's an event the view component should transform in accordance with its internals, with its setup of segments, into a more specific event.

You could have a Array<(target: Any?, action: Selector)> that corresponds to the segments, and get to it using selectedSegment inside of segmentSelected(_:). Or you whip up a more elaborate configuration. I did the latter, and will show you my stuff in the remainder of this post.

My approach to define the segments

Note: I am still using Interface Builder to configure the NSSegmentedControl. So I do start with instances of these, with icons and segment widths pre-configured.

I do have to overlay the target–action-mechanism, though, similar to the overlay by NSToolbarItem used before.

Representation of the segment configurations

The view model type I introduces is called ToolbarSegmentedControlSegment. It represents the configuration of a segment:

struct ToolbarSegmentedControlSegment {

    var toolbarItemIdentifier: NSToolbarItem.Identifier
    var label: String
    var action: Selector
    var menuTitle: String?
    var menuImage: NSImage?

     init(toolbarItemIdentifier: NSToolbarItem.Identifier,
          label: String,
          action: Selector,
          menuTitle: String? = nil,
          menuImage: NSImage? = nil) {
        self.toolbarItemIdentifier = toolbarItemIdentifier
        self.label = label
        self.action = action
        self.menuTitle = menuTitle
        self.menuImage = menuImage
    }
}

It offers a factory method to get to a NSToolbarItem representation. This is still needed to display the labels in the toolbar, and to handle the toolbar overflow menu. This is what I was doing in the original NSToolbarItem setup, now moved here:

extension ToolbarSegmentedControlSegment {
    func toolbarItem() -> NSToolbarItem {
        // Do not set the item's action and rely on the ToolbarSegmentedControl instead.
        // This makes it easier to always be running into the same bugs, if any,
        // and not have 2 paths :)
        let item = NSToolbarItem(itemIdentifier: toolbarItemIdentifier)
        item.label = label
        item.updateMenuFormRepresentation()
        item.menuTitle = menuTitle
        item.menuImage = menuImage
        return item
    }
}

It can also execute the action dispatch:

extension ToolbarSegmentedControlSegment {
    func dispatchAction() {
        NSApp.sendAction(action, to: nil, from: nil)
    }
}

And that's it for that!

Wiring the segment configurations into the NSSegmentedControl

Lastly, the changes to the view component. Remember that I am using Interface Builder, so I rely on configuration after initialization. If you create your control programmatically, you can change things up a bit, like wire the action to self in the initializer, and take an array of segment configurations as initializer parameter as well.

I'm storing the segment configurations in an array and mutate it using the addSegment method, which acts as a factory.

class ToolbarSegmentedControl: NSSegmentedControl {

    var segments: [ToolbarSegmentedControlSegment] = []

    func addSegment(toolbarItemIdentifier: NSToolbarItem.Identifier,
                    label: String,
                    action: Selector,
                    menuTitle: String? = nil,
                    menuImage: NSImage? = nil) {
        guard !segments.contains(where: { $0.toolbarItemIdentifier == toolbarItemIdentifier }) else { return }

        let segment = ToolbarSegmentedControlSegment(
            toolbarItemIdentifier: toolbarItemIdentifier,
            label: label,
            action: action,
            menuTitle: menuTitle,
            menuImage: menuImage)
        segments.append(segment)
    }

    func toolbarItems() -> [NSToolbarItem] {
        return segments.map { $0.toolbarItem() }
    }

    func wireActionToSelf() {
        self.target = self
        self.action = #selector(segmentSelected(_:))
    }

    @IBAction func segmentSelected(_ sender: Any) {
        segments[selectedSegment].dispatchAction()
    }
}

With these convenient wrappers in place, I can setup a whole NSToolbarItemGroup:

// Get the view component from Interface Builder
let toolbarSegmentedControl: ToolbarSegmentedControl! = ...

// Configure segments
toolbarSegmentedControl.addSegment(
    toolbarItemIdentifier: .init(rawValue: "alignLeft"),
    label: "Left",
    action: #selector(TableInteractions.alignColumnLeft(_:)),
    menuTitle: "Align Left",
    menuImage: Alignment.left.image)
toolbarSegmentedControl.addSegment(
    toolbarItemIdentifier: .init(rawValue: "addCenter"),
    label: "Center",
    action: #selector(TableInteractions.alignColumnCenter(_:)),
    menuTitle: "Center",
    menuImage: Alignment.center.image)
toolbarSegmentedControl.addSegment(
    toolbarItemIdentifier: .init(rawValue: "alignRight"),
    label: "Right",
    action: #selector(TableInteractions.alignColumnRight(_:)),
    menuTitle: "Align Right",
    menuImage: Alignment.right.image)

toolbarSegmentedControl.wireActionToSelf()

let itemGroup = NSToolbarItemGroup(itemIdentifier: .init("alignmentGroup"))
itemGroup.view = toolbarSegmentedControl
itemGroup.subitems = toolbarSegmentedControl.toolbarItems()

// return `itemGroup` from  `toolbar(_:itemForItemIdentifier:willBeInsertedIntoToolbar:)`

Conclusion

Even though I hesitated at first, putting the NSSegmentedControl.action handler into a subclass itself and call it segmentSelected(_:) was the right choice. It's an implementation detail on the code level, and I want to encapsulate forwarding segment selection to in that type.

Configuring a segment and thus preparing a NSToolbarItem is quite a bit of ceremony – there are just so many properties you have to set. But I prefer this over the standard AppKit approach any day, which would have you write the property settings in a procedural fashion. I like that these configuration object initializers group all the options, and that I get a couple of methods in for free which I don't have to write elsewhere.

All in all, it's still surprisingly cumbersome to set up a NSToolbarItemGroup around a NSSegmentedControl in AppKit. But it's manageable, and now that you know the individual subitems's action dispatching cannot be trusted, you're better off with a self-made solution.

Browse the blog archive