July 3, 2006

Pimp My Code, Part 11: This Sheet is Tight

Today's post is a meta-pimp -- this is the summary of some guidance I gave one of my programmers here at Delicious Monster. Most of this isn't new information; it should in fact be standard practice for experienced Cocoa hands. Consider this a remedial pimping; for young pimps and the pimp-at-heart.



Most sheets you'll pop up will be "alert sheets," of the simple "Hey, uh, you're about to do something bad and/or extremely bad, are you sure you want to continue?" variety. (The examples here are all cribbed from Apple's excellent user interface guidelines, which you should read and re-read.)

Alert sheets are simple to program, although not as simply as alert panels used to be under Rhapsody (pre-10.0), because panels are application-modal and sheets are document-modal. The relevant difference for users between the two modalities is that with document-modality the user is still allowed to mess around with other documents, whereas in app-modal mode you put your panel in front of everything and force the user to vote yea or nay before she does anything else, period.



Contrast this with Mac OS 9's largely system-modal panels, where, for example, if you were printing in one application the print panel would block the user from interacting with any other applications until the print was complete. This, frankly, sucked from the user. Using induction we can deduce that finer-grained modality is preferable, and thus that we should use document-modality over app-modality, even though app-modality still exists in the Cocoa APIs. And, for example, iTunes uses application-modal panels on their info panels instead of, say, document-modal sheets, which is really pretty bletcherous. (Really, they shouldn't use modes at all in an info panel, but that's a separate issue.)

So, before 10.0, you would find yourself asking the user's permission (or forgiveness) using one of the following two panel functions. You can still use them if you come up with a really good excuse to block the entire application -- for example, if the user selects a menu option like, "Delete this application and add my name to a list of people who are never allowed to run it again":

int NSRunInformationalAlertPanel(NSString *title, NSString *msg, NSString *defaultButton, NSString *alternateButton, NSString *otherButton, ...);
int NSRunCriticalAlertPanel(NSString *title, NSString *msg, NSString *defaultButton, NSString *alternateButton, NSString *otherButton, ...);

Note that there are two nice variants provided here, both stolen from Mac OS 9 by NeXT engineers a long, long time ago -- the "informational" panel and the "critical" panel. For your reference, here's a friendly quote from the currently-current Apple HIG guidelines explaining when to use which: "In rare cases, you may want to display a caution icon in your alert, badged with the application icon as shown in Figure 13-30. A badged alert is appropriate only if the user is performing a task, such as installing software, and a possible side effect of that task would be the inadvertent destruction of data."

And here's an example of a critical alert panel:



Notice that there's a big yellow exclamation point, which is the international symbol for "you are going to lose data." (Not really, I just made that up.) As a programmer, you might not have realized there are two variants of the standard alert panel, because many Cocoa programmers don't use critical alerts when needed (instead they use the generic NSRunAlertPanel() function, which is admittedly 50% easier to remember). For instance, in TextEdit, when you change a rich-text document to plain-text, and are going to irretrievably lose all your formatting information, you get an informational sheet, not a modal sheet. Whups.

Update: Mea culpa! I just checked and, in 10.4 at least, TextEdit can undo past a RTF conversion event, and so they were correct to NOT make their sheet be critical. I apologize to the authors of that app for impugning their character in my search for an example.

Invoking either these two panels from your programs is as simple as cake: you just call 'em and check the return code to see which button was pressed. Remember to follow the HIGs when it comes to the "title" and "msg," because a lot of people get this wrong. Specifically, the "title" isn't really so much a title any more under Aqua, it's more of a line of text describing the issue succinctly. This is a change from pre-Aqua days that some Cocoa programmers haven't fully grokked, so you'll still see panels with the title "Alert" or with just a title and no text, which the HIG guys hate.



--

So, after that brief discussion of application-modal panels, we move to document-modal sheets, which you should strive to use whenever it makes sense. First off, let's check out the easy-to-functions for these:

NSBeginCriticalAlertSheet(NSString *title, NSString *defaultButton, NSString *alternateButton, NSString *otherButton, NSWindow *docWindow, id modalDelegate, SEL didEndSelector, SEL didDismissSelector, void *contextInfo, NSString *msg, ...)
NSBeginInformationalAlertSheet(NSString *title, NSString *defaultButton, NSString *alternateButton, NSString *otherButton, NSWindow *docWindow, id modalDelegate, SEL didEndSelector, SEL didDismissSelector, void *contextInfo, NSString *msg, ...);

Hey, wait a minute... these functions have more parameters! I thought you said sheets were easier to use? Well yes, they are, for the user. For you: more work. However, the payoff is happy users. And you can't put a price on that. Well, you can, actually: it's about $40, which is pretty sweet.

The big difference in running sheets vs. running panels is that these sheet functions return immediately, before the user has responded to the panel. Note that they don't return an int, they return void: the great unknown. (If only we had Schrödinger's return type, which would represent the all and none of default and alternate and other.)

Now, this immediate return throws a lot of programmers of, and rightly so... I mean, obviously you are throwing up an alert because you want some feedback from the user, and so the _very_ next thing you want to do is going to be based on what the user decides, and yet here you are, unceremoniously dumped back into your code with no decisions in sight. What to do?

Well, there are two choices. One is, just immediately return from whatever method you're in, which should return control flow to the main even loop, and then wait for your didEndSelector to eventually be called when the user makes up her mind. In that method, you'll be informed of her decision, and can proceed naturally.

The only downside to this is that if you've built up a lot of state information before popping up your alert sheet, you're going to have to regenerate it when you get called back or you'll have to save it off in your instance variables. Bluck!

One way around this, to be used in extreme cases, is to just process events on your own, such as the following:

Pulling Down an Alert Sheet, the Hard Way
@implementation ExampleClass

- (IBAction)doSomethingCompletelyAmazingYetKindOfDangerous:(NSButton *)sender;
{
[some code that has a lot of state setup]

userChoice = INT32_MAX;

NSBeginInformationalAlertSheet(NSLocalizedString(@"I'm Going to do Something Big", @"alert title"),
nil, nil, nil, [document windowForSheet], self, selector(_sheetDidEnd:returnCode:contextInfo:),
nil, NULL, NSLocalizedString(@"It's going to be awesome. Seriously.", @"alert message"));

while (userChoice == INT32_MAX) {
[NSApp sendEvent:[NSApp nextEventMatchingMask:NSAnyEventMask
untilDate:[NSDate distantFuture] NSModalPanelRunLoopMode dequeue:YES]];
}

if (userChoice == NSAlertDefaultReturn) {
[some code that does the cool thing with the state we calculated at great expense]
}
}

@end

@implementation ExampleClass (Private)

- (void)_sheetDidEnd:(NSWindow *)sheet returnCode:(int)returnCode contextInfo:(void *)contextInfo
{
userChoice = returnCode;
}

@end

In this example we assume userChoice is an instance variable (quick quiz: why can't this be a static variable instead?) and is an int. I'm not particularly fond of having a one-use ivar like this, but, hey, not a lot of options - if the alternative is saving off four or five different pieces of state in ivars, this might be cleaner. Note that MOST of the time you're really going to want to do your actual processing in the -_sheetDidEnd:..., so you can skip all the NSEvent crap and just return from your -doSomething... method -- this is just an example of how you might work around a situation where you really don't want to return from your action method before you finish what you're doing.

Update: Pierre Lebeaupin (the pretty pin?) correctly pointed out that my contrived example above is, in fact, wrong in the case of document-modal sheets (see the comments page). I tried to be weaselly in this example by saying, "I'd only use this in extreme cases," to express that I've never used [[sendEvent:...]nextEvent:...] hack in this way, but it'd be something I might try (and then, hopefully, discovered the bug Pierre found).

What I was trying to accomplish was come up with a concise way to introduce sub-event loops, which are actually a useful tool in some extreme situations. In fact, this post was inspired by one of my programmers not knowing about them when trying to work around some problems with printing, so I tried to blend my discussion of sheets and event subloops into one mélange, but, in fact, my example was flawed. Pierre pointed out a fundamental problem that, in fact, early Mac OS X apps all suffered from, because early on Apple hadn't thought of the case where you pop up two sheets in two different documents. Apple added the asynchronous APIs just for this case.

Here's a real example of when you might use your own event subloop, which may in fact still have problems of its own (we just started doing this in our code), but I assert it does not due to the inherent limitations of the print system.

Right now, the way we print in Delicious Library 2 is to create a WebView (from Apple's WebKit) and populate it with your books &c, then just tell the WebView to print itself. Clever, no? The only problem is that WebViews will NOT load images synchronously (grrrrrr) even if they are local images and even though it would be completely trivial to do so (eg, NSURLConnection has a +sendSynchronousRequest:... method). So, if you're in the middle of running a print sheet for a document, and you create a WebView based on user input, and then want to print it, you need to spin on the event loop letting all the images load asynchronously before you try to print the WebView. Since there appears to only be one possible print operation per application, I believe this to be a valid use of a event subloop. In our case, the code looked like this:

A Valid Use of Event Subloops
- (void)_setupWebViewThumbnailBasedOnUserSettings;
{
allImagesHaveLoaded = NO;

while (!allImagesHaveLoaded) {
NSEvent *nextEvent = [NSApp nextEventMatchingMask:NSAnyEventMask
untilDate:[NSDate dateWithTimeIntervalSinceNow:0.05] inMode:NSModalPanelRunLoopMode dequeue:YES]
if (nextEvent)
[NSApp sendEvent:nextEvent];
}

// elsewhere, we have code that sets 'allImagesHaveLoaded' when... well, you know
}
Note that we are, in fact, polling in the above loop, which is normally a no-no, but we are forced to because we're not guaranteed an event will be sent after the last image loads in our WebView, we don't want the user to have to 'bump the mouse' to get the print panel to finish. (This kind of mistake is very common when writing event loops, and in fact you can see it in a lot of shipping apps -- for instance, some older apps force you to keep moving the mouse to keep autoscrolling; if you stop moving, you stop scrolling, because events aren't being processed and they didn't set a separate timer event like the Apple docs said.)

In this case, we don't mind the polling so much because we know we'll stop once we've loaded a very small number of images from a local disk, and because there's really not much else the user is planning to do in this quarter-of-a-second (switch applications and start and a compile? not likely.) Since the end of the polling is NOT determined by user input, we aren't in a situation where the user could wander away from her computer while it is polling while waiting for her and come back to find her MacBook slagged from overheating.

--

O'Reilly has written a whole article about running alert sheets, which you can read for a lot more detail than I would ever provide you with, ever. Finally, you should check out the (relatively) new NSAlert class, which is slightly more complex to use (you have to create the object and THEN show it) but provides an objective API onto all this.

--

Ok, now we've handled the case of pulling down a simple yes/no/maybe style sheet with two lines of text and an icon. What if you want to pull down a sheet with radio buttons and tables and all kinds of fun widgets, that performs a relatively complex task for the user (like, say, configuring rules for a smart shelf)? Here's an example sheet from the AppKit, of setting up a fax. (Note that if you want to do exactly what's on this sheet, you should, you know, just send a fax.)



Well, here's what I consider to be the current best practice for sheets that have a ton of logic and interaction. The overview is, you create an NSWindowController subclass to handle loading and unloading the NIB with the sheet in it (remember that a sheet is just a standard window to Interface Builder), and then provide a single, beautiful class method as its API:

More Complex Sheet Handling
@interface LIShelfCreationSheetWindowController : NSWindowController
{
NSDocument *document;
// [add some ivars and outlets and stuff]
}

// Class API
+ (void)runModalSheetlToCreateShelfForDocument:(NSDocument *)document;
// actions
- (IBAction)addShelf:(id)sender;
- (IBAction)cancel:(id)sender;

@end

[...new file...]

@implementation LIShelfCreationSheetWindowController

// Class API

+ (void)runModalSheetlToCreateShelfForDocument:(NSDocument *)document;
{
LIShelfCreationSheetWindowController *shelfCreationSheetWindowController =
[[self alloc] _initWithDocument:document];

[NSApp beginSheet:[shelfCreationSheetWindowController window]
modalForWindow:[document windowForSheet] modalDelegate:shelfCreationSheetWindowController
didEndSelector:@selector(_sheetDidEnd:returnCode:contextInfo:) contextInfo:nil];
[[shelfCreationSheetWindowController window] makeKeyAndOrderFront:nil]; // redundant but cleaner
}

// actions

- (IBAction)addShelf:(id)sender;
{
//
// actually create the shelf, based on the user's settings on the sheet, which are bound to our ivars
//

[NSApp endSheet:[self window] returnCode:0]; // stop the app's modality
}

- (IBAction)cancel:(id)sender;
{
[NSApp endSheet:[self window] returnCode:0]; // stop the app's modality
}

@end

@implementation LIShelfCreationSheetWindowController (Private)

// Init and dealloc

- (id)_initWithDocument:(NSDocument *)aDocument;
{
if (![self initWithWindowNibName:NSStringFromClass([self class])])
return nil;

document = [aDocument retain];
return self;
}

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

// private callbacks

- (void)_sheetDidEnd:(NSWindow *)sheetWindow returnCode:(int)returnCode
contextInfo:(void *)contextInfo; // [NSApp beginSheet:...]
{
[sheetWindow orderOut:nil]; // hide sheet
[self autorelease];
}

@end

Things to note here: this class has a backpointer to your NSDocument, so it can get at any relevant state it needs without you having to passed it in explicitly as parameters to the class method (in real code you'd replace the word "NSDocument" with the name of your NSDocument subclass). For instance, we immediately need to figure out which window will sport our shiny new sheet, and we use NSDocument's built-in and conveniently titled -windowForSheet method.

In NIB you'd hook up buttons to invoke the -addShelf: and -cancel: actions, and you'd using bindings to keep your instance variables in sync with the tables and radio buttons and other widgets in the UI, so you can just access your ivars directly in -addShelf: when the rubber hits the code.

I prefer having a separate class to run the sheet because I've found there can be a lot of cruft code for configuring the UI and performing some action, and it's nice to offload your NSDocument subclass of that kind of code. In NSDocument, your original action method is going to be very small (one line!), which is going to make it very readable.

--

This shell of a class isn't intended to evoke a "Genius!" reaction from the gentle reader: hopefully, in fact, you are muttering, "That seems pretty obvious." Because the correct way to write code should always seem obvious, in retrospect. Years from now, when you're looking at your code, you should say, "Of course I wrote it that way -- there's no other decent way to do it," and not, "What the hell was I thinking?"

Labels:

16 Comments:

Blogger Sören 'chucker' Kuklau said...

Not to be a smartass, and please do correct me if I'm wrong, but the NSAlert class documentation states:
"The NSAlert class, which was introduced in Mac OS X v10.3, supersedes the functional Application Kit API for displaying alerts (NSRunAlertPanel, NSBeginAlertSheet, and so on). The former API is still supported, but you are encouraged to use the NSAlert class for your application’s alert dialogs."

The way I'm reading this, the macros like NSRunCriticalAlertPanel that you're describing here will perhaps eventually be deprecated.

July 03, 2006 5:52 PM

 
Anonymous Anonymous said...

Yet another excellent Pimp My Code article - keep them coming!

July 03, 2006 6:09 PM

 
Blogger Abhi Beckert said...

Didn't really learn anything this time, but it's great to see you're finally back in business. I actually gasped when I saw the little (1) badge in my RSS reader!

I do have one question though, I know you use expressive method/variable names, but isn't this a bit overkill:

+ [LIShelfCreationSheetWindowController runModalSheetlToCreateShelfForDocument:]

Surely this would do the trick:

+ [LIShelfCreationSheetWindowController runModalSheetForDocument:]

I would've thought the necessary info was in the class name?

July 03, 2006 6:44 PM

 
Blogger Aaron Tait said...

I actually found some of this useful, but it would be great if you could "pimp out" a way to programatically resize a custom sheet (like the iTunes add smart playlist sheet). Unfortunately pulling a -setFame:display:animate on a sheet results in something nasty.

July 03, 2006 7:25 PM

 
Blogger Wil Shipley said...

abhi: I feel like it's clearer for the method name to say "Look, I'm going to not just pull down the sheet, I'm going to also do the actual action when I'm done."

That is, the method name encompasses everything that's going to happen, not just the first action.

July 03, 2006 9:17 PM

 
Blogger Wil Shipley said...

Aaron:

Use code like this to resize the window:

NSRect contentFrameInWindowCoordinates = [[self window] contentRectForFrameRect:[[self window] frame]];

float heightAdjustment = newHeight - NSHeight(contentFrameInWindowCoordinates);
contentFrameInWindowCoordinates.origin.y -= heightAdjustment;
contentFrameInWindowCoordinates.size.height += heightAdjustment;
[[self window] setFrame:[[self window] frameRectForContentRect:contentFrameInWindowCoordinates] display:[[self window] isVisible] animate:[[self window] isVisible]];

Note that you should pass in newHeight.

July 03, 2006 9:19 PM

 
Anonymous Vincent said...

It' a shame Apple doesn't follow it's own Apple HIG. For example Window Behavior > Naming New Windows (figures 13-15 and 13-16). It says: Do not capitalize "Untitled" for new document window titles, yet i can't name any program (TextEdit for example) which follows this simple rule.

July 04, 2006 5:06 AM

 
Anonymous Aurélien said...

Just a question about the Fax dialog... can i call this sheet from my code ?
Is there a standart way to fax something in Cocoa ?

thanks

July 04, 2006 5:06 AM

 
Blogger jay said...

I appreciate reading your tips.

I hope you got your E*Trade stuff worked out by now!

July 04, 2006 4:10 PM

 
Anonymous Pierre Lebeaupin said...

Well, Wil, I'm going to pimp your own code; yes, from this "Pimp My Code". Namely, the snippet where you process events yourself.

It's bullshit.

It does what I name "stealing the stack", that is, your method doesn't actually return until it has had the info it demands. Of course, it processes events so that the app keeps running... for the most part. Try this: bring up a sheet that uses this hack, then go do other stuff on other documents (hey, it's document-modal, the user should be able to do whatever pleases him before answering the dialog) and in one of these other documents, bring up a sheet that uses the same hack (it can be a sheet coming from the same code, or not). Then try to confirm the action of the first sheet. I'm almost sure of the result.

The fact you have an instance var instead of a static var (so that it's reentrant) doesn't protect you. What you're doing is, in your attempt to save this state information, you're in fact leaving it on the stack. You're stacking it. And if another sheet comes up, it stacks its own state info on top of it. And the stack is, you know, a stack: you can only retrieve the last thing you put in. If you attempt to confirm the first sheet, the instance var will be modified by the delegate method but no one cares since what holds the stack is watching the var from another instance, and won't let go until you also confirm the second sheet, at which point both will do the actions the user asked for.

That's the very reason these document modal sheet creation functions return immediately: since they are document modal, they can be answered at any time in any order, so it's just not possible to handle events inside them while waiting for the result, since it could itself hold the stack and prevent another such sheet function from returning with its own result. And do not even think about Apple implementing a wild hack in Cocoa to make control return to the first sheet function (that is, go magically down the stack) so that it can answer to the calling code, while keeping the stuff that has been stacked up of it (the second sheet and its precious state info). One can't mess with these things (or one is going to sleep with the fishes).

Since these sheets are supposed to be document-modal, the user should be able to leave it in a corner (perhaps even reduce the window back in the Dock), do just about anything else with the app, including bringing up another sheet and suddenly deciding to answer the first one, and during this time this state info has to be kept somewhere it can be retrieved anytime, the answer is, plain and simple, this state info (or at least, enough data to recreate it) should be stored in the instance (typically of a subclass of NSDocument) that invoked the sheet, or some similar method (say, a malloced buffer referenced from said instance, with no buffer and the reference being NULL where there's no sheet so as to save memory when there is no sheet live because that will be the case 99% of the time).

(there's no field for email in the form, I'm @wanadoo.fr, username being "lebpierre"; just in case of spam bots)

July 05, 2006 12:28 PM

 
Blogger Wil Shipley said...

Pierre: You're right. I'm amending the post.

July 05, 2006 2:58 PM

 
Anonymous Pierre Lebeaupin said...

Yeah, "the pretty pin" (to be accurate, "the pretty pine tree", though "beau" in this context can also mean big or high).

So you just wanted to (cheaply) introduce sub event loops... This use with printing and having asynchronous stuff be made as soon as possible is interesting, though of course Apple could decide to screw you by allowing more than one print job at the same time (but as you said in a previous class, Apple could change any method in an incompatible way any time and it's not feasible to try and write alternatives for all possible cases).

I'm a (future) electronics engineer, so as a programmer I'm quite low-level (and a Carbon user, sorry), but with this twist that I know the high-level implications of low-level decisions (you pretty much have to when you're modifying the exception vectors of your microcontroller-based system in 68k assembly and you have to consider the implications of the modification of this global state).

July 06, 2006 2:13 AM

 
Anonymous Paul said...

Thanks Wil,
Super informative and helpful. I really wish more Cocoa experts would share their knowledge. You are tha man!

July 07, 2006 8:56 AM

 
Anonymous Anonymous said...

Nothing to say here

July 18, 2006 5:40 AM

 
Anonymous Anonymous said...

"It' a shame Apple doesn't follow it's own Apple HIG. For example Window Behavior - Naming New Windows (figures 13-15 and 13-16). It says: Do not capitalize 'Untitled' for new document window titles, yet i can't name any program (TextEdit for example) which follows this simple rule."

If this still isn't fixed in the framework (not sure, I haven't written any Cocoa apps in a while), the workaround is simple. In your NSWindowController subclass, add this:

- (NSString*)windowTitleForDocumentDisplayName: (NSString*) displayName
{
if ( [[[self document] fileName] length] == 0 )
{
displayName = [displayName lowercaseString];
}
return displayName;
}


Apple's apps may not get this little HIG detail right, but I guarantee that a good number of third party apps take pains to get this stuff right.

July 21, 2006 1:48 PM

 
Anonymous Vincent said...

It's all in the little details, little details make (or break) a good user experience ;-)

July 26, 2006 1:58 PM

 

Post a Comment

<< Home