Cocoa Is My Girlfriend

Taglines are for Windows programmers

Adding iTunes-style search to your Core Data application

iTunes has a very neat way of searching your library, where it takes each word in your search and tries to find that word in multiple fields.  For example, you can search for “yesterday beatles” and it will match “yesterday” in the Name field and “beatles” in the Artist field. The basic predicate binding for NSSearchField provided by Interface Builder is not complex enough to archive this kind of search.  I need to build the predicate dynamically since I can’t assume what field the user is trying to search and that each additional word should filter the list further - just like iTunes.  Here is how to go about adding iTunes-style searching.

In my application I have a simple Core Data model with one entity (song) and 3 attributes (name, album and artist).

Add the following to iTunesFilter_AppDelegate.h:

IBOutlet NSSearchField *searchField;
IBOutlet NSArrayController *songArrayController;

and

- (IBAction)filterSongs:(id)sender;

With those changes I can go into Interface Builder and wire up the NSSearchField to searchField and my NSArrayController to songArrayController.  I also make the action of the NSSearchField, filterSongs:.

I have one more change and that is to make the Action of the NSSearchField “Sent on End Editing”

All we need now is to add the code for -filterSongs to iTunesFilter_AppDelegate.m:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
- (IBAction)filterSongs:(id)sender {
NSMutableString *searchText = [NSMutableString stringWithString:[searchField stringValue]];
 
// Remove extraenous whitespace
while ([searchText rangeOfString:@"  "].location != NSNotFound) {
    [searchText replaceOccurrencesOfString:@"  " withString:@" " options:0 range:NSMakeRange(0, [searchText length])];
}
 
//Remove leading space
if ([searchText length] != 0) [searchText replaceOccurrencesOfString:@" " withString:@"" options:0 range:NSMakeRange(0,1)];
 
//Remove trailing space
if ([searchText length] != 0) [searchText replaceOccurrencesOfString:@" " withString:@"" options:0 range:NSMakeRange([searchText length]-1, 1)];
 
if ([searchText length] == 0) {
    [songArrayController setFilterPredicate:nil];
    return;
}
 
NSArray *searchTerms = [searchText componentsSeparatedByString:@" "];
 
if ([searchTerms count] == 1) {
    NSPredicate *p = [NSPredicate predicateWithFormat:@"(name contains[cd] %@) OR (album contains[cd] %@) OR (artist contains[cd] %@)", searchText, searchText, searchText];
    [songArrayController setFilterPredicate:p];
} else {
    NSMutableArray *subPredicates = [[NSMutableArray alloc] init];
    for (NSString *term in searchTerms) {
        NSPredicate *p = [NSPredicate predicateWithFormat:@"(name contains[cd] %@) OR (album contains[cd] %@) OR (artist contains[cd] %@)", term, term, term];
        [subPredicates addObject:p];
    }
    NSPredicate *cp = [NSCompoundPredicate andPredicateWithSubpredicates:subPredicates];
 
    [songArrayController setFilterPredicate:cp];
}
}

Here’s how it works:
After we remove extra whitespace in the string, we breakdown the string into each word (-componentsSeparatedByString:).  Then we build an NSPredicate for each word in the string, before the real magic happens by combining these together using NSCompoundPredicate andPredicateWithSubpredicates:

In the end I get a predicate that looks like this:
((name contains[cd] yesterday) or (artist contains[cd] yesterday) or (album contains[cd] yesterday)) and ((name contains[cd] beatles) or (artist contains[cd] beatles) or (album contains[cd] beatles))

iTunesFilter Sample Application



2 Comments so far

  1. benzado November 25th, 2008 9:09 pm

    How well does that perform?

    An alternative would be to maintain an indexed “all text” field that contains the concatenation of all the text of the other fields. Then the query need only be performed against one index.

  2. Jonathan Badeen January 4th, 2009 3:46 pm

    Just in case you didn’t know, you can very easily add this functionality via the predicate binding in Interface Builder. In my case I am binding to an array controller w/ a controller key of filterPredicate and my predicate format is as follows:

    firstName contains[c] $value || lastName contains[c] $value || agent contains[c] $value || role.title contains[c] $value

    I also have additional predicate bindings for each individual field so the user can click on the arrow to choose more specifically what they want to search.

Leave a reply

You must be logged in to post a comment.