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 is true; the default for NSPanel is false. Release when closed, however, is ignored for windows owned by window controllers. Another strategy for releasing an NSWindow object is to have its delegate autorelease it on receiving a windowShouldClose(_:) message. (Apple Docs for NSWindow.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!