39

I'm using Core Data for a table view, and I'd like to use the first letter of each of my results as the section header (so I can get the section index on the side). Is there a way to do this with the key path? Something like below, where I use name.firstLetter as the sectionNameKeyPath (unfortunately that doesn't work).

Do I have to grab the first letter of each result manually and create my sections like that? Is it better to put in a new property to just hold the first letter and use that as the sectionNameKeyPath?

NSFetchedResultsController *aFetchedResultsController = 
[[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest
            managedObjectContext:managedObjectContext
            sectionNameKeyPath:@"name.firstLetter"
            cacheName:@"Root"];

Thanks.

**EDIT: ** I'm not sure if it makes a difference, but my results are Japanese, sorted by Katakana. I want to use these Katakana as the section index.

nevan king
  • 112,709
  • 45
  • 203
  • 241

6 Answers6

80

You should just pass "name" as the sectionNameKeyPath. See this answer to the question "Core Data backed UITableView with indexing".

UPDATE
That solution only works if you only care about having the fast index title scroller. In that case, you would NOT display the section headers. See below for sample code.

Otherwise, I agree with refulgentis that a transient property is the best solution. Also, when creating the NSFetchedResultsController, the sectionNameKeyPath has this limitation:

If this key path is not the same as that specified by the first sort descriptor in fetchRequest, they must generate the same relative orderings. For example, the first sort descriptor in fetchRequest might specify the key for a persistent property; sectionNameKeyPath might specify a key for a transient property derived from the persistent property.

Boilerplate UITableViewDataSource implementations using NSFetchedResultsController:

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return [[fetchedResultsController sections] count];
}

- (NSArray *)sectionIndexTitlesForTableView:(UITableView *)tableView {
    return [fetchedResultsController sectionIndexTitles];
}

- (NSInteger)tableView:(UITableView *)tableView sectionForSectionIndexTitle:(NSString *)title atIndex:(NSInteger)index {
    return [fetchedResultsController sectionForSectionIndexTitle:title atIndex:index];
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    id <NSFetchedResultsSectionInfo> sectionInfo = [[fetchedResultsController sections] objectAtIndex:section];
    return [sectionInfo numberOfObjects];
}

// Don't implement this since each "name" is its own section:
//- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
//    id <NSFetchedResultsSectionInfo> sectionInfo = [[fetchedResultsController sections] objectAtIndex:section];
//    return [sectionInfo name];
//}

UPDATE 2

For the new 'uppercaseFirstLetterOfName' transient property, add a new string attribute to the applicable entity in the model and check the "transient" box.

There are a few ways to implement the getter. If you are generating/creating subclasses, then you can add it in the subclass's implementation (.m) file.

Otherwise, you can create a category on NSManagedObject (I put this right at the top of my view controller's implementation file, but you can split it between a proper header and implementation file of its own):

@interface NSManagedObject (FirstLetter)
- (NSString *)uppercaseFirstLetterOfName;
@end

@implementation NSManagedObject (FirstLetter)
- (NSString *)uppercaseFirstLetterOfName {
    [self willAccessValueForKey:@"uppercaseFirstLetterOfName"];
    NSString *aString = [[self valueForKey:@"name"] uppercaseString];

    // support UTF-16:
    NSString *stringToReturn = [aString substringWithRange:[aString rangeOfComposedCharacterSequenceAtIndex:0]];

    // OR no UTF-16 support:
    //NSString *stringToReturn = [aString substringToIndex:1];

    [self didAccessValueForKey:@"uppercaseFirstLetterOfName"];
    return stringToReturn;
}
@end

Also, in this version, don't forget to pass 'uppercaseFirstLetterOfName' as the sectionNameKeyPath:

NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest managedObjectContext:managedObjectContext
sectionNameKeyPath:@"uppercaseFirstLetterOfName" // this key defines the sections
cacheName:@"Root"];

And, to uncomment tableView:titleForHeaderInSection: in the UITableViewDataSource implementation:

- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
    id <NSFetchedResultsSectionInfo> sectionInfo = [[fetchedResultsController sections] objectAtIndex:section];
    return [sectionInfo name];
}
Community
  • 1
  • 1
gerry3
  • 21,420
  • 9
  • 66
  • 74
  • I tried putting name in as the `sectionNameKeyPath` but ended up with a section for each name returned. I got as many sections as there were names, each section with only one entry (the name). – nevan king Nov 16 '09 at 10:19
  • You might not have things set up properly. Give the answer another read. It works as advertised. – Alex Reynolds Nov 16 '09 at 10:33
  • I don't believe that answer 'works' per se, I don't know why the responder acted as if it was a miracle to use the first name as a key path. – refulgentis Nov 16 '09 at 10:36
  • Good point. It only works for the index and fast index scrolling, not for the section headers. I have updated my answer appropriately. – gerry3 Nov 16 '09 at 20:06
  • +1. Great solution, and I especially like avoiding any changes to the data model by using a category. My only refinement on this was to add the category to my Entity class rather than NSManagedObject. – Shaggy Frog Apr 01 '10 at 01:05
  • You can put it right in the custom class definition rather than a category. If you are worried about it getting overwritten when auto-generating the class files, you should check out mogenerator: http://rentzsch.github.com/mogenerator/ – gerry3 Apr 01 '10 at 05:32
  • @gerry3 What you propose is more complicated. Why not use a category and avoid not only the overwriting issue but avoid creating a dependency on some 3rd party package? – Shaggy Frog Apr 05 '10 at 22:03
  • 6
    I had the biggest trouble making this work. The solution for me was doing `[NSSortDescriptor sortDescriptorWithKey:@"name" ascending:YES selector:@selector(caseInsensitiveCompare:)]` instead of `[NSSortDescriptor sortDescriptorWithKey:@"name" ascending:YES]` since one of my objects started with a lowercase character. – Sam Soffes Feb 04 '11 at 22:05
  • For posterity: I had to sprinkle some `tableView reloadData` calls to keep my index in sync with my data. – Walter Aug 08 '11 at 20:50
  • 1
    I see no need for the willAccessValueForKey and didAccessValueForKey calls. You're telling CD that you're accessing the value that you're generating. No need for that. – Graham Perks Mar 09 '12 at 22:35
  • 1
    This isn't perfect solution! Doc: `The fetch request must have at least one sort descriptor. If the controller generates sections, the first sort descriptor in the array is used to group the objects into sections; its key must either be the same as sectionNameKeyPath or the relative ordering using its key must match that using sectionNameKeyPath.`. None of these two conditions are satisfied. You can't build sort descriptor on transient property (it always crashes for me) neither relative ordering is not matching (if there is whole UTF8 set, `caseInsensitiveCompare:` is not enough to match). – user500 Dec 25 '13 at 20:13
  • @GrahamPerks calling the willAccessValueForKey is informative to the Core Data paradigm per Apple documentation: https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/CoreData/Articles/cdNSAttributes.html – TheCodingArt Jan 05 '15 at 02:04
12

There may be a more elegant way to do this, but I recently had the same problem and came up with this solution.

First, I defined a transient property on the objects I was indexing called firstLetterOfName, and wrote the getter into the .m file for the object. e.x.

- (NSString *)uppercaseFirstLetterOfName {
    [self willAccessValueForKey:@"uppercaseFirstLetterOfName"];
    NSString *stringToReturn = [[self.name uppercaseString] substringToIndex:1];
    [self didAccessValueForKey:@"uppercaseFirstLetterOfName"];
    return stringToReturn;
}

Next, I set up my fetch request/entities to use this property.

NSFetchRequest *request = [[NSFetchRequest alloc] init];
NSEntityDescription *entity = [NSEntityDescription entityForName:@"Object" inManagedObjectContext:dataContext];
[request setEntity:entity];
[NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"name" ascending:YES selector:@selector(caseInsensitiveCompare:)];
NSArray *sortDescriptors = [NSArray arrayWithObject:sortDescriptor];
[request setSortDescriptors:sortDescriptors];

Side note, apropos of nothing: Be careful with NSFetchedResultsController — it's not exactly fully baked yet IMHO, and any situation beyond the simple cases listed in the documentation, you will probably be better off doing it the 'old fashioned' way.

refulgentis
  • 2,624
  • 23
  • 37
  • +1 Transient property is the best solution when displaying the section headers. – gerry3 Nov 16 '09 at 20:07
  • 1
    Beware of `substringToIndex:`! If the string is a UTF-16 string, that will only grab the first half of the first character. The proper way to do this is `[aString subStringWithRange:[aString rangeOfComposedCharacterSequenceAtIndex:1]]` – Dave DeLong Nov 16 '09 at 20:32
  • Thanks for the answer. I don't understand where you call the `uppercaseFirstLetterOfName` method. Should you be calling this instead of `caseInsensitiveCompare` or is there another way it gets called? – nevan king Nov 17 '09 at 03:33
  • I added details about the transient property as UPDATE 2 to my answer. – gerry3 Nov 17 '09 at 07:50
4

I solved this using the UILocalizedIndexCollation as mentioned in the NSFetchedResultsController v.s. UILocalizedIndexedCollation question

Community
  • 1
  • 1
Brent Priddy
  • 3,817
  • 1
  • 24
  • 16
  • 1
    This really is the only way to solve it. UILocalizedIndexCollation has a *lot* of built in behaviour, and the uppercaseFirstLetterOfName code suggested in other answers just doesn't even come close. As one single example, UILocalizedIndexCollation correctly collapses Japanese half and full width Katakana into the correct section, with the section name denoted by the correct Hiragana character - and it does the right thing for pretty much every other language in the world too. – JosephH Aug 27 '12 at 16:59
  • I know this is a decade late but @JosephH / Brent Priddy would you mind posting code of how you got this to work? – batman Oct 26 '21 at 07:12
2

The elegant way is to do make the "firstLetter" a transient property, HOWEVER in practice that is slow.

It is slow because for a transient property to be calculated, the entire object needs to be faulted into memory. If you have a lot of records, it will be very, very slow.

The fast, but inelegant way, is to create a non-transient "firstLetter" property which you update each time you set your "name" property. Several ways to do this: override the "setName:" assessor, override "willSave", KVO.

Corey Floyd
  • 25,929
  • 31
  • 126
  • 154
  • Were you able to confirm this? I couldn't spot the difference between transient and persistent properties in my project. – Andrey Mishanin May 16 '12 at 04:53
  • I am questioning the "elegant" way in comment on @gerry3 answer. And "inelegant" is even more "inelegant" that it appears. Indexes should be made from `UILocalizedIndexedCollation`. So you should reset every entry on change locale suspicion to match current collation with your first letter. – user500 Dec 25 '13 at 20:25
1

See my answer to a similar question here, in which I describe how to create localized sectionKey indexes that are persisted (because you cannot sort on transient attributes in an NSFetchedResultsController).

Community
  • 1
  • 1
Scott Gardner
  • 8,603
  • 1
  • 44
  • 36
0

Here is the simple solution for obj-c, several years and one language late . It works and works quickly in a project of mine.

First I created a category on NSString, naming the file NSString+Indexing. I wrote a method that returns the first letter of a string

@implementation NSString (Indexing)

- (NSString *)stringGroupByFirstInitial {
    if (!self.length || self.length == 1)
        return self;
    return [self substringToIndex:1];
}

Then I used that method in the definition of the fetched result controller as follows

_fetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest managedObjectContext:self.managedObjectContext sectionNameKeyPath:@"name.stringGroupByFirstInitial"
                                                                               cacheName:nil];

The above code in conjunction with the two stringIndex methods will work with no need for messing about with transient properties or storing a separate attribute that just holds the first letter in core data

However when I try to do the same with Swift it throws an exception as it doesn't like having a function as part key path (or a calculated string property - I tried that too) If anyone out there knows how to achieve the same thing in Swift I would dearly like to know how.

SimonTheDiver
  • 1,158
  • 1
  • 11
  • 24