- Step 1: Add Controls to the Diary Window
- Step 2: Implement the Add Entry Push Button
- Step 3: Implement the Add Tag Push Button
- Step 4: Validate the Add Tag Push Button
- Step 5: Implement and Validate the Navigation Buttons
- Step 6: Implement and Validate the Date Picker
- Step 7: Implement and Validate the Search Field
- Step 8: Build and Run the Application
- Step 9: Save and Archive the Project
- Conclusion
Step 7: Implement and Validate the Search Field
The Vermont Recipes application has a general Find command in the Edit menu, courtesy of the Cocoa document-based application template. Try it out. It finds any text in the diary window, whether the text is located in an entry's title, its tag title, or its text.
But that isn't the only kind of text-based search you want for the Vermont Recipes application. You went to a lot of trouble to provide a tag list in the diary window. It is now time to put it to use by enabling the user to search for tags alone.
The idea behind tags is that you tag every entry with words or very short phrases that describe the content of the entry. Examples of interesting tags are dessert and appetizers. In this step, you implement the search field at the bottom of the diary window so that it finds tags only in tag lists, not in an entry's text, and it selects and highlights the tags so that you can see them while scrolling through the diary. To find every entry that is tagged with the dessert tag, for example, type dessert in the search field. Assuming that multiple entries have that tag, the first dessert tag scrolls into view and briefly highlights. Repeatedly clicking Return in the search field scrolls to each successive instance of the dessert tag and highlights it. All instances remain selected as you scroll up and down using the scroll bar.
To start with, add placeholder text to the search field. In Interface Builder, select the search field and go to the Search Field Attributes inspector. Enter tag in the Placeholder field. This word will appear dimmed in the search field when it does not have keyboard focus, to remind the user that the search field filters the diary on the basis of tags. When the user clicks in the search field to begin typing a tag, the placeholder text disappears automatically.
Write the -findTag: action method. For search fields, the action method you write must search for and display the found text.
In the DiaryWindowController.h header file, declare the action method immediately following the existing action methods, like this:
- (void)findTag:(id)sender;
In the corresponding source file, implement it like this:
- (IBAction)findTag:(id)sender { NSTextView *keyView = [self keyDiaryView]; NSString *tagString = [sender stringValue]; NSArray *tagRangeArray = [[self document] foundTagRangeArrayForTag:tagString]; if ([tagRangeArray count] > 0) { [keyView setSelectedRanges:tagRangeArray]; static NSUInteger highlightIndex = 0; static NSUInteger tagStringLength = 0; if (tagStringLength != [tagString length]) highlightIndex = 0; NSRange highlightRange = [[tagRangeArray objectAtIndex:highlightIndex] rangeValue]; tagStringLength = [tagString length]; highlightIndex = (highlightIndex < [tagRangeArray count] - 1) ? highlightIndex + 1 : 0; [keyView scrollRangeToVisible:highlightRange]; [keyView showFindIndicatorForRange:highlightRange]; } }
The -findTag: action method uses many techniques that you have already seen and used for working with text.
It starts by determining which of the two text fields has keyboard focus, using the -keyDiaryView method you wrote earlier. Then it reads the search field's text to ascertain the target tag by calling the -stringValue method on the sender argument, which in this case is the search field. The algorithm it uses is very simple: There are no tag separator characters. Every character you type into the search field is considered to be part of the tag you're looking for, including spaces and punctuation.
The method then sets up a search for all the tags in the text, from beginning to end, looking for the tag marker character and the immediately following tag label, Tags:. It looks only in tag lists. To do this, it calls another range method you will write in the DiaryDocument class, -foundTagRangeArrayForTag:. As you will see in a moment, that method calls two supporting methods you will write shortly, -firstTagRange and -nextTagRangeForIndex:. The actual search uses NSString's fast and efficient -rangeOfString:options:range: method, which you have already encountered. In the search loop, the range of the first found tag in every tag list is added to a mutable array, tagRangeArray.
In the final section of the code, the array of tag ranges is used by NSTextView's -setSelectedRanges: method, which selects and highlights all of them wherever they appear in the text. Next, one of the selected tags is scrolled into view, and it is then highlighted with a brief, eye-catching animation generated by NSTextView's -showFindIndicatorForRange: method.
The final section of the code implements a simple algorithm to control the order in which instances of the same tag are highlighted on successive presses of the Return key. The search field is configured to display found tags as you type each character into the field. When you type the h in ho, for example, it highlights the first h in the first tag containing an h. If you then hit the Return key while h is still the tag you're searching for, it highlights the first h in the second tag containing an h. It continues in this fashion until it runs out of found h tags, and then it cycles back to the beginning. If, instead of pressing Return after typing h, the user types the o in ho, the method notices that the length of the search text has changed and restarts the cycle. To keep track of the length of the search text and the index of the tag it highlighted the last time the user pressed Return, it uses two static variables, highlightIndex and tagStringLength. The values of static variables are saved between invocations of the method. It is safe to use static variables here, instead of instance variables, because you will eventually take steps to ensure that there can never be more than one diary window.
Now write the -foundTagRangeArrayForTag: method. Like the range methods you have written previously, it belongs in the DiaryDocument class.
In the DiaryDocument.h header file, declare it:
- (NSArray *)foundTagRangeArrayForTag:(NSString *)tag;
In the DiaryDocument.m implementation file, define it:
- (NSArray *)foundTagRangeArrayForTag:(NSString *)tag { if ([tag length] > 0) { NSString *tagLabel = [NSString stringWithFormat: NSLocalizedString(@"%@ Tags: ", @"search string for diary document tag string"), [self tagMarker]]; NSMutableArray *tagRangeArray = [NSMutableArray array]; NSRange searchRange = [self firstTagRange]; NSRange foundRange; while (searchRange.location != NSNotFound) { searchRange.location += [tagLabel length]; searchRange.length -= [tagLabel length]; foundRange = [[[self diaryDocTextStorage] string] rangeOfString:tag options:0 range:searchRange]; if (foundRange.location != NSNotFound) [tagRangeArray addObject: [NSValue valueWithRange:foundRange]]; searchRange = [self nextTagRangeForIndex: searchRange.location + searchRange.length]; } return [[tagRangeArray copy] autorelease]; } return nil; }
The method returns nil if no tag or an empty tag is provided.
Otherwise, it creates a search string tailored to the design of the application's tag list label. Then it searches repeatedly through every tag list in the diary matching that pattern. Using NSString's now-familiar -rangeOfString:options:range: method, it accumulates the ranges of every appearance of the tag in the Chef's Diary's tag lists into the tagRangeArray variable and returns it.
Now write the two supporting methods required by -foundTagRangeArrayForTag:, -firstTagRange, and -nextTagRangeForIndex:.
In the DiaryDocument.h header file, add these two declarations before the @end directive at the end of the DiaryWindowController class:
- (NSRange)firstTagRange; - (NSRange)nextTagRangeForIndex:(NSUInteger)index;
In the DiaryDocument.m source file, implement them like this:
- (NSRange)firstTagRange { if ([[self diaryDocTextStorage] length] == 0) return NSMakeRange(NSNotFound, 0); NSUInteger markerIndex = [[[self diaryDocTextStorage] string] rangeOfString:[self tagMarker] options:0 range:NSMakeRange(0, [[self diaryDocTextStorage] length])].location; return [self rangeOfLineFromMarkerIndex:markerIndex]; } - (NSRange)nextTagRangeForIndex:(NSUInteger)index { if ([[self diaryDocTextStorage] length] == 0) return NSMakeRange(NSNotFound, 0); NSUInteger markerIndex = [[[self diaryDocTextStorage] string] rangeOfString:[self tagMarker] options:0 range:NSMakeRange(index, [[self diaryDocTextStorage] length] - index)].location; return [self rangeOfLineFromMarkerIndex:markerIndex]; }
These are very similar to methods you've already written to return entry title ranges, -firstEntryTitleRange and -nextEntryTitleRangeForIndex:, and they shouldn't require further explanation.
Don't forget to validate the search field. Set this up exactly the way you set up validation for the date picker, except test whether the diary contains any tag lists. Declare and implement the ValidatedDiarySearchField class in DiaryWindowController as a subclass of NSSearchField, declaring that it conforms to the VRValidatedControl protocol. Set this subclass as the search field's class in Interface Builder. Finally, add a clause at the end of the -validateUserInterfaceItem: method, testing for the -findTag: action that you just wrote and checking whether the -firstTagRange is found.
Open the DiaryWindow nib file in Interface Builder. Control-drag from the search field in the diary window to the First Responder proxy in the Diary-Window nib file's window, and then select the findTag: action in the Received Actions section of the HUD. The search field is now connected.
Run the application and test the search field. To start, you have to create a few entries and add tags to them, using the Add Entry and Add Tag buttons. I like to add three entries with these tags: try entering hi ho for the first entry; for the second entry, ho hum; and for the third entry, ho ho ho. Then type ho in the search field. The ho tag in the first entry is highlighted with animation for a moment, and the first instance of the ho tag in each entry remains selected. Press Return several times in succession. All of the ho tags remain selected, but the animated highlight appears on a different entry's ho tag with each press of the Return key. If you spaced out the entries with lines of text, each ho tag in turn scrolls into view before it is highlighted.