One NSWindow handling multiple NSDocument instances

Juggling documentsWhen you look into AppKit’s document architecture, you’ll see that although a document may be represented with more than one windows, the inverse is not true. There is only a setDocument: method in NSWindowController which implies that a window can only handle one document. Then, you may ask, what if you need one window to handle multiple documents? It would be convenient, for example, for client applications of some network service to support multiple server-side user accounts or even multiple services. Like for example a Twitter client that supports more than one account with each account’s data isolated from one another in its own data file.

Having a single window serving multiple documents isn’t so straightforward to do in Cocoa. But it is possible, if you are willing to override some of its document architecture mechanics. In fact that’s what I did and here’s how you can do it too.

What needs to be done

As I was scouring the web for an answer, I found a rough guide from CocoaDev. Being a collaboratively edited document, the article as I saw it then wasn’t very “clean” and has many rough spots so I’ll summarize it here for you:

  • The window controller needs to keep track of all documents that the window is currently handling.
  • Override the document property accessor methods of the window controller to be a no-op and returns nil.
  • Detach the window from the document before the window is closed and vice-versa.
  • You will need to provide your own NSDocumentController subclass and drive document closing yourself to ensure that the window is detached properly before the document is closed.
  • Override makeWindowControllers in your NSDocument subclass and don’t create any window controllers. Instead attach the document to an existing window controller if there is one already. You’d probably need another singleton that owns the window controller and lazy-instantiate it as needed.
  • You may need to provide a control of some kind to switch between active documents in the window.

How I did it

Here’s how I done multi-document windows in my current project, Scuttlebutt. This app is a Yammer client, which is somewhat similar to a Twitter or Facebook client but Yammer is geared more towards businesses and thus providing a secure online social network platform that is accessible only for employees of a company or organization.

One major feature of Scuttlebutt is support for multiple Yammer accounts – each Yammer account in Scuttlebutt maps into a document, having its own data bundle and Core Data persistence stack.  But users need to switch easily between accounts and just like any other “instant messaging” client, the user interface needs to be compact. Therefore Scuttlebutt only has one window handling multiple accounts and thus multiple document objects – looks pretty much like Twitterific or the official Twitter client.

The Window Side

All of the “special cases” for handling multiple documents are handled by the window controller and not the window class itself. The primary reason stems from the way the Document Architecture works: the setDocument: method is present in NSWindowController and not NSWindow.

The window controller maintains a set of document objects which are currently attached to the window. That is, all documents that the window has access to is kept in this set.

@interface BSMultiDocumentWindowController()

@property (nonatomic,strong,readonly) NSMutableSet* documents;

@end

// ---

@implementation BSMultiDocumentWindowController

// ...

-(NSMutableSet *)documents
{
    if (!_documents) {
        _documents = [[NSMutableSet alloc] initWithCapacity:3];
    }
    return _documents;
}
// ...
@end

The set of documents is private to the window controller since there are some additional processing that goes along with adding and removing documents from it. Accordingly I’ve added methods addDocument: and removeDocument: to attach or detach documents from the window controller (and by extension, to the window itself).

In the addDocument method, the window controller asks the document whether it can create a “default” view controller that functions as the document’s primary user interface component. This is analogous to NSDocument‘s makeWindowControllers method but it instead returns an NSViewController object. Finally it calls NSDocument‘s addWindowController: to register this window controller to the document. If you notice that there is a retain cycle caused by adding the document to the set, but this is not a problem.

-(void)addDocument:(NSDocument *)docToAdd
{
    NSMutableSet* documents = self.documents;
    if ([documents containsObject:docToAdd]) {
        return;
    }

    [documents addObject:docToAdd];

    if ([docToAdd respondsToSelector:@selector(newPrimaryViewController)]) {
        NSViewController* addedCtrl = [(id)docToAdd newPrimaryViewController];
        // … insert the controller's view into the window … 
    }

    [docToAdd addWindowController:self];
}

Likewise in removeDocument: the window controller removes the view controller before it unregisters it from the document. Lastly the document is removed from the set of documents which breaks the retain cycle. 

-(void)removeDocument:(NSDocument *)docToRemove
{
    NSMutableSet* documents = self.documents;
    if (![documents containsObject:docToRemove]) {
        return;
    }

    // ... remove the document's view controller and view ...  

    // finally detach the document from the window controller 
    [docToRemove removeWindowController:self];
    [documents removeObject:docToRemove];
}

Now that the window controller have addDocument: and removeDocument: methods, the inherited method setDocument: is no longer needed since managing the linkage to the document have moved to those former methods. Thus just override setDocument: to be a no-op. 

-(void)setDocument:(NSDocument *)document
{
    DebugLog(@"Will not set document to: %@",document);
}

Scuttlebutt has a notion of “current” document since there is only one document that can be “active” at a time. I reflect this by overriding the document method so that it returns the active document.

-(NSDocument*)document
{
    NSViewController* ctrl = self.currentContentViewController;
    if ([ctrl respondsToSelector:@selector(document)]) {
        return [(id) ctrl document];
    }
    return nil;
}

Lastly the each document needs to be detached from the window controller before the window closes. In addition, any references to those documents from any child view controllers will also need to be cleared in order to ensure a proper cleanup. The windowWillClose: method does just that. One caveat I found during debugging was that the window controller’s self pointer may become invalidated at any time within the method as soon as nothing else refers to it (I’m using ARC). Since we’re disconnecting references to documents, there have been cases where the window controller got deallocated mid-way of cleanup. To prevent that, I’ve added a strong pointer to self and use that pointer exclusively in the windowWillClose: method.

-(void) windowWillClose:(NSNotification*) notification
{
    NSWindow * window = self.window;
    if (notification.object != window) {
        return;
    }

    // let's keep a reference to ourself and not have us thrown away while we clear out references.
    BSMultiDocumentWindowController* me = self;

    // detach the view controllers from the document first
    me.currentContentViewController = nil;
    for (NSViewController* ctrl in me.contentViewControllers) {
        [ctrl.view removeFromSuperview];
        if ([ctrl respondsToSelector:@selector(setDocument:)]) {
            [(id) ctrl setDocument:nil];
        }
    }
    // then any content view
    [window setContentView:nil];    
    [me.contentViewControllers removeAllObjects];

    // disassociate this window controller from the document
    for (NSDocument* doc in me.documents) {
        [doc removeWindowController:me];
    }    
    [me.documents removeAllObjects];
}

The Document Side

In a multi-document window setup, the document doesn’t create any window controllers as the norm but expects an existing window controller will attach itself to it.  For this reason makeWindowController doesn’t create any window controllers but instead broadcasts an announcement saying that the document wants one. In turn some other object that owns window controllers should respond and attach that to the appropriate controller.

-(void)makeWindowControllers
{
    [[NSNotificationCenter defaultCenter] postNotificationName:BSDocumentNeedWindowNotification object:self];
}

For Scuttlebutt’s case the document has a default view controller and it can create that if a window controller wants one.

-(NSViewController *)newPrimaryViewController
{
    BSDocumentViewController* ctrl = [BSDocumentViewController new];
    ctrl.document = self;
    // … other initialization if needed … 
    return ctrl;
}

The Application Delegate

In Scuttlebutt, the owner of window controllers is the application delegate. It listens for documents wanting a window and then assigns it to the window, auto-creating one as needed.

@implementation BSAppDelegate

-(void)applicationWillFinishLaunching:(NSNotification *)notification
{
    NSNotificationCenter* nc = [NSNotificationCenter defaultCenter];
    [nc addObserver:self selector:@selector(handleDocumentNeedWindowNotification:) name:BSDocumentNeedWindowNotification object:nil];

}

-(void)handleDocumentNeedWindowNotification:(NSNotification *)notification
{
    BSDocument* doc = notification.object;
    [self.mainWindowController addDocument:doc];
    [self.mainWindowController.window makeKeyAndOrderFront:doc];
}

-(NSWindowController *)mainWindowController
{
    if (!_mainWindowController) {
        _mainWindowController = [BSMultiDocumentWindowController new];
    }
    return _mainWindowController;
}

@end

 

The Document Controller

Last but not least I wrote a custom subclass of NSDocumentController to handle document closure. The object is instantiated in MainMenu.xib and becomes the application-global document controller, overriding NSDocumentController‘s default instance.

The document controller overrides method closeAllDocumentsWithDelegate: didCloseAllSelector: contextInfo: that drives closure of all documents (typically when the application quits), ensuring orderly teardown of multiple document-window connections. That is all windows are disconnected from each document before the document is closed.

const char* const BSMultiWindowDocumentControllerCloseAllContext = "com.basilsalad.BSMultiWindowDocumentControllerCloseAllContext";

@implementation BSMultiWindowDocumentController {
    BOOL _didCloseAll;
}

#pragma mark NSDocument Delegate

- (void)document:(NSDocument *)doc shouldClose:(BOOL)shouldClose  contextInfo:(void  *)contextInfo
{
    if (contextInfo == BSMultiWindowDocumentControllerCloseAllContext) {
        DebugLog(@"in close all. should close: %@",@(shouldClose));
        if (shouldClose) {
            // work on a copy of the window controllers array so that the doc can mutate its own array.
            NSArray* windowCtrls = [doc.windowControllers copy];
            for (NSWindowController* windowCtrl in windowCtrls) {
                if ([windowCtrl respondsToSelector:@selector(removeDocument:)]) {
                    [(id)windowCtrl removeDocument:doc];
                }
            }

            [doc close];
            [self removeDocument:doc];
        } else {
            _didCloseAll = NO;
        }        
    }
}


#pragma mark NSDocumentController

- (void)closeAllDocumentsWithDelegate:(id)delegate didCloseAllSelector:(SEL)didCloseAllSelector contextInfo:(void *)contextInfo
{
    DebugLog(@"Closing all documents");
    _didCloseAll = YES;
    for (NSDocument* currentDocument in self.documents) {
        [currentDocument canCloseDocumentWithDelegate:self shouldCloseSelector:@selector(document:shouldClose:contextInfo:) contextInfo:(void*)BSMultiWindowDocumentControllerCloseAllContext];
    }

    objc_msgSend(delegate,didCloseAllSelector,self,_didCloseAll,contextInfo);
}

@end

Conclusion

So there you have it, a quick how-to on how to trick Cocoa’s document architecture so your application can handle multiple documents on a single window. Since in the app I’m working on doesn’t support user-controlled file load, save, or duplicate, there could be a few cases that I miss which requires special handling. So please share your experience in using these code snippets and let me know how it goes!



Avoid App Review rules by distributing outside the Mac App Store!


Get my FREE cheat sheets to help you distribute real macOS applications directly to power users.

* indicates required

When you subscribe you’ll also get programming tips, business advices, and career rants from the trenches about twice a month. I respect your e-mail privacy.

Avoid Delays and Rejections when Submitting Your App to The Store!


Follow my FREE cheat sheets to design, develop, or even amend your app to deserve its virtual shelf space in the App Store.

* indicates required

When you subscribe you’ll also get programming tips, business advices, and career rants from the trenches about twice a month. I respect your e-mail privacy.

13 thoughts on “One NSWindow handling multiple NSDocument instances

  1. Hi Sasmito! Thanks a lot for writing this article. Drawing from the article I got an implementation of a tabbed window that is now 90% working.

    My implementation of the NSDocumentController subclass turned out quite different from yours. Basically I call canCloseDocumentWithDelegate:shouldCloseSelector:contextInfo: only on the last document. If that returns YES, I recursively call closeAllDocumentsWithDelegate:didCloseAllSelector:contextInfo: until no documents are left to close. This way sheets appear one after the other.

    What I can’t get working is correctly closing the window when the close button is clicked. I.e. I want the user to confirm or skip save for all dirty tabs.

    If I return a document from the window controller’s document accessor, NSWindow will work with that document to confirm it can be closed. If so the window is closed and I don’t get a chance to process the other tabs.

    All this seems to originate from a private _close: method on NSWindow.

    Did you manage to implement something like this?

      1. I have rewired the standard close button:

        NSWindow *window = [self window];
        NSButton *closeButton = [window standardWindowButton:NSWindowCloseButton];

        [closeButton setTarget:self];
        [closeButton setAction:@selector(closeAllTabs:)];

        The closeAllTabs: method is very similar to what I do in the NSDocumentController subclass. It closes all document tabs one by one. Once a window has no tabs left, I close it.

        The Close menu item actually fakes a click on the close button. This now also closes tabs first.

  2. Hello Sasmito,

    I just wanted to say thanks for your blog post. It was just enough to get me headed in the right direction when I couldn’t get any other information on this topic.

    Although it took me a while, I’m kind of glad you left out just enough code since it really forced me to understand what is happening in this application.

    I’ve made a sample program based on this post, and I’ve managed to add the PSMTabBarControl to switch between documents.

    You (and anyone else reading) can grab my code from GitHub: https://github.com/baronpantaloons/MultiDocTest

    Its not yet complete (in that I haven’t fully hooked up the menu commands yet), but it should be enough to get others started.
    I’d really love it if anyone wanted to check it out and make sure I’m doing things correctly and cleaning everything up properly (I believe there are no memory leaks, but I could be wrong).

    Once again, thans for your post as without it I’d still be searching.

    Cheers,
    Sam

    1. What are you doing with the NSViewControllers returned by the document’s newPrimaryViewController method. Do these get cleaned up by the framework when the Document is destroyed? I’ve got all my documents working with a nice tab control, but I’m just trying to figure out how to clean everything up when I close my documents (or rather, close the tab for that document).

      I’m planning to post my code when Im done

  3. This article is the most relevant and direct on this subject so congrats for that, but I’m confused about a few aspects. I’ve started with a standard NSDocument app. I subclassed NSWindowController.  Do I create a xib for my NSWindowController class ie is that the window that will contain all of the document views?  If so, how do I hook up the usual NSDocument GUI elements to the window controller eg a text view, toolbar etc?  You refer to the “Document Side” and your code references “BSDocumentViewController”. Where does that view controller come from? 

    1. You’ll need to subclass NSWindowController and implement/override those methods as I described in the blog post. The BSDocumentViewController class is an example of a view controller that will use the document – you can name it whatever you want.

      1. Hi. I’m new to Cocoa programming so I apologize if the answers to my questions are obvious. My question wasn’t about the ‘name’ of the document’s view controller class.  Maybe it’s best if I summarise my questions because I’m having trouble filling in code that is missing from your example.1. I got confused by the reference to “_documents” in – (NSMutableSet *documents). I’m assuming that in the @implementation I need to put @synthesize documents=_documents.2. In ‘removeDocument’ you seem to have left out the code to remove the document’s view controller and view ie you have a placeholder comment but have omitted the code.  Is it meant to be obvious what the code is?3. I’m assuming that newPrimaryViewController in the NSWindowController subclass is to the method newPrimaryViewController in the NSDocument subclass.4. In -(NSDocument *document), how do you reference the currentContentViewController?  I don’t know how to get an instance of the viewController for the NSWindowController.5. The line ‘if ([ctrl respondsToSelector:@selector(document)]) ‘ – is that a recursion to the same method -(NSDocument *)document?  I’m just a bit confused about the references to ‘document’, ‘_documents’, ‘documents’ etc6. You’ve also got a reference to contentViewControllers which I’m assuming is an array to all of the view controllers of the NSWindowController.  Is there a separate method for allocating the view controllers to this array and where does it go?7. My original question was that I didn’t understand how to get an instance of the document’s view controller to define BSDocumentViewController as you haven’t declared that view controller anywhere in your code. Is it a reference to the NSDocument class’ view controller and where would that view controller be defined?8. A standard NSDocument project doesn’t come with an app delegate. How do I add one? (Sorry of this is too obvious. I can always research it if you think I’m being lazy).9. I’m assuming it’s also straightforward to instantiate a subclass of NSDocumentViewController.  I don’t know how to do this but I can look it up.10. For ‘doc.windowControllers’, is windowControllers a standard method?11. In the custom document controller I’m confused by the methods [(id)windowCtrl removeDocument:doc], [self removeDocument:doc] and self.documents. This is because ‘documents’ is a property of the NSWindowController subclass. Is there also a property ‘documents’ in the document controller subclass.  ‘removeDocument’ seems to be a separate method in the NSWindowController and NSDocumentController subclasses but there is no code for that method in the NSDocumentController subclass.As I said, sorry if this is all obvious but answering my questions will save me hours of trying to fill in the gaps.Many thanksGary

        1. Sorry, that all came out as a single block of text instead of in paragraphs as I had intended. If it’s too overwhelming, can you email me and I’ll set it out in a better format.

        2. Hi

          1. Yes there is.

          2. The view controllers are removed by the window controller just before the window closes. In turn, they should get deallocated and remove their own references to the document. Note that the document does not maintain inverse references to any view controllers.
          3. You don’t really need to create view controllers if you don’t need to. Just make sure that the document instance gets attached to a _window_ controller when makeWindowControllers is called.
          4. See #2.

          5. That’s just a way to check if a controller refers to a document.

          6. Yes. Where they get there is not relevant to the topic.

          7. You instantiate it somehow whenever a document is ready and needs a UI component – refer to makeWindowControllers
          8. There isn’t any NSDocumentViewController anywhere.

          9. Usually in MainMenu.xib

          10. No.

          11. NSWindowController does not have a “documents” property (the plural version). You need to subclass it and add the property yourself. Likewise the addDocument and removeDocument methods.
          Good luck.

          Thanks

          Sasmito Adibowo
          http://cutecoder.org

Leave a Reply to Pierre BernardCancel reply