Making Inspectors for NSDocuments


By Mark Dalrymple on February 4, 2005.
One of the UI concepts that Mac OS X inherited from NeXTstep is the idea of an inspector window. If you've used Interface Builder or KeyNote, you've used an inspector. It's a single shared window that updates based on what is selected, and it changes when documents change. Inspector windows can be annoying at times (especially if you want two of them open simultaneously) but they do make for a generally nice, and compact, user experience.

The problem

While developing a document-based application, I wanted to have an overview window that shows a thumbnail of the document. The user can use this overview to scroll the main document around. (I call it a panner) I wanted one single floating window that will change what it's drawing based on what document window is currently frontmost. Here's what it looks like:

Usually when "window" and "document" are used in the same sentence, the answer is to use NSWindowController to manage the window. Unfortunately there's not an obvious place in the NSDocument / NSWindowController to put the code to update a shared window when the current document changes.

Luckily NSWindowController does most of the work for us, so just a little glue here and there will get the shared inspector behavior working.

NSWindowController

NSWindowController is one of the classes that make up Cocoa's document architecture, along with NSDocument and NSDocumentController. For an important class, there's surprisingly little written about it. The CocoaDev wiki has a number of pages on NSWindowController, but the books I have only have a page or two that cover NSWndowController. Usually it's along the lines of "make a controller, hook up the window outlet, and then tell it to load. OK, Moving on to our next topic..." Most of the discussion is along the lines of "here is how to make a document that has multiple windows" rather than "here is how to share a window amongst multiple documents".

While browsing CocoaDev, I came across MakingInspectorWindows, which has all the information needed to make an inspector. The trick is to use NSWindowController to load and manage the overview window. Then have the document class post a notification whenever a new document window comes to the front. The overview window controller thingie then listens for these notifications, and when it gets a notification, the controller updates the overview panel appropriately.

Stuff for the Document Class

The first bit of business is adding the logic to my application's document class (a subclass of NSDocument). The document will be posting two different kinds of notifications: one that a document has become active, and one that the document has become inactive. Here's an excerpt from the document header file which has the declarations of the names of the notifications that are going to be posted
// BWGraphDocument.h -- primary document class holding the chart data

#import <Cocoa/Cocoa.h>

extern NSString *BWGraphDocument_DocumentDeactivateNotification;
extern NSString *BWGraphDocument_DocumentActivateNotification;

@interface BWGraphDocument : NSDocument
{
    // ... blah
}

// ... more blah

@end // BWGraphDocument
Now we just need to figure out when to post these notifications. NSWindow has some handy notifications for knowing when windows has come to the front (NSWindowDidBecomeMainNotification) and when it's gone back down the window stack (NSWindowDidResignMainNotification).

We could have the document (or the inspector for that matter) Listen to all of these NSWindow notifications whizzing past, and have it sift out which notifications pertain to the document windows. That's kind of a pain, especially because there's no one-stop-shopping for getting the document's window.

Luckily, the main document window has its delegate set to be the document that it is associated with. When a window posts one of those notifications, it also invokes similarly named methods on the delegate. This is perfect for our needs. The document class just needs to implement these methods to track the active/inactive transitions of itse window, and it won't be distracted by any other window's notifications.

Here is a helper function (puts the notification code in one place), along with the delegate method implementations

- (void) postNotification: (NSString *) notificationName
{
    NSNotificationCenter *center;
    center = [NSNotificationCenter defaultCenter];
    
    [center postNotificationName: notificationName
            object: self];

} // postNotification


- (void) windowDidBecomeMain: (NSNotification *) notification
{
    [self postNotification: 
              BWGraphDocument_DocumentActivateNotification];

} // windowDidBecomeMain


- (void) windowDidResignMain: (NSNotification *) notification
{
    [self postNotification: 
              BWGraphDocument_DocumentDeactivateNotification];

} // windowDidResignMain


- (void) windowWillClose: (NSNotification *) notification
{
    [self postNotification: 
              BWGraphDocument_DocumentDeactivateNotification];

} // windowDidClose

Stuff for the Window Controller

A bit of wisdom I've picked up and have been applying with some success is "For every unique kind of window you have, you should have one unique kind of window controller." Window controllers are really handy places for isolating the "here is how you handle this kind of window" details. Put your IBOutlets, NSArrayControllers, and other assorted marklar in a window controller subclass. Then provide an API so that users of the window can give it the objects to manipulate (whether handing off an object, or setting up bindings). I've seen suggestions that go to the extreme of moving all of your document's UI logic into window controllers and leave the document there just to handle file I/O. I don't go to quite that extreme.

So, for each of my different palette windows in the app, each of them has their own NSWindowController subclass (I currently have four, for different kinds of inspectors). I've got a lot of this code in a common base class (BWWindowController) and have my palette window controllers inherit from it. There are some reflexive attribute methods (meaning some things the subclasses implement to supply some of the more generic code with specific details) that the subclasses implement. Those are peripheral details, and so I'll show things generally hardwired for a single kind of palette window. But it is pretty easy to refactor and genericize stuff.

Here is the interface for the panner window controller class:

// BWPannerWindowController.h -- controls the panner window

#import <Cocoa/Cocoa.h>

@class BWPannerView;

@interface BWPannerWindowController : NSWindowController
{
    IBOutlet BWPannerView *pannerView;
}

+ (BWPannerWindowController *) sharedController;

- (void) show;

@end // BWPannerWindowController
Its public API is +sharedController, which returns a shared instance (the Singleton pattern for folks who like that kind of stuff). There's nothing that prevents you from having multiple inspector windows up, but in this case a Singleton makes sense because a design requirement is that there is just one panner window open at a time.

There's also a -show method, which is what clients call when handling actions like "show panner window". Typical usage by clients is:

// from the application controller class

- (IBAction) showPannerWindow: (id) sender
{
    [[BWPannerWindowController sharedController] show];

} // showPannerWindow
There's also an IBOutlet in the BWPannerWindowController class that has a reference to a BWPannerView, which is an NSView subclass that shows the contents of an NSScrollView and lets the user scroll it around.

Now for the implementation. The code starts out like you'd expect, including the necessary header files, and declaring a static pointer to point to the shared instance.

// BWPannerWindowController.m

#import "BWPannerWindowController.h"
#import "BWGraphDocument.h"
#import "BWPannerView.h"

static BWPannerWindowController *g_controller;

@implementation BWPannerWindowController
The first method is +sharedController, which uses NSWindow's initWithWindowNibName to actually load the nib file, which lives in the application bundle. The newly created controller gets registered with the default notification center to receive the two notifications that will be posted from the document.
+ (BWPannerWindowController *) sharedController
{
    if (g_controller == nil) {

        g_controller = [[BWPannerWindowController alloc] 
                           initWithWindowNibName: @"BWPannerWindow"];

        NSNotificationCenter *center;
	center = [NSNotificationCenter defaultCenter];
        
        [center addObserver: g_controller
                selector: @selector(documentActivateNotification:)
                name: BWGraphDocument_DocumentActivateNotification
                object: nil];
        
        [center addObserver: g_controller
                selector: @selector(documentDeactivateNotification:)
                name: BWGraphDocument_DocumentDeactivateNotification
                object: nil];
    }

    return (g_controller);

} // sharedController

The cleanup code is pretty simple. Just unhook ourselves from the notification center.

- (void) dealloc
{
    NSNotificationCenter *center = [NSNotificationCenter defaultCenter];

    [center removeObserver: self
            name: nil
            object: nil];

    [super dealloc];

} // dealloc

The notification code is pretty straightforward too. When a document posts the activate notification, the panner controller sets its document to be the document that posted the notification. If the deactivate notification comes around, it clears out the current document.

- (void) documentActivateNotification: (NSNotification *) notification
{
    NSDocument *document = [notification object];
    [self setDocument: document];

} // documentActivateNotification


- (void) documentDeactivateNotification: (NSNotification *) notification
{
    [self setDocument: nil];

} // documentDeactivateNotification
setDocument: is where the real meat of the work happens, reacting to the document change to update the panner. Everything else is life-support for this method.
- (void) setDocument: (NSDocument *) document
{
    [super setDocument: document];

    NSScrollView *view;
    view = [document valueForKey: @"layerViewScrollView"];

    [pannerView setScrollView: view];

} // setDocument
-setDocument: extracts from the scrollview being used by the document (yeah, this is kind of a nasty way to do it, but the app is still in its early bootstrap phase). -setDocument: then updates the pannerView in the palette window to use the new scroll view. setDocument is also an NSWindowController method, so let it have first crack at doing its work with the document.

And finally, -show is what makes the panner window visible. It sets the current document so that the panner gets hooked up if there's already a document visible.

- (void) show
{
    [self setDocument: [[NSDocumentController sharedDocumentController]
                           currentDocument]];
    [self showWindow: self];

} // show

Filling in Some Details

There's two last bits of detail to handle. The first deals with window placement. NSWindowController happily handles window placement for you, defaulting to cascading the managed window with previously placed windows. That's not good behavior for a floating palette. The user expects it to stay in the last place it was put. I put the code to handle change the default placment behavior into windowDidLoad, which is the NSWindowController equivalent of awakeFromNib.
- (void) windowDidLoad
{
    [super windowDidLoad];

    [self setShouldCascadeWindows: NO];
    [self setWindowFrameAutosaveName: @"pannerWindow"];

    [self setDocument: [self document]];

} // windowDidLoad
setShouldCascadeWindows: tells the window controller to not cascade the windows. The autosave name set in Interface Builder doesn't get used by NSWindowController, so that needs to be set explicitly. Lastly the document is set again, just to make sure that the panner gets set up with the current document. (I forget if it's really necessary here since it's already been done in the show method, but it doesn't seem to be hurting anything.)

The last bit of code is making a good title for the panner window. It should reflect the name of the current document ("Overview of Untitled" or "Overview of Bear's Picnic"), so you can tell at a glance that the panner is going to affect the document you expect. windowTitleForDocumentDisplayName: is called by NSWindowController to get the name to show in the window:

- (NSString *) windowTitleForDocumentDisplayName: (NSString *) displayName
{
    NSString *string;

    string = [NSString stringWithFormat: 
                   NSLocalizedString(@"Overview of %@", @""), displayName];
    return (string);

} // windowTitleForDocumentDisplayName

And that's pretty much it. I've used this same pattern with the other shared palette windows in my application, and it works very smoothly.

Resources



borkware home | products | miniblog | rants | quickies | cocoaheads
Advanced Mac OS X Programming book

webmonster@borkware.com