Fix NSSegmentedControl Action Forwarding from Your NSToolbar
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.
- Original Approach
- Enabling/Disabling Segments
- Fixing the action dispatching bug (this post)
- Fixing action dispatching within overflow menus
NSToolbarItems are not activated without labels
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 NSToolbarItem
s do not do anything at all – neither display, nor action dispatching.
The nice thing about splitting a single NSSegmentedControl
into multiple NSToolbarItem
s 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:
- Make the universally functional
NSSegmentedControl.action
the main click handler. - Leave
NSToolbarItem.action = nil
so the control doesn’t get overridden. - 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:
- Where to put the general-purpose action?
- How to delegate to a single segment’s real action?
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 {
let item = NSToolbarItem(itemIdentifier: toolbarItemIdentifier)
item.label = label
item.action = action // Needed for the overflow menu (2024-02-02)
item.menuTitle = menuTitle
item.menuImage = menuImage
item.updateMenuFormRepresentation()
return item
}
}
Update 2024-02-02: The action needs to be set, too, even though the segment takes care of action dispatching most of the time. The one time it doesn’t take care of action dispatching is when the toolbar item is in its overflow menu representation state.
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.