Matt Ball

Posts tagged with “preferences”

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


16 Comments