Implement NSWindow Tabbing with Multiple NSWindowControllers
After the fiasco of sharing a single NSWindowController
among multiple NSWindow
instances, one per tab, let’s look at a proper implementation.
For the working example, see the code on GitHub.
This approach adheres to a 1:1 relationship between window controller and window. That’s the way AppKit’s components are meant to be used, apparently. While we didn’t run into a ton of problems in the last post, you can safely assume that creating windows with window controllers performs at least some memory management tricks for us: If you look at the docs, you can see that NSWindow.windowController
and NSWindowController.window
are both not marked as weak or unsafe_unowned
. This would create a retain cycle. Yet in practice it doesn’t. I noticed this first when reading the doc comments about windows releasing themselves when closed:
The default for
NSWindow
istrue
; the default forNSPanel
isfalse
. Release when closed, however, is ignored for windows owned by window controllers. Another strategy for releasing anNSWindow
object is to have its delegate autorelease it on receiving awindowShouldClose(_:)
message. (Apple Docs forNSWindow.isReleasedWhenClosed
, emphasis mine.)
Whatever is actually going on, there is something going on behind the scenes.
To create a window and its window controller from scratch, my first suggestion is to extract your window from the Main.storyboard
. Then you don’t have to rely on storyboard scene names to recreate windows, which I don’t like that much. It keep creation of the window pretty simple, too. Or you do things programmatically, of course. Whatever course you take, it should be as easy as calling e.g. createWindowController()
to get a new instance.
func createWindowController() -> WindowController {
let windowStoryboard = NSStoryboard(name: "WindowController", bundle: nil)
return windowStoryboard.instantiateInitialController() as! WindowController
}
Managing window controller objects in memory
The initial window controller from your Main.storyboard
file was retained for you through the conventions of @NSApplicationMain
and loading the app from a storyboard. By extracting the window controller from there, the initial window will not have any special memory management anymore. It will be treated like any other instance you are going to create during the lifetime of your app: you have to retain a strong reference somewhere.
You could put the necessary code in AppDelegate
, but I often find my apps outgrowing this very quickly. I try to keep the application delegate as small as possible. Managing all window instances should be some other object’s job. You can call this WindowManager
or similar. For the sake of demonstration, I’ll call it TabService
, because it deals with tab creation first and foremost.
TabService
will have a collection of NSWindowController
references. As long as TabService
is around, it’ll make sure your windows will stay on screen and be wired to a window controller.
Closing tabs is the same as closing the tab’s window. If a tab, and by extension its window are closed, we want to deallocate the window controller, too. So it makes sense to listen to windowDidClose
notifications. That’s why I bundle any window, its window controller, and a NSNotificationCenter
subscription token in a little struct:
struct ManagedWindow {
/// Keep the controller around to store a strong reference to it
let windowController: NSWindowController
/// Keep the window around to identify instances of this type
let window: NSWindow
/// React to window closing, auto-unsubscribing on dealloc
let closingSubscription: NotificationToken
}
Keep in mind that NSNotificationCenter
will not unsubscribe block-based notification tokens when you deallocate them automatically. I use Ole Begemann’s NotificationToken
wrapper for this. You could just as well use selector–target-based subscriptions which do unsubscribe when the target is deallocated since macOS 10.11.
Create new tabs from the “+” button in the tab bar
Given an initial window on screen, we need a reference from the existing WindowController
to TabService
to tell it to create and store the objects for a new tab. Recall that the method for the “+” (add tab) button in macOS tab bars invoke newWindowForTab(_:)
on the window controller; the implementation I end up liking the most is this:
protocol TabDelegate: class {
func createTab(newWindowController: WindowController,
inWindow window: NSWindow,
ordered orderingMode: NSWindow.OrderingMode)
}
class WindowController: NSWindowController {
weak var tabDelegate: TabDelegate?
override func newWindowForTab(_ sender: Any?) {
guard let window = self.window else { preconditionFailure("Expected window to be loaded") }
guard let tabDelegate = self.tabDelegate else { return }
let newWindowController = self.storyboard!.instantiateInitialController() as! WindowController
tabDelegate.createTab(newWindowController: newWindowController,
inWindow: window,
ordered: .above)
inspectWindowHierarchy()
}
}
TabService
will conform to TabDelegate
, and I made the connection weak to adhere to the convention of declaring delegates. This avoids retain cycles. In this case, closing the window would break the cycle anyway, but I try not to get clever and break AppKit expectations without good reason, because from the tabDelegate
declaration, you cannot infer when a cycle would be broken. Makes it harder to understand the code.
Here’s an implementation of TabDelegate
that creates a ManagedWindow
and registers for the window closing notification:
class TabService {
fileprivate(set) var managedWindows: [ManagedWindow] = []
}
extension TabService: TabDelegate {
func createTab(newWindowController: WindowController,
inWindow window: NSWindow,
ordered orderingMode: NSWindow.OrderingMode) {
guard let newWindow = addManagedWindow(windowController: newWindowController)?.window
else { preconditionFailure() }
window.addTabbedWindow(newWindow, ordered: orderingMode)
newWindow.makeKeyAndOrderFront(nil)
}
private func addManagedWindow(windowController: WindowController) -> ManagedWindow? {
guard let window = windowController.window else { return nil }
// Subscribe to window closing notifications
let subscription = NotificationCenter.default
.observe(
name: NSWindow.willCloseNotification,
object: window) { [unowned self] notification in
guard let window = notification.object as? NSWindow else { return }
self.removeManagedWindow(forWindow: window)
}
let management = ManagedWindow(
windowController: windowController,
window: window,
closingSubscription: subscription)
managedWindows.append(management)
// Hook us up as the delegate for the window controller.
// You might want to do this in another place in your app,
// e.g. where the window controller is created,
// to avoid side effects here.
windowController.tabDelegate = self
return management
}
/// `windowWillClose` callback.
private func removeManagedWindow(forWindow window: NSWindow) {
managedWindows.removeAll(where: { $0.window === window })
}
}
Creating the first window in the app
Finally, to display the initial window and keep a strong reference to TabService
, the AppDelegate
will take care of the launch:
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var tabService: TabService!
func applicationDidFinishLaunching(_ aNotification: Notification) {
replaceTabServiceWithInitialWindow()
}
private func replaceTabServiceWithInitialWindow() {
let windowController = WindowController.create()
windowController.showWindow(self)
tabService = TabService(initialWindowController: windowController)
}
}
We can now easily add a “New Tab” main menu item and wire it to ⌘T for more convenient tab creation. I just use the newWindowForTab(_:)
selector directly for the menu item.
Allow tab/window creation when all windows are closed
If the last window is closed, though, there’s no responder to the newWindowForTab(_:)
action because all window controllers are gone! In than case, the AppDelegate
can chime in.
extension AppDelegate {
/// Fallback for the menu bar action when all windows are closed or
/// an unrelated window is open, e.g. the About panel or a Preferences window.
@IBAction func newWindowForTab(_ sender: Any?) {
if let existingWindow = tabService.mainWindow {
tabService.createTab(newWindowController: WindowController.create(),
inWindow: existingWindow,
ordered: .above)
} else {
replaceTabServiceWithInitialWindow()
}
}
}
extension TabService {
/// Returns the main window of the managed window stack.
/// Falls back the first element if no window is main. Note that this would
/// likely be an internal inconsistency we gracefully handle here.
var mainWindow: NSWindow? {
let mainManagedWindow = managedWindows
.first { $0.window.isMainWindow }
// In case we run into the inconsistency, let it crash in debug mode so we
// can fix our window management setup to prevent this from happening.
assert(mainManagedWindow != nil || managedWindows.isEmpty)
return (mainManagedWindow ?? managedWindows.first)
.map { $0.window }
}
}
Thanks to the responder chain, the window controller will answer first, if possible, and the AppDelegate
is a fallback when no other responder is available.
I took care of not just replacing the tabService
instance blindly when AppDelegate.newWindowForTab(_:)
is reached. It sounds reasonable to assume that the window controller will respond in almost all cases, and that this fallback will only be used when all tabs are closed. But that’s not the truth. The fallback will be called whenever there’s no other suitable responder in the responder chain, which happens at least when
- all tabs are closed, or
- an unrelated window is key window.
You don’t want to throw away all your user’s tabs when she accidentally hits ⌘T to create a new tab from your app’s Preferences window!
In general, your AppDelegate
may want to try to re-route incoming actions to interested objects when it’s responding to global NSApp.sendAction(_:to:from:)
messages.
That, or you don’t implement @IBAction
and NSResponder
callbacks in your AppDelegate
to explicitly avoid a global handler that isn’t aware of main menu item’s validation. It makes perfect sense to disable “New Tab” menu items when the tabbable window is not main and key window.
Conclusion
That’s all there is to it. It now just works. No weird behavior, no quick fixes necessary. This is likely the way we should be setting this up.
Again, check out the GitHub repo for the demo code and have fun making your app tabbable!