Programmatically Add Tabs to NSWindows without NSDocument
The Cocoa/AppKit documentation is very sparse when it comes to tabbing. You can make use of the native window tabbing introduced in macOS Sierra with a few simple method calls, though.
Conceptually, this is what you will need to do:
- Set
NSWindow.tabbingMode
to.preferred
so the tab bar will always be visible. - It suffices to call
NSWindow.addTabbedWindow(_:ordered:)
to add a window to the native tab bar and get everything tabs do for free. - Once you put
NSResponder.newWindowForTab(_:)
into the responder chain of the main window, the “+” button in the tab bar will be visible.
However, there are some caveats when implementing these methods naively. The plus button may stop working (no new tabs are added when you click it) and all default shortcuts are broken, their main menu items greyed out.
Update 2019-07-20: I wrote a follow-up with implementation details, pointing out problems with the shared window controller approach shown here, and then another post with a better approach!
How to Implement newWindowForTab
First, where to add @IBAction override func newWindowForTab(_ sender: Any?)
? That’ll be the event handler to create new tabs.
- If you use Storyboards, then put this into a
NSWindowController
subclass you own. That’s the simplest way to get to anNSWindow
to calladdTabbedWindow
for. - If you use Xibs, the
AppDelegate
will have a reference to the main window. You can put the method here. - If you use a programmatic setup, put it wherever you know the main
NSWindow
instance.
We’ll stick to NSWindowController
for the rest of this post, no matter how you create it:
class WindowController: NSWindowController {
@IBAction override func newWindowForTab(_ sender: Any?) {
// Implementing this will display the button already
}
}
How to Call addTabbedWindow
Once you have newWindowForTab(_:)
in place, add functionality to it: create a new NSWindow
and add it to the tab bar.
- If you use Storyboards, grab the instance via
NSWindowController.storyboard
; then instantiate a new window controller instance, for example usingself.storyboard!.instantiateInitialController() as! WindowController
. - If you use Xibs with a
NSWindowController
as the File’s Owner, create an identical window controller usingNSWindowController.init(windowNibName:)
. Use itswindow
property, discard the controller. - If you use Xibs with a
NSWindow
only and no controller, get the window from there. - If you use programmatic setup, well, instantiate a new object of the same window type as usual.
When you have the new window object, you can call addTabbedWindow
. Using the Storyboard approach, for example, turns the implementation into this:
class WindowController: NSWindowController {
@IBAction override func newWindowForTab(_ sender: Any?) {
let newWindowController = self.storyboard!.instantiateInitialController() as! WindowController
let newWindow = newWindowController.window!
self.window!.addTabbedWindow(newWindow, ordered: .above)
}
}
Fix the “+” Button and Main Menu
TL;DR: When you initialize a new window, set window.windowController = self
to make sure the new tab forwards the responder chain messages just like the initial window.
Take into account how events are dispatched. Main Menu messages are sent down the responder chain, and so is newWindowForTab
. NSApp.sendAction
will fail for standard events if the source of the call doesn’t connect up all the way – that means, at least up to your NSWindowController
, maybe even up to your AppDelegate
.
You have to make sure any additional window you add is, in fact, part of the same responder chain as the original window, or else the menu items will stop working (and be greyed-out/disabled). Similarly, the “+” button stops to work when you click on it.
If you forget to do this and run the code from above, it seems you can’t create more than two tabs. That’s the observation, but it’s not an explanation. You can always create more tabs, but only from the original window/tab, not the new one; that’s because the other tab is not responding to newWindowForTab
.
Remember: “The other tab” itself is just an NSWindow
. Your newWindowForTab
implementation resides in the controller, though. That’s up one level.
class WindowController: NSWindowController {
@IBAction override func newWindowForTab(_ sender: Any?) {
let newWindowController = self.storyboard!.instantiateInitialController() as! WindowController
let newWindow = newWindowController.window!
// Add this line:
newWindow.windowController = self
self.window!.addTabbedWindow(newWindow, ordered: .above)
}
}
Now newWindow
will have a nextResponder
. This will fix message forwarding.
Using Multiple Window Controllers
The solution above shows how to add multiple windows of the same kind, reusing a single window controller for all of them.
You can move up newWindowForTab
one level to another service object, say the AppDelegate
. Then you could manage instances of NSWindowController
instead of instances of NSWindow
. I don’t see why you would want to do that if you can share a single controller object.
I haven’t tried to do anything fancy besides, but you should be able to use different windows and different window controllers and group them in the tab bar of a single host window. You will then need to keep the window controller instances around, too.
Update 2019-07-21: In a follow-up post in this series, I show how to implement this in more detail.