Publishers of technology books, eBooks, and videos for creative people

Home > Articles > Apple > Operating Systems

  • Print
  • + Share This
This chapter is from the book

Step 2: Implement the Add Entry Push Button

You learned in Step 7 of Recipe 2 how to connect an existing action method to a button to make the button respond when the user clicks it. Creating a stub for a custom action method and hooking it up is easy. The hard part in this step and the next will be figuring out how to write the body of the -addEntry: and -addTag: action methods. Start with the Add Entry button.

  1. Open the DiaryWindowController.h header file in Xcode. Add this action method declaration above the @end directive:

    - (IBAction)addEntry:(id)sender;

    Open the DiaryWindowController.m source file. Insert the following stub of the -addEntry: action method definition between the existing -otherDiaryView and -windowDidLoad method definitions.

    - (IBAction)addEntry:(id)sender {
    }

    The signature of every action method follows the same pattern, as you saw when you wrote the -newDiaryDocument: action method in Step 5 of Recipe 3. The IBAction return type is a synonym for void, and it informs Interface Builder that this is an action method. Every action method takes a single parameter, the sender, usually typed as id for maximum flexibility.

    When you write the body of an action method, you are free to ignore the sender parameter value, but it can be very useful. Developers often forget that the sender parameter is available in an action method. They go to extraordinary lengths to build up a reference to the object that sent the action message, when all along the sender was right there begging to be used. For example, you can determine whether the sender was a button or a menu item and, if it was a complex control, what the settings of its constituent cells were after its action method was sent.

  2. Open the DiaryWindow nib file in Interface Builder. Control-drag from the Add Entry button in the diary window to the First Responder proxy in the Diary-Window nib file's window, and then select the addEntry: action in the Received Actions section of the HUD. The Add Entry button is now connected. Clicking it while the application is running executes the -addEntry: action method.

    Save the nib file once you've done this.

  3. Several techniques can be used to verify that an action method is properly connected. One is to put a call to Cocoa's NSLog() function in the body of the action method, then build and run the application and watch the debugger console while you click the button. To do this now, add this statement to the -addEntry: stub implementation in the DiaryWindowController.m source file:

             NSLog(@"Add Entry button was clicked.");
          

    Choose Run > Console and position the Debugger Console window so that you can see it while the application is running. Then build and run the application, choose File > New Chef's Diary, and click the Add Entry button. If the action method is properly connected, you see a message in the Debugger Console showing the date and time, the name of the application with some cryptic information about it, and the message you specified in the NSLog() function call.

    The NSLog() function call would be more useful if it identified what you clicked by using the sender parameter value instead of hard coding it in the string. You will add an Add Entry menu item to the menu bar in Recipe 5, and it would be disconcerting to see a console message that the Add Entry button was clicked when you had actually chosen the Add Entry menu item. Change the NSLog() function call to this:

             NSLog(@"Add Entry; sender is %@ named \"%@\"", sender, [sender title]);
          

    Now the debugger console shows the title of the button as well as its class. This will work with a menu item, too, because menu items also respond to the -title message.

    The string in the first parameter of the NSLog() function is called a format string because it can optionally contain placeholders. At run time, the values of the following parameters replace the placeholders in order. The two placeholders (%@) in the format string here are filled in at run time with the sender's object and its title. The %@ placeholder is for object values only, including NSString object values. Different placeholders are available for a variety of other data types. Bookmark the "String Format Specifiers" section of Apple's String Programming Guide for Cocoa. It lists all of the available placeholders, and you will consult it often. If you use the wrong placeholder, the resulting type mismatch may generate its own runtime error, and, ironically, your debugging effort may itself be difficult to debug.

  4. Now you're ready to write the body of the -addEntry: action method. Consider first what it should accomplish. It may be called in two situations: where the text of the diary window is empty, and where there is already some text in the window. The Add Entry button should append the title of a new diary entry to the end of whatever text is currently in the diary, starting on a new line if necessary, or simply insert it if the diary is empty. The title should be the current date in human-readable form, preceded by a special character reserved to mark the beginning of a diary entry. You will use the special marker character later to search for the beginning of diary entries, so it must be a character that the user would never type. The title should be in boldface so that it stands out visually. Undo and redo must be supported. Finally, the action method should end by inserting a new line so that the user can immediately begin typing the new entry.

    In writing the -addEntry: action method, consider the MVC design pattern. Details regarding the structure and content of the diary, such as the fact that the diary is organized into entries having titles in the form of human-readable date and time strings and the special character used to mark a diary entry, belong in the MVC model. You should place methods relating to these features of the Chef's Diary in the DiaryDocument class. In the case of an entry's title, you therefore implement methods in DiaryDocument to return the title itself and the special marker character that precedes it, as well as a method that constructs the white space around the title that is required for proper spacing above and below it. These methods are -entryMarker, -entryTitleForDate:, and -entryTitleInsertForDate:. In the course of writing the action method, you call -entryTitleInsertForDate: to obtain the fully constructed insert to be added to the diary.

    Before you begin, delete the NSLog() function call you wrote earlier because you no longer need it.

    Now implement the -addEntry: action method in the DiaryWindowController.m implementation file, as set out here:

    - (IBAction)addEntry:(id)sender {
        NSTextView *keyView = [self keyDiaryView];
        NSTextStorage *storage = [keyView textStorage];
        NSString *titleString = [[self document]
               entryTitleInsertForDate:[NSDate date]];
        
        NSRange endRange = NSMakeRange([storage length], 0);
        if ([keyView shouldChangeTextInRange:endRange
               replacementString:titleString]) {
            [storage replaceCharactersInRange:endRange
                   withString:titleString];
            endRange.length = [titleString length] - 1;
            [storage applyFontTraits:NSBoldFontMask range:endRange];
            [keyView didChangeText];
            [[keyView undoManager] setActionName:
                   NSLocalizedString(@"Add Entry",
                   @"name of undo action for Add Entry")];
    
            [[self window] makeFirstResponder:keyView];
            [keyView scrollRangeToVisible:endRange];
            [keyView setSelectedRange:
                   NSMakeRange(endRange.location + endRange.length + 1, 0)];
        }
    }

    The first block sets up three local variables, keyView to hold the text view in whichever pane of the diary window currently has keyboard focus; storage to hold the view's text storage object; and titleString to hold the entry title insert obtained from the diary document. Foundation's +[NSDate date] method returns an NSDate object representing the current date and time.

    You define keyView to specify which of the two text views in the split pane currently has keyboard focus. You do this by calling a utility method you will write shortly, -keyDiaryView, which uses NSWindow's -firstResponder method. It is important to use the correct view, because at the end of the -addEntry: method you scroll the new entry title into view and select it. These visual changes should occur in the pane the user is editing. You use keyView throughout the -addEntry: method as a convenient way to refer to the text view.

    The documentation for Cocoa's text system recommends that you always perform programmatic operations on the contents of a text view by altering the underlying text storage object. Although NSTextView implements a number of methods that alter the contents of the view without requiring you to deal directly with the text storage, such as -insertText:, you can't use them here because they are designed to be used only while the user is typing.

    In -addEntry:, you get the diary's text storage object through its text view object, which is described in the NSTextView Class Reference as the front end to the Cocoa text system. Apple's Text System Overview goes so far as to say that most developers can do everything using only the NSTextView API. You could just as easily have called the -diaryDocTextStorage accessor method in DiaryDocument to get the text storage object, but MVC principles make clear that it is preferable to use methods that don't require the window controller to know anything about specific details of the document's implementation.

    You set the titleString local variable by calling another method you will write shortly, -entryTitleInsertForDate:.

    The next block uses NSMutableAttributedString's -replaceCharactersInRange:withString: method, inherited by NSTextStorage, to insert the entry title at the end of the Chef's Diary.

    The call to -shouldChangeTextInRange:replacementString: that precedes this operation tests whether changing the contents of the text storage is currently permitted. You should always run this test before changing text that is displayed in a text view, even if you're confident that changing the text is appropriate. You—or Apple—might later decide to override-shouldChangeTextInRange:replacementString: to return NO under circumstances that you haven't anticipated. By default, it returns NO only when the text view is specified as noneditable.

    These two methods, like many methods in the Cocoa text system, require an NSRange parameter value identifying the range of characters to be changed. You're going to need the range in other statements, as well, for example, to scroll the text view to the newly inserted title. You define it just before the test as a range of zero length located at the end of the existing text, if any, storing it in the endRange local variable.

    The method then revises the length of endRange to reflect the fact that you have now added the title string to the end of the document. It uses this new range to apply the NSBoldFontMask font trait to the title. You exclude the trailing newline character from the boldface trait to ensure that the text the user begins typing after adding the title is not boldface.

    The method next uses a standard text system technique to make the user's insertion of a new diary entry undoable. Until now, you have operated under the assumption that text views automatically implement undo and redo because you selected the Undo checkbox in the Text View Attributes inspector in Interface Builder. Your assumption is correct, but only as long as the user performs built-in text manipulation operations. When you implement a custom text manipulation operation that isn't part of NSTextView's built-in typing repertoire, you have to pay attention to undo and redo yourself.

    Whenever you implement a method, such as the -addEntry: method here, that gives the user the ability to perform a custom operation in a text view, you must bracket the code with calls to -shouldChangeTextInRange:replacementString: and -didChangeText. This ensures that all the expected notifications get sent and all the expected delegate methods get called, and it ensures that the operation is undoable and redoable.

    The titles of the Undo and Redo menu items should reflect the nature of the undo or redo operation. You accomplish this by passing the string @"Add Entry" to the undo manager's -setActionName: method, using the NSLocalizedString() macro to make sure your localization contractor can translate the string for use in other locales. In the English locale, the menu item titles will be Undo Add Entry and Redo Add Entry.

    In the final block, you scroll the title into view. In case the user has set the Full Keyboard Access setting to "All controls" in the Keyboard Shortcuts tab of the Keyboard pane of System Preferences, you also set the current keyboard focus on the text view in the top pane of the split view, unless it was already on the bottom pane. This respects the user's expectation that clicking the Add Entry button enables typing immediately below the new entry's title, even if some other view in the window, such as the search field, had keyboard focus.

  5. Write the -keyDiaryView method called at the beginning of -addEntry:. This is a very short method and its code could just as well be written in line. However, you will need it in several places so it's a good candidate for a separate method.

    At the end of the DiaryWindowController.h header file, insert this declaration:

    - (NSTextView *)keyDiaryView;

    In the DiaryWindowController.m implementation file, define it like this:

    - (NSTextView *)keyDiaryView {
         return ([[self window] firstResponder] == [self otherDiaryView]) ?
                [self otherDiaryView] : [self diaryView];
    }
    

    If the user is currently typing in the bottom pane of the diary window, this method returns otherDiaryView. If any other view in the window has keyboard focus, it returns the primary text view, diaryView, which is in the top pane of the window.

  6. Next, add the -entryTitleInsertForDate: method and its two supporting methods to the DiaryDocument class. They belong in DiaryDocument, not DiaryWindowController, because they provide information about the structure and content of the document.

    In the DiaryDocument.h header file, declare them as follows:

    - (NSString *)entryMarker;
    - (NSString *)entryTitleForDate:(NSDate *)date;
    - (NSString *)entryTitleInsertForDate:(NSDate *)date;

    In the DiaryDocument.m implementation file, define them as follows:

    - (NSString *)entryMarker {
         return DIARY_TITLE_MARKER;
    }
    - (NSString *)entryTitleForDate:(NSDate *)date {
         NSString *dateString;
         if (floor(NSFoundationVersionNumber)
                <= NSFoundationVersionNumber10_5) {
             NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
             [formatter setDateStyle:NSDateFormatterLongStyle];
             [formatter setTimeStyle:NSDateFormatterLongStyle];
             dateString = [formatter stringFromDate:date];
             [formatter release];
         } else {
             dateString = [NSDateFormatter localizedStringFromDate:date
                    dateStyle:NSDateFormatterLongStyle
                    timeStyle:NSDateFormatterLongStyle];
         }
         return dateString;
    }
      
    - (NSString *)entryTitleInsertForDate:(NSDate *)date {
         NSTextStorage *storage = [self diaryDocTextStorage];
         NSString *leadingWhitespaceString;
         if ([storage length] == 0) {
             leadingWhitespaceString = @"";
         } else if ([[storage string] hasSuffix:@"\n"]) {
             leadingWhitespaceString = @"\n";
         } else {
             leadingWhitespaceString = @"\n\n";
         }
         NSString *formatString = @"%@%@ %@\n";
         return [NSString stringWithFormat:formatString,
                leadingWhitespaceString, [self entryMarker],
                [self entryTitleForDate:date]];
    }

    The first method, -entryMarker, simply returns DIARY_TITLE_MARKER. Don't forget to define it. At the top of the DiaryWindowController.m source file, add this line below the #import directives:

    #define DIARY_TITLE_MARKER @"\u270E" // Unicode LOWER RIGHT PENCIL

    You can now use the DIARY_TITLE_MARKER macro anywhere in this file's code, and the preprocessor automatically substitutes the string. You return it in a method as well, in case you need it in the diary window controller.

    The second method, -entryTitleForDate:, formats the current date and time into a string suitable for use as the title of a new diary entry. You should recognize the opening if test from Step 3 of Recipe 2, where you learned that the statements in the first branch are executed only when the application is running under Leopard and not under Snow Leopard. In fact, these statements would work correctly under Snow Leopard, too. However, a new convenience method was introduced in Snow Leopard that does the same thing in a single statement. This book is focused on Snow Leopard, so you should know how to use it. Because the new method is in Foundation, not AppKit, you test against NSFoundationVersionNumber10_5.

    Although there are other ways to create a date string, the Cocoa documentation recommends that you use NSDateFormatter, a built-in Cocoa class. Here, you allocate and initialize a date formatter object using its designated initializer, -init, and then you set both its date style and its time style to NSDateFormatterLongStyle, one of several date and time styles declared in Cocoa. Then you tell the formatter to create and return a string reflecting these styles by calling the formatter's -stringFromDate: method. When that is done, you release the formatter because you're through with it.

    When Vermont Recipes is running under Snow Leopard, all this work is done by a single new method, +localizedStringFromDate:dateStyle:timeStyle:. It is a class method that you can call globally without allocating and initializing your own date formatter. You don't release the formatter afterward because you didn't create it.

    Both techniques set up the date string according to the date and time styles you specified. The styles control which date and time elements appear in the string and whether short or long forms are used. The user's locale and current date and time formatting preferences are honored, as set in the Language & Text pane in System Preferences.

    The third method, -entryTitleInsertForDate:, sets up the leadingWhitespaceString local variable. It then assembles it, the marker character, and the entry title into a combined string suitable to be inserted into the Chef's Diary. The leadingWhitespaceString string is used to ensure that there is always a blank line before a new diary entry's title. If the text field is currently empty, of course, there should be no white space before the initial entry's title. If it is not empty, the application specification calls for a new title to be added at the end of the text view's content. If the last character in the text view is a newline character, one more newline character is added. Otherwise, two newline characters are added. This ensures that a blank line always separates a new entry's title from a preceding entry's text.

    The method then uses NSString's +stringWithFormat: class method. This is a workhorse that you will use very frequently to compose formatted strings. It takes a variable number of parameters, the first of which is a format string like the format string you used in the NSLog() function earlier. The remaining parameters generate objects or other types of data and place them into the corresponding placeholders embedded in the text of the format string. Here, you define a format string with three placeholders and some whitespace. After assigning the format string to a local variable, formatString, you pass it and other information into the +stringWithFormat: method.

    Examine the format string. The first placeholder is %@. You saw it in the NSLog() function a moment ago. Here, you place the leadingWhitespaceString object in the placeholder, even if it is an empty string.

    The second placeholder is also %@, separated from the first placeholder by a space character that will appear in the final string. You replace the second placeholder with the entry title marker character, which you defined earlier as an NSString object holding the Unicode LOWER RIGHT PENCIL character, code point 0x270E. This is the special character you will use to mark the beginning of each diary entry in the diary window.

    Pause for a moment to consider this second placeholder. If you were building the Vermont Recipes application under Leopard, you would probably write the format string as @"%@%C %@\n", and you would define DIARY_TITLE_MARKER not as a string but as the hexadecimal number 0x270E. The second placeholder would be %C, not %@. Look it up in the "String Format Specifiers" section of Apple's String Programming Guide for Cocoa, which you bookmarked a moment ago, and you see that the %C placeholder is designed to hold a 16-bit Unicode code point.

    When you build an application under Snow Leopard, you don't have to use %C and the numeric code point for a Unicode character. Xcode 3.2 in Snow Leopard compiles code using the C99 dialect of the C programming language by default, and C99 lets you define strings using an escape sequence for Unicode characters like this: @"\u270E". The compiled C99 object code runs perfectly well under Leopard and Snow Leopard. In fact, you could even build the application under Leopard using the escape sequence, if you set up the project to use the C99 dialect.

    An easy way to find Unicode characters is Apple's Character Viewer. Add it to your menu bar by opening the Keyboard tab of the Keyboard pane in System Preferences and selecting the "Show Keyboard & Character Viewer in menu bar" setting. In many applications, you can open it by choosing Edit > Special Characters. Choose it from an application or choose Show Character Viewer from the menu bar item, choose Code Tables from the View pull-down menu, and scroll to Unicode block 00002700 (Dingbats).

    The third placeholder in the format string is another %@ placeholder. Here, you replace it with the title string returned by the -entryTitleForDate: method you just wrote.

    The format string contains the escape sequence \n at the end. This escape sequence also appears in the whitespaceString variable. It inserts the standard newline control character into the string. You could just as well have used \r for the carriage return control character, or \r\n for the standard Windows line-ending control character sequence. When you use this string to create an attributed string for insertion into the text view, Cocoa automatically interprets any of these escape sequences as a signal to begin a new paragraph in the text view. You should make a habit of using one of them consistently, however, because you must sometimes search for it.

  7. If you build and run the application now, you find that the Add Entry button works exactly as expected. Try it. Open a new Chef's Diary window and click Add Entry. A new boldface title appears in the text view. A cute little pencil image appears at the beginning of the title. Type some more text, and then click Add Entry again. Another title appears at the end, separated by a blank line from the preceding text. Choose Undo Add Entry and Redo Add Entry, and see that undo and redo work.

    Try a different sequence of actions. Open a new Chef's Diary window, and then click Add Entry. Then save the document and click Add Entry again. Choose Undo Add Entry, and the second entry title goes away, leaving the document marked clean because it is now back in the state it was in when you saved it. Open the Edit menu again, and you see an Undo Add Entry menu item. Choose it, and the first title is removed, leaving the document marked dirty because it now differs from the document as you saved it. Choose Redo Add Entry, and the title is reinserted, leaving the document marked clean because you are now back at the point where you saved it. You have just undone an action back in time past the save barrier. This ability was introduced in Mac OS X 10.4 Tiger and is now standard. Some applications, such as Xcode, present an alert asking whether you really want to undo past the save barrier, but most applications don't do this because users have come to expect it. The ability to undo past the save barrier does not survive quitting the application.

  8. In the course of playing with the text view, you might discover one inconsistency regarding undo past the save barrier. Try typing some text in the Chef's Diary window, then save the document, and then type some more text. Now choose Edit > Undo Typing. All of the text you typed, both before and after the point where you saved the document, is removed at once, and choosing Edit > Redo Typing restores all of it.

    The typical user expects that saving a document will interrupt the collection of undo information. After you save a document and type some more text, Undo Typing should undo only back to the point at which the document was saved. To undo the typing before the save operation should require another invocation of Undo Typing.

    According to Apple's NSTextView Class Reference, you should resolve this issue by invoking -breakUndoCoalescing every time the user saves the document.

    Apple does this in its TextEdit sample code by overriding NSDocument's implementation of the -saveToURL:ofType:forSaveOperation:error: method. The problem with TextEdit's implementation, as the TextEdit sample code notes in a comment, is that undo coalescing is broken not only when the user saves a document, but also when the document saves itself because autosaving is turned on. The TextEdit sample code points out that this is potentially confusing to the user, who normally won't notice the autosave operation.

    To avoid this problem, override -saveToURL:ofType:forSaveOperation: delegate:didSaveSelector:contextInfo:, instead. You could override the same method that TextEdit overrides and test its saveOperation argument to exclude NSAutosaveOperation operations, as you do here, but this is an opportunity to take advantage of the temporary delegate callback design pattern common in Cocoa. This method is called for all kinds of save operations, including those performed by the Save and the Save As menu items as well as autosave operations. You test for an autosave operation—which you will turn on in Recipe 7—and exclude it from the callback method so that autosaves do not break undo coalescing. To understand the message flow when a document in a document-based application is created, opened, or saved, see the figures in the "Message Flow in the Document Architecture" section of Apple's Document-Based Applications Overview. They contain lists of methods you can override. See also the "Autosaving in the Document Architecture" section.

    Another problem with the TextEdit sample code is that it accesses the document's array of window controllers to call an intermediate -breakUndoCoalescing method written specifically to make this technique work. The intermediate method in turn calls NSTextView's -breakUndoCoalescing method. It is generally preferable to avoid requiring the document to know anything about how the window controller is implemented.

    To avoid this problem in the diary document, take advantage of the Cocoa text system by using the document's NSTextStorage object to locate all of its associated NSTextView objects and call their -breakUndoCoalescing methods.

    In the DiaryDocument.m source file, implement these two methods:

    - (void)saveToURL:(NSURL *)absoluteURL ofType:(NSString *)typeName
            forSaveOperation:(NSSaveOperationType)saveOperation
            delegate:(id)delegate didSaveSelector:(SEL)didSaveSelector
            contextInfo:(void *)contextInfo {
         NSNumber *saveOperationNumber =
                [NSNumber numberWithInt:saveOperation];
         [super saveToURL:absoluteURL ofType:typeName
                forSaveOperation:saveOperation delegate:self
                didSaveSelector:@selector(document:didSave:contextInfo:)
                contextInfo:[saveOperationNumber retain]];
    }
    
    - (void)document:(NSDocument *)doc didSave:(BOOL)didSave
            contextInfo:(void *)contextInfo {
         NSSaveOperationType saveOperation =
                [(NSNumber *)contextInfo intValue];
         [(NSNumber *)contextInfo release];
         if (didSave && (saveOperation != NSAutosaveOperation)) {
             for (NSLayoutManager *manager
                    in [[self diaryDocTextStorage] layoutManagers]) {
                 for (NSTextContainer *container in [manager textContainers]) {
                    [[container textView] breakUndoCoalescing];
                 }
             }
         }
    }

    This is your first contact with a common Cocoa design pattern, the callback selector to a temporary delegate. The delegate argument of the first method allows you to designate any object as the method's temporary delegate. Here, as is commonly the case, you designate self as the temporary delegate. This relationship is only for purposes of the callback method, and it lasts only until the callback method is called.

    The didSaveSelector: parameter allows you to identify a selector for the callback method, which you must implement in the temporary delegate, self. Cocoa will call the callback method identified by the selector automatically when some event occurs. The documentation describes the event that triggers the callback method, and it also specifies the required signature of the callback method. The -saveToURL:ofType:forSaveOperation:delegate:didSaveSelector:contextInfo: method is documented to call the callback method when the save operation completes successfully.

    Here, the -saveToURL:ofType:forSaveOperation:delegate:didSaveSelector:contextInfo: method does only one thing: It calls super's implementation of the method, designating self as the temporary delegate and document:didSave:contextInfo: as the callback selector. The call to super starts the save operation. It also specifies the callback selector, using the @selector() compiler directive. In addition, it encapsulates the saveOperation argument, an integer of type NSSaveOperationType, in an NSNumber object and passes it to the callback method in the contextInfo parameter. You retain the NSNumber object before passing it to the callback method, then release it in the callback method when you're finished with it.

    The callback method is where the important action takes place. First, it tests the didSave argument, since you don't want to break undo coalescing if the save operation as not successful. Next, it extracts that saveOperation value from the contextInfo argument and checks whether it is an autosave operation. If the save operation did complete and it was not an autosave opeartion, the method uses nested for loops to traverse all of the NSLayoutManager objects, all of the NSText-Container objects, and finally all of the NSTextView objects in the diary document's text storage object. It calls -breakUndoCoalescing on each of the text views it finds. This logic covers all possible text system arrangements, so it makes no assumptions about the window controller or the user interface of the application.

    Try the same experiment now. Type some text, save the document, type some more text, and choose Edit > Undo Typing. Only the text added after the document was saved is removed. Choose Undo Typing again, and the rest of it is removed. Choose Redo Typing twice, and the text comes back in two discrete steps. The document is marked dirty or clean correctly based on the point at which it was last saved. This works correctly whether you save the document using the Save or the Save As menu item.

  • + Share This
  • 🔖 Save To Your Account