3
votes

In iOS, a toolbar can be added to any view. In macOS however, it seems only possible to add a toolbar to a window.

I'm working on an app with a split view controller with a toolbar but the toolbar's items only have a meaning with respect to the right view controller's context.

E.g. let's say I have a text editor of some sort, where the left pane shows all documents (like in the Notes app) and the right pane shows the actual text which can be edited. The formatting buttons only affect the text in the right pane. Thus, it seems very intuitive to place the toolbar within that right pane instead of stretching it over the full width of the window.

Example window with split view controller

Is there some way to achieve this?
(Or is there a good UX reason why this would be a bad practice?)

I've noticed how Apple solved this problem in terms of UX in their Notes app: They still use a full-width toolbar but align the button items that are only related to the right pane with the leading edge of that pane.

Notes App Screenshot

So in case, there is no way to place a toolbar in a view controller, how can I align the toolbar items with the leading edge of the right view controller as seen in the screenshot above?


Edit:

According to TimTwoToes' answer and the posts linked by Willeke in the comments, it seems to be possible to use Auto Layout for constraining a toolbar item with the split view's child view. This solution would work if there was a fixed toolbar layout. However, Apple encourages (for a good reason) to let users customize your app's toolbar.

Thus, I cannot add constraints to a fixed item in the toolbar. Instead, a viable solution seems to be to use a leading flexible space and adjust its size accordingly.

2

2 Answers

2
votes

Initial Notes

It turns out this is tricky because there are many things that need to be considered:

  • Auto Layout doesn't seem to work properly with toolbar items. (I've read a few posts mentioning that Apple has classified this as a bug.)

  • Normally, the user can customize your app's toolbar (add and remove items). We should not deprive the user of that option.

Thus, simply constraining a particular toolbar item with the split view or a layout guide is not an option (because the item might be at a different position than expected or not there at all).

After hours of "hacking", I've finally found a reliable way to achieve the desired behavior that doesn't use any internal / undocumented methods. Here's how it looks:

Example animation of resizing flexible view


How To

  1. Instead of a standard NSToolbarFlexibleSpaceItem create an NSToolbarItem with a custom view. This will serve as your flexible, resizing space. You can do that in code or in Interface Builder:

    Toolbar customization in Interface Builder

  2. Create outlets/properties for your toolbar and your flexible space (inside the respective NSWindowController):

    @IBOutlet weak var toolbar: NSToolbar!
    @IBOutlet weak var tabSpace: NSToolbarItem!
    
  3. Create a method inside the same window controller that adjusts the space width:

    private func adjustTabSpaceWidth() {
        for item in toolbar.items {
            if item == tabSpace {
                guard
                    let origin = item.view?.frame.origin,
                    let originInWindowCoordinates = item.view?.convert(origin, to: nil),
                    let leftPane = splitViewController?.splitViewItems.first?.viewController.view
                    else {
                        return
                }
    
                let leftPaneWidth = leftPane.frame.size.width
                let tabWidth = max(leftPaneWidth - originInWindowCoordinates.x, MainWindowController.minTabSpaceWidth)
    
                item.set(width: tabWidth)
            }
        }
    

    }

  4. Define the set(width:) method in an extension on NSToolbarItem as follows:

    private extension NSToolbarItem {
    
        func set(width: CGFloat) {
            minSize = .init(width: width, height: minSize.height)
            maxSize = .init(width: width, height: maxSize.height)
        }
    
    }
    
  5. Make your window controller conform to NSSplitViewDelegate and assign it to your split view's delegate property.1 Implement the following NSSplitViewDelegate protocol method in your window controller:

    override func splitViewDidResizeSubviews(_ notification: Notification) {
        adjustTabSpaceWidth()
    }
    

This will yield the desired resizing behavior. (The user will still be able to remove the space completely or reposition it, but he can always add it back to the front.)


1Note:

If you're using an NSSplitViewController, the system automatically assigns that controller to its split view's delegate property and you cannot change that. As a consequence, you need to subclass NSSplitViewController, override its splitViewDidResizeSubviews() method and notify the window controller from there. Your can achieve that with the following code:

protocol SplitViewControllerDelegate: class {
    func splitViewControllerDidResize(_ splitViewController: SplitViewController)
}

class SplitViewController: NSSplitViewController {

    weak var delegate: SplitViewControllerDelegate?

    override func splitViewDidResizeSubviews(_ notification: Notification) {
        delegate?.splitViewControllerDidResize(self)
    }

}

Don't forget to assign your window controller as the split view controller's delegate:

override func windowDidLoad() {
    super.windowDidLoad()
    splitViewController?.delegate = self
}

and to implement the respective delegate method:

extension MainWindowController: SplitViewControllerDelegate {

    func splitViewControllerDidResize(_ splitViewController: SplitViewController) {
        adjustTabSpaceWidth()
    }

}
1
votes

There is no native way to achieve a "local" toolbar. You would have to create the control yourself, but I believe it would be simpel to make.

Aligning the toolbar items using autolayout is described here. Align with custom toolbar item described by Mischa.

The macOS way is to use the Toolbar solution and make them context sensitive. In this instance the text attribute buttons would enable when the right pane has the focus and disable when it looses the focus.