Posts tagged with “code”
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.
16 Comments
I’m alive! (and a DoxyClean update)
As some may have noticed over the last several months, this blog was a bit… dead. I apologize for the lack of content — I can pretty much chalk it up to the fact that Twitter pretty much monopolized my relevant thoughts. Plus, I’ve been interning at Apple since June, so pretty much everything that I’m working on and finding interesting is covered by “we’ll-rape-your-future-children” NDAs.
Twitter monopolized my time for one simple reason: it allows me to post my brief thoughts without having to fluff it up with stuff like grammar and paragraphs. So, to make me more likely to post here, I’ve redesigned the blog to be more conversational. If all goes according to plan, this simpler design should encourage me to post more succinct entries with increasing frequency.
In what little free time I’ve had over the last few months, I have had a chance to restructure major parts of DoxyClean, my script to generate Apple-style documentation from Objective-C code. As of v0.5, links across files are preserved! Hopefully this will address what seemed to be the most glaring omission in DoxyClean. So please, grab it and let me know how it’s working out for you! Also, I’m totally open to any suggestions on future changes or additions to the script, so feel free to leave those kinds of requests in the comments.
4 Comments