Programming Mac OS X with Cocoa for Beginners/An Inspector calls
Previous Page: More shapes | Next Page: Archiving
This section won't really introduce anything new about Cocoa, but it will help consolidate what we've learned already. The idea is to provide an inspector type interface for Wikidraw so that we can set the properties of the objects we have drawn, such as the colours, stroke widths, etc.
From what we learned about notifications, we can see how we might achieve this. Whenever the selection changes, a notification can be broadcast indicating that fact. The inspector can listen for these, and find out what the current selection is, then simply update its interface to match the selected object's properties.
When the user changes the interface, the new properties are applied to the objects in the selection.
The process of creating the Inspector is similar to the one for the tool palette. A single global inspector is used, and so it can go in the 'MainMenu.nib' file. We'll need a new controller object to act as the go-between between the interface elements and the document. Now at this point it should be pointed out that in a real application, you would probably make separate .nib files for interface elements like this, simply to keep things manageable and even reusable in other applications. However, doing this brings in a number of complications that don't really help us get to know about Cocoa's features, so for now we won't do this. But bear in mind that there are better ways to do things than what we are about to do.
Inspector design
[edit | edit source]Most of the inspector design we can accomplish in Interface Builder, though this is a moderately complex piece of user interface.
The user is able to select different objects in the main document; the inspector is there to display the properties of the selected object and allow them to be changed interactively. However the user is also able to select more than one object, or none. What should we do in these cases? In this design, we take a simple approach - if there are no objects selected the inspector will hide the controls that edit the properties and display 'No selection'. For a multiple selection, we do the same thing, except display 'Multiple selection'. Only if there is only one object selected do we display the edit controls. This is not as sophisticated as some applications - perhaps you would design a real application so that multiple objects could be edited at once. However, this approach is simple and makes it clear how to go about handling the different cases.
A good way of arranging sets of controls to appear and disappear in the described fashion is to use a tabbed interface, but without actually having the tabs themselves. As we detect each case, we switch to the appropriate pane of the tab which we have already set up with our desired controls. This way, we do not have to worry about individually hiding or showing particular controls, which is certainly possible, but requires more complex code.
To detect each case, we need only look at the count of objects in the selection array. If the count is zero, we know there is nothing selected. If it is one, we have a single object, and if it is any other number, we have a multiple selection. Thus the code to determine the selection state is very simple indeed.
Building the interface
[edit | edit source]In IB, open the 'MainMenu.nib' file. Add a Panel window to the file and use IB's inspector to make it a utility window with the title 'Inspector'.
Drag an NSTabView to the window. Initially it will have visible tabs, which makes it easier to handle. Later we will hide these. We need three tabs - use the inspector to set this. Size the tab view so that it is comfortably within the window. The first tab will be for the 'No selection' case, so select this tab and drag a text item into the window. Change the text to 'No selection', and set its colour to light grey. Change the identifier of the tab to the string 'none'. In our code we will be selecting the displayed tab using these identifiers.
Switch to tab 3. This will be the multiple selection case, so drag a text item as before and set its text to 'Multiple selection'. Set the identifier for this tab view to 'multi'.
Switch to tab 2. This will be our actual edit controls tab, and we need quite a few. Set the identifier for this tab to 'std'. As shown in the screenshot, I divided it up into two sections, stroke and fill. Each section contains a radio button selection and a colour well. The stroke section also contains a slider control used for setting the stroke line width. Find all of these controls and drag them in. Arrange them neatly. The stroke width slider is set to have a range of 0.3 to 20.0 - this will eventually become the line width value. Set the number of markers to 21 and to be above the slider. Also check the 'send action continuously while sliding' and make sure the 'stop on tick marks only' is unchecked.
Finally, select the tab view again and set it to have hidden tabs. Position and size the tab view so that it fills the panel window and that each tab looks as you want it, and that you can see all of the controls and they are positioned neatly.
Now we need to create a controller for the inspector panel. Switch to the 'classes' tab in the main window, and select the NSWindowController class. Choose Classes->Subclass NSWindowController. Give the new class the name of 'InspectorController'. We need to add all of our actions and outlets now. Double-click the new class name to display the actions and outlets editor in the IB inspector. Add the following actions:
- fillColourButtonAction:
- fillColourDidChange:
- strokeColourButtonAction:
- strokeColourDidChange:
- strokeWidthDidChange:
Switch to the outlets editor. Add the following outlets:
- panelTabControl
- fillColourButtons
- fillColourWell
- strokeColourButtons
- strokeColourWell
- strokeWidthSlider
Now we can generate the code files for the InspectorController. Choose Classes->Create files for InspectorController. Accept the defaults and click Choose in the subsequent dialogue.
Next, instantiate the controller. Select the new class and Choose Classes->Instantiate InspectorController. A new instance is added to the main window.
Finally, we need to hook up all of the outlets and actions. Switch to the 'Instances' tab in the main window. First the outlets: control-drag FROM the box representing the InspectorController instance to the various controls in the panel. It should be clear from the names which controls are meant to be connected to which outlet. The tab pane itself might be tricky - you may need to show the tabs again temporarily to be able to highlight the tab view as a target. It is important that the 'panelTabControl' outlet connects to the NSTabView and not to the NSView that is inside it. Also, connect the outlet 'window' to the panel window itself.
Next connect the actions. Control-drag FROM each of the active controls TO the InspectorController instance. Again, the names should indicate which actions go with which controls. Only active controls need an action - the text you added to the other panes in the tab view are passive and have no actions.
Finally, add a menu command to the main menu bar under 'Window' for the 'Show Inspector' command. Its action should be the showWindow: action in the InspectorController.
Once you are satisfied that you have connected every action and outlet correctly, SAVE the file and return to Xcode.
Coding the inspector
[edit | edit source]The first thing we need to do is to add a notification to our document so that when the selection changes, any interested objects get to know about it. The inspector will listen for this notification, and respond accordingly. There are four methods that affect the selection state in MyDocument:
- (void) selectObject:(id) object; - (void) deselectObject:(id) object; - (void) selectAll; - (void) deselectAll;
We will need to make a small modification to the code of all four to make sure that the inspector 'keeps up' with selection changes. Since a notification needs a name, let's define one.
extern NSString* notifyObjectSelectionDidChange;
This goes outside of the class definition, below the '@end' statement in MyDocument.h
In MyDocument.m, add the following:
NSString* notifyObjectSelectionDidChange = @"objectSelectionDidChange";
This goes before the '@implementation' statement. All we are doing is declaring a global string that represents this particular event. By declaring it 'extern' in our .h file, we allow other code to know about this string without having to care where it actually is. Our .m file actually gives the string its real substance.
Now, change the selection methods as follows:
- (void) selectObject:(id) object { if([_objects containsObject:object] && ![_selection containsObject:object]) { [_selection addObject:object]; [[NSNotificationCenter defaultCenter] postNotificationName:notifyObjectSelectionDidChange object:self]; } } - (void) deselectObject:(id) object { [_selection removeObject:object]; [[NSNotificationCenter defaultCenter] postNotificationName:notifyObjectSelectionDidChange object:self]; } - (void) selectAll { [_selection setArray:_objects]; [[NSNotificationCenter defaultCenter] postNotificationName:notifyObjectSelectionDidChange object:self]; } - (void) deselectAll { [_selection removeAllObjects]; [[NSNotificationCenter defaultCenter] postNotificationName:notifyObjectSelectionDidChange object:self]; }
All we have done is add a line to each that posts a notification with the name we declared whenever the selection changes. Note that we don't bother trying to notify what sort of change was made, only that it has changed. The receiver of the message can call us back to get further information if it needs to.
At this stage, it may be worth compiling and running the application to check that it still works OK. So far we haven't added any visible new functionality.
The code for the inspector itself will be added in InspectorController.m. This is one of the files that IB made for us in the last section. If you select this file, you will see that it has already expanded the action methods for us. We do need two other however, so set things up initially and tear them down properly. Here are the awakeFromNib method and the dealloc method:
- (void) awakeFromNib { [(NSPanel*)[self window] setFloatingPanel:YES]; [(NSPanel*)[self window] setBecomesKeyOnlyIfNeeded:YES]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(selectionChanged:) name:notifyObjectSelectionDidChange object:nil]; } - (void) dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; [super dealloc]; }
As with our tools palette, we first just make sure that the panel window floats and doesn't become key unless absolutely necessary. Then, we subscribe to the notification from MyDocument that we set up above. In order for this file to know about our notification name and the methods of MyDocument, also add '#import "MyDocument.h"' to the top of the file.
Switch to InspectorController.h and add this method to the class definition:
- (void) selectionChanged:(NSNotification*) note;
Back in the .m file, expand this method as follows:
- (void) selectionChanged:(NSNotification*) note { MyDocument* doc = (MyDocument*)[note object]; NSArray* sel = [doc selection]; NSString* tab; NSLog(@"selection changed, objects selected = %d", [sel count]); switch([sel count]) { case 0: tab = @"none"; break; case 1: tab = @"std"; break; default: tab = @"multi"; break; } [panelTabControl selectTabViewItemWithIdentifier:tab]; }
This is where the notification of selections changing gets actually responded to. It is called every time the selection state in the main document changes. The first thing it does is to find out which document the message came from. The sender of the message is part of the notification itself, obtained using the doc = [note object] message. Next, we need the selection, so we ask the document for it, sel = [doc selection]. Then we can simply count the number of objects to figure out which pane of our inspector we need to display. According to whether 0, 1 or more objects are selected, we set up the string 'tab' with the required identifier (which you will recall we established in Interface Builder - if this doesn't work check that the identifiers match). Finally, we simply set the tab using the identifier. We have a reference to the tab view because it's one of our outlets, and it was set up automatically.
Now we can test this. Compile and run the application. Show the tools palette and the inspector palettes using the menu commands. Create a few objects in the main document. Use the selection tool to select different objects, more than one object and no objects - verify that the inspector displays the appropriate controls according to the selection state.
The next step is to hook up the edit controls to the selected object so we can edit the object's properties. First we write some code to set the state of the controls to match the selected object's state. Then we fill in the action methods so that the object's state can be changed.
In the InspectorController.h file, add the following method definition:
- (void) setupWithShape:(WKDShape*) shape;
Now, because we declare this to take a WKDShape* parameter, we'll need this file to 'know about' that class. We could #import WKDShape.h here, but that would mean that any file that only needed to know about the inspector would also pull in the shape file, which it might not need. So instead, for efficiency and to reduce interdependency, we 'forward declare' the class at this stage. This is simple:
@class WKDShape;
Add that line above the '@interface' statement. All it says is that there is a class, somewhere, called WKDShape. At this point it doesn't need any internal details of WKDShape, so it avoids the inefficiency of importing the entire file.
Back in the .m file, we flesh out this method as follows:
- (void) setupWithShape:(WKDShape*) shape { NSColor* fill = [shape fillColour]; NSColor* strk = [shape strokeColour]; if ( fill ) { [fillColourButtons selectCellWithTag:0]; [fillColourWell setEnabled:YES]; [fillColourWell setColor:fill]; } else { [fillColourButtons selectCellWithTag:1]; [fillColourWell setEnabled:NO]; } if ( strk ) { [strokeColourButtons selectCellWithTag:0]; [strokeColourWell setEnabled:YES]; [strokeColourWell setColor:strk]; } else { [strokeColourButtons selectCellWithTag:1]; [strokeColourWell setEnabled:NO]; } [strokeWidthSlider setFloatValue:[shape strokeWidth]]; }
It's quite obvious really - it simply gets the fill and stroke properties of the shape object passed in, and uses them to set the states of the controls via the outlets in the controller. If there is no colour associated with the stroke or fill, this is used to select the 'none' option by using the tag of the radio button. In that case, the colour well is also disabled.
Because this code does need the internal details of WKDShape, the .m file does need to #import WKDShape.h, so add that line to the top of the file.
Now, hook up this method by modifying the 'selectionChanged:' method:
case 1: tab = @"std"; [self setupWithShape:[sel objectAtIndex:0]]; break;
Because in this case we know that the selection contains only one object, it must be the object at index 0 in that array. We can set up the controls before the tab panel actually switches to them without any problems.
Compile and run the application and verify that now when an object is selected, the inspector displays the default colours which are black stroke and white fill.
Finally, let's make the inspector interactively edit the selected object.
Editing the shape
[edit | edit source]There are a couple of things we'll need to do first. We have a bunch of different action methods all hooked up to various inspector controls, but they all affect the same selected object. We'll therefore need to track which object this is so that we can edit it at any time, as long as it remains selected. To do this, we'll add a reference to it as a data member of the inspector class. So in InspectorController.h, add:
WKDShape* editShape;
as a data member. For safety, we'll also add a method called setShape:, which handles the usual retain/release of a shape as the selection changes. We could set this up so that a retain isn't required, but then we'd have to be careful that there could never be any circumstances that a stale reference could exist. This way is more straightforward, safer and 'the Cocoa way'. So expand setShape as follows:
- (void) setShape:(WKDShape*) shape { [shape retain]; [editShape release]; editShape = shape; }
Add a call to [self setShape:shape] at the top of the setupWithShape: method. Now we can simply refer to editShape safely at any time.
One more thing we need to do to prepare. You'll recall that the view that displays the shapes is responsible for drawing them, but we will be editing the shape's properties directly. In order for the changes to be visible immediately, there needs to be a way to tell the view to refresh the shape's bounds rectangle whenever something changes. We don't want to encumber the inspector with this task, as it then sets up an unnecessary dependency between the inspector and our view class. Instead, the shapes themselves need to be able to flag the necessary refresh of the view that is drawing them. To accomplish this, we need another notification. When a shape's state changes, it posts a notification. The view responds to this by obtaining the shape's bounds and refreshing that region of the screen. Because the view originally created the shape, establishing the notification is very straightforward, and the inspector plays no part.
In WKDShape.m, add the method:
- (void) repaint { [[NSNotificationCenter defaultCenter] postNotificationName:notifyShapeRequiresRefresh object:self]; }
in the .h file, declare this method and also an extern NSString* for 'notifyShapeRequiresRefresh'. In the .m file, make this string take some suitable value - I used the string @"repaintMe!" - but it can be anything unique.
In each method that changes a property, we need to add a call to [self repaint]; so that the notification gets posted.
Now in WKDDrawView, we need to add a responder for this notification. Add the following method:
- (void) repaintNotification:(NSNotification*) note { WKDShape* shape = (WKDShape*)[note object]; [self setNeedsDisplayInRect:[shape drawBounds]]; }
Finally, add a line to the initWithFrame: method of WKDDrawView so that it subscribes to these notifications:
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(repaintNotification:) name:notifyShapeRequiresRefresh object:nil];
This allows the view to respond to a refresh request from any shape object whenever that shape's properties are changed. There is a small flaw in this scheme as written; sharp-eyed readers may have noticed it. It has to do with multiple documents. At present, a view can't tell whether the shape really belongs to it or not. The effect is however, harmless - it just means more refreshes going on than really necessary if there are multiple documents. You might like to think about how to solve this problem.
Back to the inspector. Now a shape can refresh itself when needed, and we have a way to track the object we are editing, it's a simple matter of hooking up the action methods for each control so they do the right thing.
- (IBAction) fillColourButtonAction:(id) sender { int tag = [[sender selectedCell] tag]; if ( tag == 1 ) { [editShape setFillColour:nil]; [fillColourWell setEnabled:NO]; } else { [fillColourWell setEnabled:YES]; [editShape setFillColour:[fillColourWell color]]; } } - (IBAction) fillColourDidChange:(id) sender { [editShape setFillColour:[sender color]]; } - (IBAction) strokeColourButtonAction:(id) sender { int tag = [[sender selectedCell] tag]; if ( tag == 1 ) { [editShape setStrokeColour:nil]; [strokeColourWell setEnabled:NO]; } else { [strokeColourWell setEnabled:YES]; [editShape setStrokeColour:[strokeColourWell color]]; } } - (IBAction) strokeColourDidChange:(id) sender { [editShape setStrokeColour:[sender color]]; } - (IBAction) strokeWidthDidChange:(id) sender { [editShape setStrokeWidth:[sender floatValue]]; }
This should be clear - we extract the value of the sender and pass it along to the relevant property. For the radio buttons, we pass along nil if the 'none' button is selected (tag == 1), otherwise the current well colour. We also implement some UI state here, disabling the colour well if the 'none' option is chosen.
Compile and run to test - you should find that the inspector is now fully interactive. The selected object refreshes itself using the repaint notification, so the inspector is free to simply set the relevant property without caring how it handles the display. This is the correct separation of functionality - the controller (inspector) changes the data model (shape) which flags the change to the view which obtains whatever it needs from the data model to update the screen. This is how MVC is meant to work.