2018-06-22

Using modal sheets in OS X 10.9+

A few days ago I was having trouble making the new sheet windows work for a simple dialog. I must have been doing something stupid. Maybe I was mixing methods in different classes. I came back to the problem today with fresh eyes and now it all seems simple!

First, create a subclass of NSWindowController and an associated NIB/XIB file. Arrange your dialog controls inside a window in that NIB file. Your header file will need IBOutlet properties for all of your dialog controls and at least one IBAction method for closing the dialog.

@interface WCNumberDialog : NSWindowController
@property (weak) IBOutlet NSTextField *textField1;
@property (weak) IBOutlet NSTextField *textField2;

-(IBAction) closeDialog: (id) sender;
@end

The -closeDialog: method could be just one line:

-(IBAction) closeDialog: (id) sender {
    [self.window.sheetParent endSheet: self.window];
}

(For multiple close buttons, see below.)

All of the initialization and retrieval code is kept in a single method that calls the dialog. Note that window-modal sheets are not pinned to the top of a window. You should tell the sheet where to appear. In theory, you could select a point in your original view, convert it to screen coordinates, and set the dialog window's frame relative to that point. Unfortunately, I have found at least one case where [NSView -convertPoint: ... toView: nil] returns strange vertical values. I guess you have to test it with each NIB. It is simpler to position your sheet window relative to the original window's frame instead.

-(IBAction) sheetTest: (id)sender {
	WCNumberDialog *dlg = [[WCNumberDialog alloc] initWithWindowNibName: @"WCNumberDialog"];

#if CONVERTPOINT_TO_VIEW_WORKS
	NSPoint pointInView = myView.frame.origin;
	NSPoint pointInWindow = [myView convertPoint: pointInView toView: nil];
#else
	// Example: position sheet 20% above bottom-left corner and slightly inset.
	NSPoint pointInWindow = NSMakePoint( 25, 0.20*myView.window.frame.size.height );
#endif
	NSPoint windowOrigin = myView.window.frame.origin;
	NSPoint pointInScreen = NSMakePoint( windowOrigin.x + pointInWindow.x, windowOrigin.y + pointInWindow.y );
	[dlg.window setFrameOrigin: pointInScreen];

	// Note that referencing dlg.window also forces the NSWindowController to load the window from 
	// the NIB file, so set the frame before using any of the window controller properties below.

	// Set the dialog's controls' initial values.
	dlg.textField1.intValue = DEFAULT_VALUE_1;
	dlg.textField2.intValue = DEFAULT_VALUE_2;

	// Launch the sheet. When one of the -endSheet: methods is called, it will trigger the completion handler.
	[myView.window beginSheet: dlg.window completionHandler: ^(NSModalResponse rc) {

		NSLog( "Completion handler: rc = %d, field1 = %d, field2 = %d",
				rc, dlg.textField1.intValue, dlg.textField2.intValue );

		// If (rc == NSModalResponseOK) do something with the final values of the dialog's controls.
		// After this completion handler returns, the dialog will be destroyed and its values will be lost.
	}];
}

Remember: ignore the deprecated sheet methods under NSApplication. Use the NSWindow methods instead.

But I need more buttons ...

Your dialog might need two buttons, OK and Cancel. One option is to change your window controller subclass to have two action methods and attach the buttons to the appropriate method.

-(IBAction) closeDialogOK: (id) sender {
    [self.window.sheetParent endSheet: self.window returnCode: NSModalResponseOK];
}
-(IBAction) closeDialogCancel: (id) sender {
    [self.window.sheetParent endSheet: self.window returnCode: NSModalResponseCancel];
}

The interesting thing here is the keyboard interface. If there is one button, a Return key executes that button's action and an Escape key does nothing. If there are two buttons, a Return key executes one action and an Escape key executes the other action. Some experimentation shows that the Return and Escape keys are translated into button presses. Return triggers the bottom right button and Escape triggers the next button to the left. You can verify this behavior by creating a -closeDialog: method that returns different codes based on the button labels:

-(IBAction) closeDialog: (id) sender {
    NSLog( @"-closeDialog: called!");
    if ([sender isKindOfClass: [NSButton class]]) {
        static NSDictionary *dialogReturnCodes = nil;
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            dialogReturnCodes = @{
		  @"OK"       : [NSNumber numberWithInt: NSModalResponseOK],
		  @"Cancel"   : [NSNumber numberWithInt: NSModalResponseCancel],
		  @"Stop"     : [NSNumber numberWithInt: NSModalResponseStop],
		  @"Abort"    : [NSNumber numberWithInt: NSModalResponseAbort],
		  @"Continue" : [NSNumber numberWithInt: NSModalResponseContinue]
		  };
        });

        NSButton *button = (NSButton *) sender;
        NSNumber *rc = dialogReturnCodes[button.title];
        if (nil != rc){
            NSLog( @"-closeDialog: button %@ action = %d", button.title, [rc intValue]);
            [self.window.sheetParent endSheet: self.window returnCode: [rc intValue]];
            return;
        }
    }
    NSLog( @"-closeDialog: default action = NSModalResponseStop" );
    [self.window.sheetParent endSheet: self.window returnCode: NSModalResponseStop];
}

If you do something abnormal like make the bottom-right button a "Cancel" button and the other button an "OK" button, be aware that Return and Escape keys will return abnormal values. In any case, do not do that. It would annoy your users.

If you use a lot of sheets and all your close-dialog buttons use standard labels, you could implement the last -closeDialog: method above in a subclass of NSWindowController. Perhaps call it DialogController. Then for each sheet type, you could create a subclass of DialogController just to define text-field properties, etc.