Matt Ball

October 1, 2008

Building a Preferences Window


The preferences window is a key part of most Cocoa applications. There are a lot of different ways to create them, and I want to briefly walk through my way of doing things. Before getting started, it's worth noting that my general design mirrors that of Apple's private NSPreferences class. However, I've eliminated a lot of the verbosity and redundancy (as well as the unapproved-ness) of NSPreferences. With that out of the way, let's get started!

The Basics

Every preferences window has a few basic things in common. There's a window with a toolbar, which is used to move between different panes. In-keeping with the NSPreferences design, I've called these panes "modules." Each module has a title, an icon, a unique identifier, and a view, which will be displayed when the user clicks on the module's toolbar icon.

So, now that we've got a list of requirements for each module, let's formalize these requirements, using a formal protocol:

// All modules must conform to this protocol
@protocol MBPreferencesModule
@required
- (NSString *)title;
- (NSString *)identifier;
- (NSImage *)image;
- (NSView *)view;
@optional
- (void)willBeDisplayed;
@end

You'll notice that I've also added an optional -willBeDisplayed method -- I've done this to allow for lazy loading of preferences. Basically, if a module does all of its initial data loading inside of a -willBeDisplayed method, we can delay all of these loads until the module is activated, speeding up load time of the window itself. The difference may not be noticeable for most applications (which is why I've made the method optional), but for a module that needs to initialize a physical device or perform complex calculations, it can make a world of difference.

The Controller

Now know what methods our modules will implement. Great! But we still need to have an actual window and handle all the module switching. For this, let's create a subclass of NSWindowController:

@interface MBPreferencesController : NSWindowController {
    NSArray *_modules;
    id<MBPreferencesModule> _currentModule;
}

// We only want one preference window per application
+ (MBPreferencesController *)sharedController;

// All of the modules to place in the window
@property(retain) NSArray *modules;

// Convenience method
- (id<MBPreferencesModule>)moduleForIdentifier:(NSString *)identifier;
@end

Pretty basic, no? I've kept the public interface for this class very basic, since there shouldn't be much outside interaction involved. Let's start implementing our class!

To begin with, we need to set up all of our initialization code. Since we only want one shared instance (accessed using the +sharedController class method), I've pulled the majority of the code from Apple's singlton example. We do, however, need to deal with property synthesis and memory management:

@synthesize modules=_modules;

- (void)dealloc
{
    self.modules = nil;
    [super dealloc];
}

Now, we still don't have a window, do we? We need a window that has the standard appearance for a preference window (closable only, toolbar, hidden toolbar button, etc). Let's take care of that during initialization:

- (id)init
{
    if (self = [super init]) {
        // The initial size of the window doesn't matter
        // We'll be setting the window size later
        NSWindow *prefsWindow = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 300, 200) styleMask:(NSTitledWindowMask | NSClosableWindowMask) backing:NSBackingStoreBuffered defer:YES];
        [prefsWindow setShowsToolbarButton:NO];
        self.window = prefsWindow;
        [prefsWindow release];

        // Create the toolbar. This is handled below.
        [self _setupToolbar];
    }
    return self;
}

- (void)_setupToolbar
{
    NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@"PreferencesToolbar"];
    [toolbar setDisplayMode:NSToolbarDisplayModeIconAndLabel];
    [toolbar setAllowsUserCustomization:NO];
    [toolbar setDelegate:self];
    [toolbar setAutosavesConfiguration:NO];
    [self.window setToolbar:toolbar];
}

Now we just have a little more work to get our window in working order. NSToolbar requires its delegate (that's us!) to implement a few methods so it knows what items to display. Let's implement the standard ones:

- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar
{
    // Each module has a toolbar item
    NSMutableArray *identifiers = [NSMutableArray array];
    for (id<MBPreferencesModule> module in self.modules) {
        [identifiers addObject:[module identifier]];
    }

    return identifiers;
}

- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar
{
    // We start off with no items. 
    // We'll add them when we set the modules
    return nil;
}


- (NSArray *)toolbarSelectableItemIdentifiers:(NSToolbar *)toolbar
{
    // Every toolbar icon is selectable
    return [self toolbarAllowedItemIdentifiers:toolbar];
}

- (NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSString *)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag
{
    id<MBPreferencesModule> module = [self moduleForIdentifier:itemIdentifier];

    NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:itemIdentifier];
    if (!module)
        return [item autorelease];

    // Set the attributes of the item
    [item setLabel:[module title]];
    [item setImage:[module image]];
    [item setTarget:self];
    [item setAction:@selector(_selectModule:)];
    return [item autorelease];
}

Module Switching

This gives us a window with a toolbar, but right now it doesn't do anything. We need to deal with switching between the views. But before we can do that, we need to implement the convenience method from the interface:

- (id<MBPreferencesModule>)moduleForIdentifier:(NSString *)identifier
{
    // Compare identifier against each module's identifier
    for (id<MBPreferencesModule> module in self.modules) {
        if ([[module identifier] isEqualToString:identifier]) {
            return module;
        }
    }
    return nil;
}

That's pretty self-explanatory, and it leaves us free to implement our view switching methods. I've broken this into two methods: -_selectModule:, which will be called whenever a toolbar item is clicked, and -_changeToModule:, which handles the actual view switching. This allows us to change the view programmatically as well, which will come into play in a bit.

- (void)_selectModule:(NSToolbarItem *)sender
{
    // This action should only be called by toolbar items
    if (![sender isKindOfClass:[NSToolbarItem class]])
        return;

    // Find the module which is represented by the item
    id<MBPreferencesModule> module = [self moduleForIdentifier:[sender itemIdentifier]];
    if (!module)
        return;

    [self _changeToModule:module];
}

- (void)_changeToModule:(id<MBPreferencesModule>)module
{
    [[_currentModule view] removeFromSuperview];

    // The view which will be displayed
    NSView *newView = [module view];

    // Resize the window
    // Be sure to keep the top-left corner stationary
    NSRect newWindowFrame = [self.window frameRectForContentRect:[newView frame]];
    newWindowFrame.origin = [self.window frame].origin;
    newWindowFrame.origin.y -= newWindowFrame.size.height - [self.window frame].size.height;
    [self.window setFrame:newWindowFrame display:YES animate:YES];

    [[self.window toolbar] setSelectedItemIdentifier:[module identifier]];
    [self.window setTitle:[module title]];

    // Call the optional protocol method if the module implements it
    if ([(NSObject *)module respondsToSelector:@selector(willBeDisplayed)]) {
        [module willBeDisplayed];
    }

    // Show the view
    _currentModule = module;
    [[self.window contentView] addSubview:[_currentModule view]];

    // Autosave the selection
    [[NSUserDefaults standardUserDefaults] setObject:[module identifier] forKey:@"MBPreferencesSelection"];
}

Looks good. The only thing that sticks out is this line:

// Autosave the selection
[[NSUserDefaults standardUserDefaults] setObject:[module identifier] forKey:@"MBPreferencesSelection"];

Something I haven't mentioned until now is the fact that standard preference windows remember their selection, even across launches. We need to do the same, so this line makes sure that we always store what the most recently-activated module was. We'll look at restoring this state in a moment.

The Toolbar, Revisited

If you were to compile your app and open the window, you'd notice something striking: The toolbar is empty. Well, the reason for this is simple. Remember this?

- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar
{
    // We start off with no items. 
    // Add them when we set the modules
    return nil;
}

Ah, yes. We never added any items to the toolbar. Since we don't know anything about the modules when we create our toolbar, we need to deal with that later. Like, say, when we set the modules? Let's override the accessor for the property!

- (void)setModules:(NSArray *)newModules
{
    if (newModules == _modules)
        return;

    if (_modules) {
        [_modules release];
        _modules = nil;
    }

    if (!newModules)
        return;

    _modules = [newModules retain];

    // Reset the toolbar items
    NSToolbar *toolbar = [self.window toolbar];
    if (toolbar) {
        NSInteger index = [[toolbar items] count]-1;
        while (index > 0) {
            [toolbar removeItemAtIndex:index];
            index--;
        }

        // Add the new items
        for (id<MBPreferencesModule> module in self.modules) {
            [toolbar insertItemWithItemIdentifier:[module identifier] atIndex:[[toolbar items] count]];
        }
    }

    // Change to the correct module
    // This is where we restore the autosaved info
    if ([self.modules count]) {
        id<MBPreferencesModule> defaultModule = nil;

        // Check the autosave info
        NSString *savedIdentifier = [[NSUserDefaults standardUserDefaults] stringForKey:MBPreferencesSelectionAutosaveKey];
        defaultModule = [self moduleForIdentifier:savedIdentifier];

        if (!defaultModule) {
            defaultModule = [self.modules objectAtIndex:0];
        }

        [self _changeToModule:defaultModule];
    }
}

It's a bit long, but it's all fairly basic. The good news is, we only have one more method to override before we have our preference window in working order! Even better, it's ridiculously short. Preference windows always open centered on the screen. So, we need to override NSWindowController's -showWindow: method:

- (void)showWindow:(id)sender
{
    [self.window center];
    [super showWindow:sender];
}

That's it! We're done!

Who Cares?

"This is all well and good, but how can I use this in my application?" you ask? Well, good news! It's ridiculously easy. Simply create your modules, making sure they conform to the MBPreferencesModule protocol (I prefer using NSViewController subclasses) and pass them to the controller using [[MBPreferencesController sharedController] setModules:]. That's it! If you want a better idea of how to use this, I've included a sample project with the code below.

Download Sample Project


15 Comments

  1. Cool tutorial Matt. If you wanted to clean up the code a little bit you could change the - (void)setModules:(NSArray *)newModules method by having it check if (!newModules) and then do an early return. It will pull the line of execution in the method to the left and make it a little easier to read.


    October 2, 2008 @ 09:17 AM
  2. It looks great (except its 10.5+, but that looks fixable), but the code includes no license, except the default "All rights reserved". I'd love to be able to just drop this in to my app (as I'm writing a prefs window right now), but without an explicit license I can't use it in a commercial app - maybe that's your intent, maybe not...?


    November 18, 2008 @ 08:10 PM
  3. Peter: All my code, unless specified otherwise, is released under the MIT license. I forgot to include the notice in the files (thanks for the heads up!)

    I just uploaded a new version which includes the proper licensing info (the rest of the source is identical).


    November 22, 2008 @ 10:55 PM
  4. Nice tutorial! Just a quick question. Will the selected toolbar items have the same indented look that most application's preference windows have?


    December 6, 2008 @ 04:21 PM
  5. @Nathaniel: Yep, the toolbar looks and behaves like a normal preference window's does. The toolbarSelectableItemIdentifiers: method ensures that the selected item gets the appearance you're talking about.


    December 13, 2008 @ 11:32 PM
  6. This is awesome! I just spent a few hours writing something to do the same thing. My code was real ugly and buggy. I happily switched it out for yours. Thanks Matt!


    January 27, 2009 @ 08:45 PM
  7. Thanks for sharing Matt, it was very useful. Here are some minor corrections for the sample project: 1) in file "TestMobileMeViewController.h" you should add #import "MBPreferencesController.h" at the top and 2) protocol name is missing after @interface TestMobileMeViewController : NSViewController


    February 17, 2009 @ 07:07 AM
  8. ALEXander Walter

    Great piece of code Matt. Could you tell me how to insert this into the responder chain? I cannot get that to work, I am afraid...


    April 26, 2009 @ 05:52 PM
  9. Looking for Florida university sport? Funny fantasy sport names is here http://blogprosport.com/ ! .


    May 21, 2009 @ 03:36 AM
  10. Your most actual amateur resources here http://amateur.goodnanoav.com/


    May 31, 2009 @ 07:14 AM
  11. flor385

    Exactly what I was looking for! Thanks for this tutorial / sample project....


    June 11, 2009 @ 11:19 AM
  12. Thank you for post. It is very imformative stuff. I love to browse mattballdesign.com!

    natural teeth whitening


    June 17, 2009 @ 11:44 AM
  13. Hi!

    Thanks for the article! Nice journal software, btw. Made me reply (as opposed to others). Is it available?

    One tiny thing:

    @protocol MBPreferencesModule

    could be restricted to:

    @protocol MBPreferencesModule

    which would make the ugly type cast here...

    if ([(NSObject *)module respondsToSelector:@selector(willBeDisplayed)]) {
    

    unnecessary while not loosing a lot of flexibility (who would use an object not inheriting from NSObject?).

    Greetings, Dirk


    June 26, 2009 @ 02:12 PM
  14. Questions and answers about Beach hotel southern california and On the beach poster http://beach.goodnanoav.com/


    June 28, 2009 @ 03:05 AM
  15. Very nice site!


    July 2, 2009 @ 06:24 AM