6

Finder and Notes have a peculiar behaviour that I am seeking to reproduce. The ‘flexible space’ in the NSToolbar seems to take the dimensions of the split view into account. For instance, the first group of buttons aligns on the left side with the right side of the sidebar. The second group of icons aligns with the right side of the first column. When I widen the sidebar, the toolbar items move along with it.

Is it possible to reproduce this?

enter image description here


Solution

With the solution provided by @KenThomases, I have implemented this as follows:

final class MainWindowController: NSWindowController {
    override func windowDidLoad() {
        super.windowDidLoad()
        window?.toolbar?.delegate = self
        // Make sure that tracking is enabled when the toolbar is completed
        DispatchQueue.main.async {
            self.trackSplitViewForFirstFlexibleToolbarItem()
        }
    }
}

extension MainWindowController: NSToolbarDelegate {
    func toolbarWillAddItem(_ notification: Notification) {
        // Make sure that tracking is evaluated only after the item was added
        DispatchQueue.main.async {
            self.trackSplitViewForFirstFlexibleToolbarItem()
        }
    }

    func toolbarDidRemoveItem(_ notification: Notification) {
        trackSplitViewForFirstFlexibleToolbarItem()
    }

    /// - Warning: This is a private Apple method and may break in the future.
    func toolbarDidReorderItem(_ notification: Notification) {
        trackSplitViewForFirstFlexibleToolbarItem()
    }

    /// - Warning: This method uses private Apple methods that may break in the future.
    fileprivate func trackSplitViewForFirstFlexibleToolbarItem() {
        guard var toolbarItems = self.window?.toolbar?.items, let splitView = (contentViewController as? NSSplitViewController)?.splitView else {
            return
        }

        // Add tracking to the first flexible space and remove it from the group
        if let firstFlexibleToolbarItem = toolbarItems.first, firstFlexibleToolbarItem.itemIdentifier == NSToolbarFlexibleSpaceItemIdentifier {
            _ = firstFlexibleToolbarItem.perform(Selector(("setTrackedSplitView:")), with: splitView)
            toolbarItems.removeFirst()
        }

        // Remove tracking from other flexible spaces
        for flexibleToolbarItem in toolbarItems.filter({ $0.itemIdentifier == NSToolbarFlexibleSpaceItemIdentifier }) {
            _ = flexibleToolbarItem.perform(Selector(("setTrackedSplitView:")), with: nil)
        }
    }
}
Community
  • 1
  • 1
Eitot
  • 186
  • 1
  • 11

2 Answers2

7

When using macOS 11 or newer, you can insert NSTrackingSeparatorToolbarItem items to the toolbar, which will split up your toolbar in sections, aligned with the dividers of a NSSplitView object.

This example adds the new separator items to a toolbar that already contains the rest of the buttons, configured in Interface Builder or in code. The target splitview concerns a standard configuration of 3 splitviews, including a sidebar panel.

class WindowController: NSWindowController, NSToolbarDelegate {

    let mainPanelSeparatorIdentifier = NSToolbarItem.Identifier(rawValue: "MainPanel")

    override func windowDidLoad() {
        super.windowDidLoad()

        self.window?.toolbar?.delegate = self
    
        // Calling the inserts async gives more time to bind with the split viewer, and prevents crashes
        DispatchQueue.main.async {

            // The .sidebarTrackingSeparator is a built-in tracking separator which always aligns with the sidebar splitview
            self.window?.toolbar?.insertItem(withItemIdentifier: .sidebarTrackingSeparator, at: 0)
        
            // Example of a custom mainPanelSeparatorIdentifier
            // Index at '3' means that there are 3 toolbar items at the left side
            // of this separator, including the first tracking separator
            self.window?.toolbar?.insertItem(withItemIdentifier: mainPanelSeparatorIdentifier at: 3)
        }
    }

    func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
    
        if let splitView = (self.contentViewController as? NSSplitViewController)?.splitView {
        
            // You must implement this for custom separator identifiers, to connect the separator with a split view divider
            if itemIdentifier == mainPanelSeparatorIdentifier {
                return NSTrackingSeparatorToolbarItem(identifier: itemIdentifier, splitView: splitView, dividerIndex: 1)
            }
        }
        return nil
    }
}

If you want to add an extra separator, for example for an Inspector panel, simply insert an additional toolbar item identifier to the toolbar, and assign an extra NSTrackingSeparatorToolbarItem to another divider in the itemForItemIdentifier delegate function.

Ely
  • 8,259
  • 1
  • 54
  • 67
  • The solution works, but there is a problem: in this solution you add only one separator, but if I would like add more separator? if I would like that items moves like toolbar's item of Xcode? How can I do it? With this code, I don't know why, but it creates 2 separators. one visible and one under the splitviewcontroller's separator. how can I fix this? – Gabriele Quatela Feb 26 '21 at 17:06
  • @GabrieleQuatela I have extended the answer with an instruction for extra separators. Separators are sometimes unaligned on a toolbar if the corresponding split-view item section is too narrow. – Ely Feb 26 '21 at 19:39
2

You can do this with Apple-private methods, although that's not allowed in the App Store.

There's a private method, -setTrackedSplitView:, on NSToolbarItem. It takes an NSSplitView* as its parameter. You need to call it on the flexible-space toolbar item that you want to track a split view and pass it the split view it should track. To protect yourself against Apple removing the method, you should check if NSToolbarItem responds to the method before trying to use it.

Since the user can customize and re-order the toolbar, you generally need to enumerate the window's toolbar's items. For the first one whose identifier is NSToolbarFlexibleSpaceItemIdentifier, you set the split view it should track. For all other flexible-space items, you clear (set to nil) the split view to track. You need to do that when the window is first set up and again in the toolbar delegate's -toolbarWillAddItem: and -toolbarDidRemoveItem: methods. There's also another undocumented delegate method, -toolbarDidReorderItem:, where I've found it useful to update the toolbar.

Ken Thomases
  • 88,520
  • 7
  • 116
  • 154
  • Fantastic, this seems to work. Two comments: (1) `toolbarWillAddItem:` requires a slightly different behaviour, because the toolbar items are not updated when this method is called. Instead `setTrackedSplitView:` should be called on this net item directly. (2) Setting the remaining items to nil results in erratic behaviour and an error of kind ‘unrecognised selector’. – Eitot Dec 29 '16 at 16:10
  • 1
    You should only set the remaining **flexible-space** items to track `nil`. I assume that, under the hood, there's a specific subclass for those items. You need to clear the tracked split view for those because you may have previously set it for one of them and then they changed order. With regard to `-toolbarWillAddItem:`, my code does defer the update of the items to the end of the run loop cycle using `dispatch_async()` to the main queue. – Ken Thomases Dec 29 '16 at 16:30
  • Calling it in a `DispatchQueue.main.async()` works in my code only when the application is running, but on startup it does not seem to work. Can you given an example of how you implemented this? – Eitot Dec 29 '16 at 17:14
  • I think I got it now. Updated solution above. Thanks again! – Eitot Dec 29 '16 at 17:49