MacDevCenter    
 Published on MacDevCenter (http://www.macdevcenter.com/)
 See this if you're having trouble printing code examples


Adding a New Style Preferences Window to Your App, Part 1

by Martin Redington
01/20/2006

A preferences window is a staple feature of most Mac OS X applications.

For applications with a small number of preference settings, the required controls can be grouped within a single view, but for more complex applications, the Apple human interface guidelines recommend "using a toolbar within the preferences window in which each item in the toolbar changes the content of the main window," in the same way as the Finder, Safari, Mail, iChat, and many other Apple applications. Figure 1 below shows the Safari preferences window, with the Advanced preference pane selected.

In this pair of articles, I will show you how to add a new style preferences window to your application, that behaves in all respects exactly like the Apple preferences windows. In this part, I will cover the scaffolding necessary to support a new style preferences window. In part two, I will show you how to create the actual preference panes.

Using SS_PrefsController

Laziness is one of the three great virtues of a computer programmer, so I was very happy to discover that Matt Gemmell has already written the SS_PrefsController package, which does most of what we need.

To implement a preferences window using SS_PrefsController, you define each preference pane its own separate bundle, which contains a nib file with a custom NSView for the preference pane, and a class (which we will refer to as the PreferencePaneController) that implements the SS_PreferencePaneProtocol. The protocol's methods allow the controller to access necessary information about pane, such as the name and icon to display in the toolbar, and the tooltip to use.

figure

Figure 1. The new style preferences window from Safari 2.0.2

The preferences window, and the set of preference panes, are managed by an instance of the SS_PrefsController class. When initializing this object, we tell it where to look for the preference pane bundles, and what suffix it should expect them to have. As if by magic, our modern style preferences window, complete with toolbar, icons, and automatic window resizing will appear.

However, the SS_PrefsController approach leaves a little to be desired.

Firstly, as implemented, every PreferencePaneController needs to implement the SS_PreferencePaneProtocol. This results in a lot of duplication, which could be avoided if the implementations of the protocol's methods could be inherited from a superclass. Furthermore, rather than hardcoding the underlying preference pane variables, we should be able to read them in as parameters from a configuration file of some kind.

Secondly, as written, SS_PrefsController does not highlight the icon for the selected pane correctly. This highlighting provides useful feedback to the user about which preferences pane is currently selected.

I will discuss how to remedy both of these issues below.

A New Application

Not every application needs a preferences window, but every preferences window needs an application.

In Xcode, create a new Cocoa application, called "NewPreferencesExample." To provide an entry point for the application, we add a new Objective-C class, NPEController, to the Classes group in the "Groups and Folders" pane in XCode.

NPEController has two preferences, preferenceOne (a Boolean on/off preference) and preferenceTwo (an integer preference, with three possible values--one, two, or three), as well as a handle to the NSUserDefaults object, and accessors and mutators (getters and setters) for each preference. Note that we don't have any class variables corresponding to preferenceOne and preferenceTwo; instead, we have a handle to an NSUserDefaults object.

The source for NPEController.h is as follows:

#import <Cocoa/Cocoa.h>

@interface NPEController : NSObject {
    NSUserDefaults *_defaults;
}

- (BOOL) preferenceOne;
- (void) setPreferenceOne:(BOOL) preferenceOne;

- (int) preferenceTwo;
- (void) setPreferenceTwo:(int) preferenceTwo;

@end

The implementation, NPEController.m, is a little more complicated. In the awakeFromNib method, we obtain the user defaults, and register default values for each of our preferences. For neatness' sake, we also add a dealloc method, which releases the NSUserDefaults object after saving it to disk with the synchronize message. We also add applicationWillTerminate, an NSApplication delegate method, so that our controller is released (and hence dealloc is called, saving our preferences, just before we quit).

The accessor and mutator methods read from and write to the NSUserDefaults object directly, with setPreferencesTwo using an assertion to verify that the supplied value is legal.

#import "NPEController.h"

#define PREF_ONE_KEY @"preferenceOne"
#define PREF_TWO_KEY @"preferenceTwo"

@implementation NPEController

- (void) awakeFromNib{
    // set our default prefs.
    _defaults = [[NSUserDefaults standardUserDefaults] retain];
    NSDictionary *defaultPreferences = 
        [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithBool:NO], PREF_ONE_KEY,
                                                   [NSNumber numberWithInt:0], PREF_TWO_KEY, nil];
    [_defaults registerDefaults:defaultPreferences];
}

- (void) dealloc{
    [_defaults synchronize];
    [_defaults release];
    [super dealloc];
}

- (void) applicationWillTerminate:(NSNotification *)notification{
    [self release]; // to make sure we do get released.
}

- (BOOL) preferenceOne{ return [_defaults boolForKey:PREF_ONE_KEY]; }

- (void) setPreferenceOne:(BOOL) preferenceOne{
    [_defaults setBool:preferenceOne forKey:PREF_ONE_KEY];
}

- (int) preferenceTwo{ return [[_defaults objectForKey:PREF_TWO_KEY] intValue]; }

- (void) setPreferenceTwo:(int) preferenceTwo{
    NSAssert(preferenceTwo >= 0 && preferenceTwo <= 3, 
               @"preferenceTwo was less than 0 or greater than 3");

    [_defaults setInteger:preferenceTwo forKey:PREF_TWO_KEY];
}

@end

That is all the code required for the application. Of course, a real-world application would do more than this, and would probably isolate the preferences off in a separate class from the main application controller, but for our purposes, this is sufficient.

The final step that we need to take is to open MainMenu.nib (which is in the Resources group of our Xcode project). Switching to the Classes tab of MainMenu.nib in Interface Builder, we read in NPEController.h, and instantiate an NPEController object.

In the Instances tab of Interface Builder, we make a connection from File's Owner to our NPEController object, setting it to be the delegate for the File's Owner (which is the NSApplication).

Running Mac OS X Tiger

Related Reading

Running Mac OS X Tiger
A No-Compromise Power User's Guide to the Mac
By Jason Deraleau, James Duncan Davidson

Running the Application

Build and run the NewPreferencesExample application in Xcode. If everything goes to plan, a window will appear in screen. Hit Command-Q to exit the application. There's nothing more to see here--please move along.

Adding a Preferences Window

The first step is to add the SS_PrefsController source files, SS_PrefsController.h, SS_PrefsController.m, and SS_PreferencePaneProtocol.h, to our XCode project. I prefer to put these in the Other Sources group.

Now open the SS_PreferencePaneProtocol.h file. It contains seven methods, with the following signatures:

+ (NSArray *)preferencePanes;
- (NSView *)paneView;
- (NSString *)paneName;
- (NSImage *)paneIcon;
- (NSString *)paneToolTip;
- (BOOL)allowsHorizontalResizing;
- (BOOL)allowsVerticalResizing;

SS_PrefsController requires each preference pane to implement the SS_PreferencePaneProtocol protocol. In the example that comes with SS_PrefsController, each preference pane implementation (controller) class implements this protocol anew, with the values for the pane name, icon, tooltip, and other properties hardcoded in the pane controller implementation.

Inspecting the source for the SS_PrefsController class, we see that it enforces the rule that preference pane controllers must implement the SS_PreferencePaneProtocol only in the designated initializer (- (id)initWithPanesSearchPath:(NSString*)path bundleExtension:(NSString *)ext).

Instead of implementing the SS_PreferencePaneProtocol protocol in our PreferencePaneControllers, we will provide a parent class that replicates the functionality of the SS_PreferencePaneProtocol, from which our PreferencePaneControllers will inherit.

The Preference Pane Controller

Here is the header for our NPEPreferencePaneController class:

#import <Cocoa/Cocoa.h>

#define PANE_NAME_KEY @"paneName"
#define ICON_PATH_KEY @"iconPath"
#define TOOL_TIP_KEY @"toolTip"
#define HELP_ANCHOR_KEY @"helpAnchor"

#define ALLOWS_HORIZONTAL_RESIZING_KEY @"allowsHorizontalResizing"
#define ALLOWS_VERTICAL_RESIZING_KEY @"allowsVerticalResizing"


@interface NPEPreferencePaneController : NSObject{
    id _controller;
    
    NSString *_nibName;
    NSString *_paneName;    
    NSString *_iconPath;
    NSString *_toolTip; 
    
    NSImage *_paneIcon;

    BOOL _allowsHorizontalResizing;
    BOOL _allowsVerticalResizing;

    NSString *_helpAnchor; 
    
    IBOutlet NSView *_prefsView;
}

- (id) initWithNib:(NSString *)nibName dictionary:(NSDictionary *)dictionary controller:(id)controller;

- (void) dealloc;

- (id) controller;

- (void) showWarningAlert:(NSError *) error;
- (IBAction) showHelp:(id) sender;

// SS_PreferencePaneProtocol

// we don't need this.
// + (NSArray *)preferencePanes;
- (NSView *)paneView;
- (NSString *)paneName;
- (NSImage *)paneIcon;
- (NSString *)paneToolTip;
- (BOOL)allowsHorizontalResizing;
- (BOOL)allowsVerticalResizing;

@end

As you can see, we have class variables for each of the properties that a pane controller requires. We also have a pointer to a controller instance, and to an NSView object--the NSView that will contain the controls for our preference pane.

The methods for our NPEPreferencePaneController closely mimic those of the SS_PreferencePaneProtocol, with the following exceptions: we have eliminated the preferencePanes method, as we will not need it, and we have added showWarningAlert and showHelp methods. We will describe these later on.

We have also added an initializer that takes as its arguments the name of the nib for the preference pane, a dictionary (from which we will read the other parameters), and a pointer to a controller--in our case, this will be our NPEController instance.

Our implementation for NPEPreferencePaneController is shown below:

#import "NPEPreferencePaneController.h"

@implementation NPEPreferencePaneController

- (id) initWithNib:(NSString *)nibName dictionary:(NSDictionary *)dictionary controller:(id)controller{
    
    if(! (self = [super init])) return nil;

    _nibName = nibName;
    _paneName = [dictionary objectForKey:PANE_NAME_KEY];
    _iconPath = [dictionary objectForKey:ICON_PATH_KEY];
    _toolTip = [dictionary objectForKey:TOOL_TIP_KEY];

    NSAssert(_nibName && _paneName && _iconPath && _toolTip,
             @"Dictionary does not contain a nibName, paneName, iconPath, or toolTip entry.");

    [_nibName retain];
    [_paneName retain];
    [_iconPath retain];
    [_toolTip retain];

    _helpAnchor = [[dictionary objectForKey:HELP_ANCHOR_KEY] retain];
    
    _allowsHorizontalResizing = [@"YES" isEqualToString:[dictionary objectForKey:ALLOWS_HORIZONTAL_RESIZING_KEY]];
    _allowsVerticalResizing = [@"YES" isEqualToString:[dictionary objectForKey:ALLOWS_VERTICAL_RESIZING_KEY]];

    _controller = [controller retain];

    return self;
}

- (void) dealloc{
    [_nibName release];
    [_paneName release];
    [_iconPath release];
    [_toolTip release];
    [_paneIcon release];
    [_helpAnchor release];
    [_controller release];
    [super dealloc];
}

- (id) controller{ return _controller; }

- (void) showWarningAlert:(NSError *) error{
    NSAssert(_prefsView != nil, @"prefsView was nil");
    NSAlert *alert = [NSAlert alertWithError:error];
    if(_helpAnchor != nil){
        [alert setShowsHelp:YES];
        [alert setHelpAnchor:_helpAnchor];
    }

    [alert setAlertStyle:NSWarningAlertStyle];
    [alert beginSheetModalForWindow:[_prefsView window]
                      modalDelegate:self
                     didEndSelector:nil
                        contextInfo:nil];
}

- (IBAction) showHelp:(id) sender{
    NSAssert(_helpAnchor, @"Help anchor was not set");
    [[NSHelpManager sharedHelpManager] openHelpAnchor:_helpAnchor inBook:nil];
}

- (NSView *)paneView{
    BOOL loaded = YES;
    
    if(! _prefsView) loaded = [NSBundle loadNibNamed:_nibName owner:self];
    
    if(loaded) return _prefsView;
    
    return nil;
}


- (NSString *)paneName{ return _paneName; }


- (NSImage *)paneIcon{
    if(_paneIcon == nil){
        _paneIcon = [[NSImage alloc] initWithContentsOfFile:[[NSBundle bundleForClass:[self class]] 
                                                                     pathForImageResource:_iconPath]];
    }
    return _paneIcon;
}

- (NSString *)paneToolTip{ return _toolTip; }

- (BOOL)allowsHorizontalResizing{ return _allowsHorizontalResizing; }

- (BOOL)allowsVerticalResizing{ return _allowsVerticalResizing; }

@end

With the exception of the initializer, and the showHelp and showWarningAlert methods, this is lifted almost wholesale from the example SS_PreferencePaneProtocol implementations that come with the SS_PrefsController examples.

The Preferences Controller

Our NPEPreferencePaneController class will not work with SS_PrefsController. To get it to work, we will subclass SS_PrefsController, and add the necessary code in our subclass.

We do need to make one tiny hack to the SS_PrefsController code directly, though. The init method for SS_PrefsController calls the designated initializer: - (id)initWithPanesSearchPath:(NSString*)path bundleExtension:(NSString *)ext. We want to avoid this, so we will comment out the init method in SS_PrefsController.

Our NPEPrefsController class header looks like this:

#import <Cocoa/Cocoa.h>
#import "SS_PrefsController.h"
#import "NPEPreferencePaneController.h"

@interface NPEPrefsController : SS_PrefsController {

}

- (id)initWithPanesSearchPath:(NSString*)path bundleExtension:(NSString *)ext controller:(id)controller;
- (void)activatePane:(NSString*)path controller:(id)controller;

- (NSArray *)toolbarSelectableItemIdentifiers:(NSToolbar *)toolbar;

- (void)showPreferencesWindow;
- (void)showPreferencePane:(NSString *) paneName;

@end

The underlying implementation looks like this:

#import "NPEPrefsController.h"

@implementation NPEPrefsController

// Designated initializer

- (id)initWithPanesSearchPath:(NSString*)path bundleExtension:(NSString *)ext controller:(id)controller;
{
    if (self = [super init]) {
        [self setDebug:NO];
        preferencePanes = [[NSMutableDictionary alloc] init];
        panesOrder = [[NSMutableArray alloc] init];
        
        [self setToolbarDisplayMode:NSToolbarDisplayModeIconAndLabel];
#if MAC_OS_X_VERSION_MAX_ALLOWED >= MAC_OS_X_VERSION_10_2
        [self setToolbarSizeMode:NSToolbarSizeModeDefault];
#endif
        [self setUsesTexturedWindow:NO];
        [self setAlwaysShowsToolbar:NO];
        [self setAlwaysOpensCentered:YES];
        
        if (!ext || [ext isEqualToString:@""]) {
            bundleExtension = [[NSString alloc] initWithString:@"preferencePane"];
        } else {
            bundleExtension = [ext retain];
        }
        
        if (!path || [path isEqualToString:@""]) {
            searchPath = [[NSString alloc] initWithString:[[NSBundle mainBundle] resourcePath]];
        } else {
            searchPath = [path retain];
        }
        
        // Read PreferencePanes - this is where we differ from SS_PrefsController

        if (searchPath) {
            NSEnumerator* enumerator = [[NSBundle pathsForResourcesOfType:bundleExtension inDirectory:searchPath] objectEnumerator];
            NSString* panePath;
            while ((panePath = [enumerator nextObject])) {
                [self activatePane:panePath controller:controller];
            }
        }
        return self;
    }
    return nil;
}

- (void)activatePane:(NSString*)path controller:(id)controller{
    
    NSBundle* paneBundle = [NSBundle bundleWithPath:path];
    NSAssert1(paneBundle != nil, @"Could not initialize bundle: %@", paneBundle);
    
    NSDictionary* paneDict = [paneBundle infoDictionary];
    NSString* paneClassName = [paneDict objectForKey:@"NSPrincipalClass"];
    NSAssert1(paneClassName != nil, @"Could not obtain name of Principal Class for bundle: %@", paneBundle);    
    
    Class paneClass = NSClassFromString(paneClassName);
    NSAssert2(paneClass == nil, @"Did not load bundle: %@ because its Principal Class %@ was already used in another Preference pane.", paneBundle, paneClassName);
    
    paneClass = [paneBundle principalClass];
    
    NSAssert2([paneClass isSubclassOfClass:[NPEPreferencePaneController class]], 
              @"Could not load bundle %@ because it Principal Class %@ is not a subclass of NPEPreferencePaneController",
              paneBundle, paneClassName);
    
    NSString *nibName = [paneDict objectForKey:@"NSMainNibFile"];
    NSAssert1(nibName, @"Could not obtain name of nib for bundle: %@", paneBundle);
    
    NPEPreferencePaneController *aPane = [[paneClass alloc] initWithNib:nibName dictionary:paneDict controller:controller];    
    
    if(aPane != nil){
        [panesOrder addObject:[aPane paneName]];
        [preferencePanes setObject:aPane forKey:[aPane paneName]];
        [aPane release];
    }
}


#if MAC_OS_X_VERSION_MAX_ALLOWED >= MAC_OS_X_VERSION_10_3
- (NSArray *)toolbarSelectableItemIdentifiers:(NSToolbar *)toolbar{
    return panesOrder;
}
#endif


// make sure we get a highlighted icon first time around.

- (void)showPreferencesWindow{
    [super showPreferencesWindow];
    [prefsToolbar setSelectedItemIdentifier:[prefsWindow title]];    
}

// make sure we get a highlighted icon when activating a pane programmatically.

- (void)showPreferencePane:(NSString *) paneName{
    [self loadPreferencePaneNamed:paneName];        
    [prefsToolbar setSelectedItemIdentifier:paneName];    
}

@end

The initWithPanesSearchPath and activatePane methods are lifted almost wholesale from SS_PrefsController. The only significant changes that we have made are to ensure that the preference pane controller classes are subclasses of our NPEPreferencePane controller class, instead of conforming to the SS_PreferencePaneProtocol, and to add an additional controller parameter to each method, so that we can pass a handle to the controller down to the preference pane controller implementations.

The remaining three functions are all related to icon highlighting: toolbarSelectableItemIdentifiers is an NSToolBar delegate method, that returns an array of pane name, which should be highlighted when they are selected (all of them, in our case), and we override the showPreferencesWindow method of SS_PrefsController to ensure that our initially selected pane's icon is highlighted. Finally, the showPreferencesPane method is unique to NPEPrefsController--it allows us to display a pane programmatically, and handles the correct icon highlighting. Note that highlighting only works in Mac OS X 10.3 or later.

Adding the Preferences Controller to the Application

To add NPEPrefsController to our application, we will add a single variable and method to our NPEController class.

The variable _prefsController is a pointer to an NPEPrefsController object (so we also need to import NPEPrefsController.h). The method, showPrefs, is listed below:

- (IBAction) showPreferences:(id) sender{
    if(! _prefsController){
        NSString *path = nil;
        NSString *ext = nil;
        _prefsController = [[NPEPrefsController alloc] initWithPanesSearchPath:path 
                                                               bundleExtension:ext 
                                                                    controller:self];

        // so that we always see the toolbar, even with just one pane
        [_prefsController setAlwaysShowsToolbar:YES];
        [_prefsController setPanesOrder:[NSArray arrayWithObjects:@"General", @"Advanced", nil]];
    }

    [_prefsController showPreferencesWindow];
}

Oops. One more thing. We need to add the line [_prefsController release]; to NPEController's dealloc method.

Finally, opening MainMenu.nib in Interface Builder, we re-read the NPEController.h file (make sure you declare showPreferences in the header), and connect the Preferences menu item to the showPreferences action in NPEPrefsController.

Running the Application

Build and run the NewPreferencesExample application in XCode. If everything goes to plan, a window will appear onscreen.

Select the "Preferences..." item from the NewApplication menu (you can rename this in Interface Builder if you like), and, as if by magic, a dialog box will appear:

"Preferences are not available for NewPreferencesExample"

figure

Figure 2. The NewPreferencesExample application, with no preferences.

All of the scaffolding for our new preferences window is now in place. In part two of this article, I will cover the actual creation of the preference panes.

Martin Redington is a long-time Mac user who recently started writing Mac shareware. His first product, MySync, provides Mac-to-Mac syncing without .Mac and is currently in public beta.


Return to the Mac DevCenter

Copyright © 2009 O'Reilly Media, Inc.