|
| | | | | |
| | | |
| |
|
OS X & Cocoa Writings by Andrew C. Stone ©1995-2003 Andrew C. Stone |
|
|
|
|
Thanks A Bundle!
Building spaghetti free scalable applications
©2003 Andrew C. Stone All Rights Reserved
Is it possible to do incremental development on an application and not end up with code that is gnarled and twisted and unmaintainable and bug ridden, aka "Spaghetti"? The reason the old style Mac houses have taken so long to come to Cocoa is the massive number of lines of old-style Mac toolbox code they are already forced to maintain, much less port . And some of the original software authors are long gone which doesn't help. And without an object-oriented approach, code can end up having many side effects, making it much harder to modify.
Cocoa developers aren't likely to be affected by these problems, especially if they used the Cocoa Document Architecture which neatly divides the model, the view and the controller. If you want a scalable, growable application, use Cocoa! And if you want it to have no impact on a speedy launch time, make it a loadable bundle.
This article shows the guts of a typical bundle I built to add instant poster making to PStill™, our PDF distillery application and server. I wanted a simple interface to create posters and then tile them into pages printable by standard printers, all in a neat bundle named "Posterize". As a bonus, you'll learn how convert input sizes between points, inches, centimeters and picas and how to programmatically fill NSComboBoxes with entries conforming to their selected measurement units. |
|
| |
|
| |
PStill's "Posterize" bundle user interface |
|
|
The Job
Since Frank Siegert maintains the PStill™ engine, and I develop the user interface, we have a clean separation of tasks and domains. The postermaker engine just needs a few parameters - a pdf file, the size of paper and overlap, and the output size of the poster. We also want to let users work in inches, points, centimeters or picas and quickly move between them. We'd also like an option to just specify the width of the poster and let the height be determined by proportional scalling. We shall "reuse" the standard Page Layout... dialog to let the user set the page size based on their printer set. Finally, the lagniappe (a Cajun term for a little something extra): we'll keep track of the user's last settings via the NSUserDefaults database for the next session.
Fleshing Out The UI
The fastest way to program something new is to launch Interface Builder, make a new Cocoa interface, subclass NSWindowController, in this example, to Posterize. Then, set the File's Owner in Info -> Classes to Posterize, and start designing the UI. Add all your elements, and then name these in the Info panel, "Outlets" with Posterize selected in "Classes". Add some actions for each of the UI elements.
Create a new Bundle target in Project builder, and then, moving to Interface Builder, save the file, adding it to this bundle target. Continuing in IB, select the Posterize File's Owner, double-click it to bring up its class, command-click it to get context menu, select "Create Files for Posterize". A dialog will ask you if you want to add them to the Posterize target, click OK.
Now, your files are already started for you, and all you have to do is fill out the templates.
/* Posterize.h */
#import <Cocoa/Cocoa.h>
@interface Posterize : NSWindowController
{
IBOutlet id bleedField;
IBOutlet id inputField;
IBOutlet id makePosterButton;
IBOutlet id outputHeightField;
IBOutlet id outputWidthField;
IBOutlet id measureUnitsPop;
IBOutlet id proportionalToFitMatrix;
IBOutlet id imageView;
}
- (IBAction)makePosterAction:(id)sender;
- (IBAction)updateButtonAction:(id)sender;
- (IBAction)updateMeasureUnitsAction:(id)sender;
- (IBAction)changeProportionalAction:(id)sender;
- (IBAction)changeWidthAction:(id)sender;
- (IBAction)changeHeightAction:(id)sender;
- (IBAction)changeBleedAction:(id)sender;
@end
InterfaceBuilder writes out for you untyped pointer of "id" as the class of each instance variables:
IBOutlet id bleedField;
Experienced coders like to give more information and allow IB and the compiler to do more type checking for you by editing them to add type information:
IBOutlet NSComboBox *bleedField;
Then readers of your code will know exactly what kind of UI control the instance variable is. Of course, your naming scheme should already tip them off.
It's A Drag!
Mac OS X is all about drag and dropping - everything and everywhere. We make the bundle's owner Posterize a subclass of NSWindowController. This is our central brain and the "Principal Class" of our bundle. In Project Builder, be sure to set the principal class key in the Target Inspector, "Cocoa Specific" pane. This insures that when the bundle is loaded, this class is loaded first if there are other classes defined.
We'll subclass the NSPanel with PosterPanel to allow any PDF dropped on anywhere to load the file. Because I like code to be in the WindowController subclass, we'll forward the NSDraggingDestination methods to our central brain, Posterize. Note that the panel's delegate is the File's Owner and we just send on the methods:
@interface PosterizePanel : NSPanel
{
}
@end
#import "PosterizePanel.h"
@implementation PosterizePanel
- (void)awakeFromNib
{
[self registerForDraggedTypes:[NSArray arrayWithObject:NSFilenamesPboardType]];
}
- (unsigned int) draggingEnteredOrUpdated:sender
{
return [[self delegate] draggingEnteredOrUpdated:sender];
}
- (unsigned int) draggingEntered:sender
{
return [self draggingEnteredOrUpdated:sender];
}
- (unsigned int) draggingUpdated:sender
{
return [self draggingEnteredOrUpdated:sender];
}
- (BOOL) prepareForDragOperation:sender
{
return YES;
}
- (BOOL) performDragOperation:(id <NSDraggingInfo>)sender
{
return YES;
}
- (void)concludeDragOperation:(id <NSDraggingInfo>)sender
{
[[self delegate] concludeDragOperation:sender];
}
@end
And here are the drag methods in Posterize:
- (unsigned int) draggingEnteredOrUpdated:sender
{
NSPasteboard *pboard = [sender draggingPasteboard];
NSString *type = [pboard availableTypeFromArray:[NSArray arrayWithObject:NSFilenamesPboardType]];
if (type) {
if ([type isEqualToString:NSFilenamesPboardType]) {
NSArray *filenames = [pboard propertyListForType:NSFilenamesPboardType];
unsigned i = [filenames count];
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir;
while (i-- > 0) {
NSString *filename = [filenames objectAtIndex:i];
if ([self canConvertFile:filename])
return NSDragOperationCopy;
}
}
}
return NSDragOperationNone;
}
- (unsigned int) draggingEntered:sender
{
return [self draggingEnteredOrUpdated:sender];
}
- (unsigned int) draggingUpdated:sender
{
return [self draggingEnteredOrUpdated:sender];
}
- (BOOL) prepareForDragOperation:sender
{
return YES;
}
- (BOOL) performDragOperation:(id <NSDraggingInfo>)sender
{
return YES;
}
- (void)concludeDragOperation:(id <NSDraggingInfo>)sender
{
NSPasteboard *pboard = [sender draggingPasteboard];
NSString *type = [pboard availableTypeFromArray:[NSArray arrayWithObject:NSFilenamesPboardType]];
if (type) {
if ([type isEqualToString:NSFilenamesPboardType]) {
NSArray *filenames = [pboard propertyListForType:NSFilenamesPboardType];
unsigned i = [filenames count];
NSMutableArray *goodFiles = [NSMutableArray array];
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir;
while (i-- > 0) {
NSString *filename = [filenames objectAtIndex:i];
if ([self canConvertFile:filename])
[self setFilePath:filename];
}
}
}
}
We have to test to be sure it's a valid PDF, and we'll take unix-style or Mac Typed PDF - it may have a bogus extension like file.fakeExtension so we need to also check if it has a Mac type.
- (NSArray *)pdfFileTypes {
return [NSArray arrayWithObjects:@"pdf",@"PDF",NSFileTypeForHFSTypeCode('PDF '),nil];
}
- (void)setFilePath:(NSString *)path {
NSPDFImageRep *rep = [NSPDFImageRep imageRepWithData:[NSData dataWithContentsOfFile:path]];
if (rep) {
int numberPages = [rep pageCount];
_size = [rep size];
if (numberPages > 1) {
#define TILE_TITLE NSLocalizedStringFromTable(@"Poster", @"PStill", "alert title to if poster has more than one page.")
#define TILE_MSG NSLocalizedStringFromTable(@"Input file has more than one page! Posters must be made out of single page PDFs.", @"PStill", "alert title to after font install.")
NSBeginAlertSheet(TILE_TITLE, OK, NULL, NULL, [self window], self,@selector(alertDidEnd:), @selector(alertDidDismiss:), NULL, TILE_MSG);
} else {
NSImage *image = [[NSImage alloc] initWithSize:_size];
[image setDataRetained:YES];
[image addRepresentation:rep];
[imageView setImage:image];
[imageView display];
[inputField setStringValue:path];
[self updateButtonAction:self];
[self changeWidthAction:self];
}
}
}
- (BOOL)canConvertFile:(NSString *)path {
NSString *extension = [path pathExtension];
// NOTE - here's how you test for old-style Mac TYPE pdf!
NSString *possibleHFS = NSHFSTypeOfFile(path);
if (!(extension) || [extension isEqualToString:@""]) extension = possibleHFS;
if ([[self pdfFileTypes] containsObject:extension] || [[self pdfFileTypes] containsObject:possibleHFS]) return YES;
else return NO;
}
Show me the file!
Now we need to create a special subclass of NSImageView to show our PDF and additionally show the cut lines given the paper size and paper orientation. We create TileImageView directly from IB to save typing (are you noticing a theme here?).
/* TileImageView */
#import <Cocoa/Cocoa.h>
@interface TileImageView : NSImageView
{
}
@end
TileImageView has two jobs:
1. pass on any dragged on files to the Posterize brain (that's why we gathered the methods there!) Same code as in PosterPanel.m
2. draw the tile marks - the window delegate will provide the information by exporting this API:
@interface Posterize : NSWindowController
...
- (NSSize)paperSize;
- (float)bleed;
- (NSSize)posterSize;
- (BOOL)hasInputFile;
@end
Our actual drawing code will call super to lay down the border and image:
- (void)drawRect:(NSRect)rect {
Posterize *guy = [[self window] delegate];
[super drawRect:rect]; // the image
if ([guy hasInputFile]) {
float bleed = [guy bleed];
NSSize posterSize = [guy posterSize];
NSSize paperSize = [guy paperSize];
NSRect r = [self photoRectInImageView]; // our usable draw area
float xScale = r.size.width / posterSize.width;
float yScale = r.size.height / posterSize.height;
NSRect drawPaperRect = NSMakeRect(r.origin.x,r.origin.y,
(paperSize.width - 2*bleed) * xScale, (paperSize.height - 2*bleed) * yScale);
unsigned c, cols = ceil ( NSWidth(r)/NSWidth(drawPaperRect));
unsigned row, rows = ceil ( NSHeight(r)/NSHeight(drawPaperRect));
NSRect clipRect = NSIntersectionRect(rect,NSInsetRect(r, -1.0,-1.0));
[[NSColor blackColor] set];
[NSBezierPath clipRect:clipRect];
for (row = 0; row < rows; row++) {
for (c = 0; c < cols; c++) {
NSRect drawRect = NSMakeRect (r.origin.x + c * NSWidth(drawPaperRect),
r.origin.y + row * NSHeight(drawPaperRect), NSWidth(drawPaperRect), NSHeight(drawPaperRect));
[NSBezierPath strokeRect:drawRect];
}
}
}
}
// here's how we determine the size of the actual image in the ImageView:
// I reused this code from PhotoToWeb, since it worked already!
- (float)photoReduction {
NSSize size = [[self image] size];
NSRect iFrame = [self bounds];
if (NSWidth(iFrame) > size.width && NSHeight(iFrame) > size.height) return 1.0; // it fits
else {
// one leg of the photo doesn't fit - the smallest ratio rules
double xRatio = NSWidth(iFrame)/size.width;
double yRatio = NSHeight(iFrame)/size.height;
return MIN (xRatio, yRatio);
}
}
- (NSRect)photoRectInImageView {
NSSize size = [[self image] size];
NSRect iBounds = [self bounds];
float reduction = [self photoReduction];
NSRect photoRect;
photoRect.size.width = floor(size.width * reduction + 0.5);
photoRect.size.height = floor(size.height * reduction + 0.5);
photoRect.origin.x = floor((iBounds.size.width - photoRect.size.width)/2.0 + 0.5);
photoRect.origin.y = floor((iBounds.size.height - photoRect.size.height)/2.0 + 0.5);
return (photoRect);
}
Measuring Up
Some people think in points, some in inches, some in centimeters, and maybe, some even in picas. We'll let folks swap between these as desired with our Measurement Units popup. Here are some handy C functions (yes even I use good ol' C, and have a dog-eared copy of Kernighan & Ritchie "The C Programming Language" behind my desk!):
typedef enum {
Inches,
Centimeters,
Points,
Picas
} MeasureUnits;
float
pointsFromUserUnits(int units)
{
switch(units) {
case 0: return 72.0;
case 1: return 28.3464567;
case 2: return 1.0;
case 3: return 12.0;
default: return 72.0;
}
}
float
convertToPointsFromUserUnits(float val, int units)
{
return (val*pointsFromUserUnits(units));
}
float
convertToPoints(int units,float val)
{
switch(units) {
case 0: return val*72.0;
case 1: return val* 28.3464567;
case 2: return val;
case 3: return val*12.0;
default: return val*72.0;
}
}
float
convertToUserUnits(float points,int units)
{
return points/pointsFromUserUnits(units);
}
Now, let's return to ObjectiveC to create some handy methods that figure out the user's units, and whether they want a proportional poster or an arbitrarily scaled along one axis poster:
- (MeasureUnits)selectedUnits {
_lastUnits = (MeasureUnits)[measureUnitsPop indexOfSelectedItem];
return _lastUnits;
}
- (float)pointsFromField:(NSTextField *)field {
return convertToPoints([self selectedUnits], [field floatValue]);
}
- (float)pointsFromField:(NSTextField *)field units:(int)units {
return convertToPoints(units, [field floatValue]);
}
- (float)xScale {
return [self pointsFromField:outputWidthField]/ _size.width;
}
- (float)yScale {
return [self pointsFromField:outputHeightField]/ _size.height;
}
- (float)desiredXScale {
if ([proportionalToFitMatrix selectedTag] == 1) return [self xScale];
return MIN([self xScale], [self yScale]);
}
- (float)desiredYScale {
if ([proportionalToFitMatrix selectedTag] == 1) return [self yScale];
return MIN([self xScale], [self yScale]);
}
Now we have our core methods, we can actually write the IBActions, some of which do nothing more than tell the imageView to redisplay!
- (IBAction)changeWidthAction:(id)sender {
if ([proportionalToFitMatrix selectedTag] != 1 && _size.height != 0.0) {
[outputHeightField setFloatValue: convertToUserUnits(floor([self xScale] * _size.height), [self selectedUnits])];
}
[imageView setNeedsDisplay];
}
- (IBAction)changeHeightAction:(id)sender {
[imageView setNeedsDisplay];
}
- (IBAction)changeBleedAction:(id)sender {
[imageView setNeedsDisplay];
}
I've become infatuated with NSComboBoxes - they allow handy pre-conceived entry options to be available with a click. You can populate these entry lists in InterfaceBuilder, but we want to be smart and provide a different list depending on the measurement units selected. By selecting "Uses data source" in IB, and linking the datasource outlet to the File's owner, we can provide the lists on the fly. An object which can be a data source should implement the informal NSComboBoxDataSource protocol:
@interface NSObject (NSComboBoxDataSource)
- (int)numberOfItemsInComboBox:(NSComboBox *)aComboBox;
- (id)comboBox:(NSComboBox *)aComboBox objectValueForItemAtIndex:(int)index;
- (unsigned int)comboBox:(NSComboBox *)aComboBox indexOfItemWithStringValue:(NSString *)string;
- (NSString *)comboBox:(NSComboBox *)aComboBox completedString:(NSString *)string;
@end
Only the first three methods must be implemented, the last is called if implemented.
Relying on an old programming trick of a two dimensional array, we can define some basic sizes:
#define NUMBER_BLEEDS 4
#define NUMBER_UNITS 4
const NSString *bleeds [NUMBER_BLEEDS][NUMBER_UNITS] = {
{ @"0.50", @"1.27", @"36.00", @"3.00" },
{ @"0.75", @"1.90", @"54.00", @"4.50" },
{ @"1.00", @"2.54", @"72.00", @"6.00" },
{ @"1.25", @"3.17", @"90.00", @"7.50" }
};
#define NUMBER_SIZES 8
const NSString *postersizes [NUMBER_SIZES][NUMBER_UNITS] = {
{ @"18.00", @"45.71", @"1296", @"108.00" },
{ @"24.00", @"60.95", @"1728", @"144.00" },
{ @"30.00", @"76.19", @"2160", @"180.00" },
{ @"36.00", @"91.43", @"2592", @"216.00" },
{ @"42.00", @"106.67", @"3024", @"252.00" },
{ @"48.00", @"121.90", @"3456", @"288.00" },
{ @"54.00", @"137.14", @"3888", @"324" },
{ @"60.00", @"152.38", @"4320", @"360" }
};
which makes our implementation easy:
- (int)numberOfItemsInComboBox:(NSComboBox *)aComboBox {
if (aComboBox == bleedField) return NUMBER_BLEEDS;
else return NUMBER_SIZES;
}
- (id)comboBox:(NSComboBox *)aComboBox objectValueForItemAtIndex:(int)index {
if (aComboBox == bleedField) return [NSNumber numberWithFloat:[ bleeds[index][[self selectedUnits]] floatValue]];
else return [NSNumber numberWithFloat:[ postersizes[index][[self selectedUnits]] floatValue]];
}
- (unsigned int)comboBox:(NSComboBox *)aComboBox indexOfItemWithStringValue:(NSString *)string {
unsigned i, count;
if (aComboBox == bleedField) {
for (i = 0; i < NUMBER_BLEEDS; i++) {
if ([string isEqualToString:bleeds[i][[self selectedUnits]]]) return i;
}
} else {
for (i = 0; i < NUMBER_SIZES; i++) {
if ([string isEqualToString:postersizes[i][[self selectedUnits]]]) return i;
}
}
return -1;
}
A nice feature about NSComboBoxes is that they can also allow any arbitrary value. But if someone does have a preferred size, why not save that for next time using the NSUserDefaults system? We need to establish some useful values when the bundle loads and then set the interface to the stored values:
- (void)awakeFromNib {
[self registerDefaults];
[self restoreDefaults];
...
- (void)registerDefaults {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSMutableDictionary *d = [NSMutableDictionary dictionary];
[d setObject:@"0" forKey:@"PosterUnits"];
[d setObject:@"24.0" forKey:@"PosterWidth"];
[d setObject:@"36.0" forKey:@"PosterHeight"];
[d setObject:@"0" forKey:@"PosterToFit"];
[d setObject:@"1.0" forKey:@"PosterBleed"];
[ud registerDefaults:d];
}
- (void)restoreDefaults {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
[measureUnitsPop selectItemAtIndex:[ud integerForKey:@"PosterUnits"]];
[bleedField setFloatValue:[ud floatForKey:@"PosterBleed"]];
[outputWidthField setFloatValue:[ud floatForKey:@"PosterWidth"]];
[outputHeightField setFloatValue:[ud floatForKey:@"PosterHeight"]];
[proportionalToFitMatrix selectCellWithTag:[ud integerForKey:@"PosterToFit"]];
}
Call this method to store the current values when the user makes the actual poster:
- (void)writeDefaults {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
[ud setFloat:[bleedField floatValue] forKey:@"PosterBleed"];
[ud setFloat:[outputWidthField floatValue] forKey:@"PosterWidth"];
[ud setFloat:[outputHeightField floatValue] forKey:@"PosterHeight"];
[ud setInteger:[proportionalToFitMatrix selectedTag] forKey:@"PosterToFit"];
[ud setInteger:[measureUnitsPop indexOfSelectedItem] forKey:@"PosterUnits"];
[ud synchronize];
}
This meat-of-the-matter method is the only connection with the rest of the application - and we simply create a job with our settings and tell it to convert:
- (IBAction)makePosterAction:(id)sender
{
NSSize pr = [self paperSize];
PosterizeJob *job = [[NSClassFromString(@"PosterizeJob") alloc] initWithInputPath:[inputField stringValue] scaleX:[self desiredXScale] scaleY:[self desiredYScale] bleed:[self bleed] width:pr.width height:pr.height];
[job doConversionAction:self];
[self writeDefaults];
}
And here's how we implemented the methods needed for our TileImageView to draw - note how we use the default implementation of NSPrintInfo:
- (NSSize)paperSize {
NSPrintInfo *printInfo = [NSPrintInfo sharedPrintInfo];
return [printInfo paperSize];
}
- (float)bleed { return [self pointsFromField:bleedField]; }
- (NSSize)posterSize {
return NSMakeSize( [self desiredXScale] * _size.width, [self desiredYScale] * _size.height);
}
Get Loaded
So that was a quick tour of a bundle - but how do we load it? We'll implement my standard "sharedInstance" so that it can be loaded dynamically if it gets called.
+ (id)sharedInstance {
static Posterize *sharedPoster = nil;
if (! sharedPoster) {
sharedPoster = [[Posterize allocWithZone:NULL] init];
}
return sharedPoster;
}
- (id)init {
self = [self initWithWindowNibName:@"Posterize"];
if (self) {
[self setWindowFrameAutosaveName:@"Posterize"];
}
return self;
}
And then in our application delegate class, have a menu item connected to "showPosterPanelAction:" which could be implemented like this:
- (void) showPosterPanelAction:(id)sender {
[[self sharedInstanceOfName:@"Posterize"] showWindow:self];
}
This code offers two types of dynamic code loading - both singleton class instances such as Posterize's with sharedInstanceOfClassName , as well as simple new instances for each invocation with instanceOfClassName . Both use the same core code with a "shared" flag:
- (id)sharedInstanceOfClassName:(NSString *)name
{
return [self instanceOfClassName:name shared:YES];
}
- (id)instanceOfClassName:(NSString *)name {
return [self instanceOfClassName:name shared:NO];
}
instanceOfClassName would be used, for example, in a document-based architecture application where each document had its own version of that bundle's nib. An example would be a sheet that gets run on a per-document basis - because more than one document can be in a sheet-modal state at the same time. In this case, the document becomes responsible for freeing the bundle resources upon closing. Singleton instances are not freed until the application quits.
- (id)instanceOfClassName:(NSString *)name shared:(BOOL)shared
{
id obj = nil;
// NSBundle does the work of finding the bundle
// pathForResource means "look into Resources folder"
// use pathForBundle if you store them in the "Bundles" folder
NSString *path = [[NSBundle mainBundle] pathForResource:name ofType:@"bundle"];
if (path) {
NSBundle *b = [[NSBundle allocWithZone:NULL] initWithPath:path];
// the object we wish to return is an instance of the principal class:
if ((b != nil) && ([b principalClass] !=NULL)) {
// we either grab the sharedInstance or create a new one:
if (shared) obj = [[b principalClass] sharedInstance];
else obj = [[[b principalClass] allocWithZone:NULL] init];
// provide Developer feedback if something has gone wrong:
} else NSLog(@"Can't Load %@!\n", path);
} else NSLog(@"Couldn't find %@ bundle!\n",name);
return obj;
}
Conclusion
When designing or growing your application, it is good practice to decompose your functionality into small, loadable pieces. By using dynamically loaded bundles, you gain launch speed and can take advantage of runtime binding for loading new tools. Probably most importantly, bundles create a firm foundation for future feature growth without application bloat as well as provide a natural modularity that is easy to comprehend.
Andrew Stone, http://www.stone.com, has been working with Cocoa for 15 years in these guises: NeXT, NeXTStep, OpenStep, Rhapsody, Developer Preview 1 & 2, Mac OS X Server and finally, Mac OS X.
|
|