1

I'm rewriting an old obj-c project in swift that has a tableView with a sectionIndex

I've set a predicate so it only returns objects with the same country attribute

I want to make the section index based on the first letter of the country attribute

in obj-c i created the fetchedResultsController like this

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

and i had an extension on NSString

  @implementation NSString (Indexing)

- (NSString *)stringGroupByFirstInitial {

    if (!self.length || self.length == 1)
        return self;

    return [self substringToIndex:1];
}

this worked fine in conjunction with the two methods

- (NSArray *) sectionIndexTitlesForTableView: (UITableView *) tableView{

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

    return [self.fetchedResultsController sectionForSectionIndexTitle:title atIndex:index];
}

In swift I tried to create a similar extension, with snappier name :)

extension String {
    func firstCharacter()-> String {
        let startIndex = self.startIndex
        let first =  self[...startIndex]
        return String(first)
    }
}

which works fine in a playground returning the string of the first character of any string you call it on.

but using a similar approach creating the fetchedResultsController, in swift, ...

let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                          managedObjectContext: (dataModel?.container.viewContext)!,
                                                          sectionNameKeyPath: "country.firstCharacter()",
                                                          cacheName: nil)

...causes an exception. heres the console log

2018-02-23 11:41:20.762650-0800 Splash[5287:459654] *** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[ valueForUndefinedKey:]: this class is not key value coding-compliant for the key firstCharacter().'

Any suggestions as the correct way to approach this would be appreciated

This is not a duplicate of the question suggested as this is specifically related to how to achieve this in Swift. I have added the simple correct way to achieve this in Obj -c to the other question

SimonTheDiver
  • 1,158
  • 1
  • 11
  • 24
  • A function cannot be part of a keypath. Try creating an attribute instead: `var firstCharacter: String {`. I have not checked whether `sectionNameKeyPath` will work with a synthesized keypath rather than one from the Core Data store, but my guess is that it will. – Gregory Higley Feb 23 '18 at 20:06
  • so i changed the extension to extension String { var firstCharacter: String { let startIndex = self.startIndex let first = self[...startIndex] return String(first) } } and made this sectionNameKeyPath: "country.firstCharacter", but still got essentially the same error Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[ valueForUndefinedKey:]: this class is not key value coding-compliant for the key firstCharacter.' – SimonTheDiver Feb 23 '18 at 20:10
  • Possible duplicate of [How to use the first character as a section name](https://stackoverflow.com/questions/1741093/how-to-use-the-first-character-as-a-section-name) – Gregory Higley Feb 23 '18 at 20:11
  • Gregory - thanks for that link - Ironically they're old obj-c problems and seem to resolve around a workaround to get around not finding the solution - which I already had working in obj-c - I'm looking for Swift equivalent of the way obj-c solution I used. – SimonTheDiver Feb 23 '18 at 21:12
  • Your code is hard to decipher because you don't even state the name of the entity. – El Tomato Feb 24 '18 at 00:45
  • The entity is called DiveSite. The attribute on DiveSite is called country – SimonTheDiver Feb 24 '18 at 03:21

2 Answers2

2

Fastest solution (works with for Swift 4)

The solution from pbasdf works. It's much faster than using a transient property. I have a list of about 700 names to show in a tableview, with a relative complex fetch from CoreData. Just doing the initial search:

  • With a transient property returning a capitalised first letter of 'name': Takes 2-3s to load. With some peaks of up to 4s !
  • With the solution from pbasdf ( @obj func nameFirstCharacter() -> String, this loading time goes down to 0.4 - 0.5s.
  • Still the fastest solution is adding a regular property 'nameFirstChar' to the model. And make sure it is indexed. In this case the loading time goes down to 0.01 - 0.02s for the same fetch query!

I have to agree that I agree with some remarks that it looks a bit dirty to add a property to a model, just for creating sections in a tableview. But looking at the performance gain I'll stick to that option!

For the fastest solution, this is the adapted implementation in Person+CoreDataProperties.swift so updating the namefirstchar is automatic when changes to the name property are made. Note that code autogeneration is turned off here. :

  //@NSManaged public var name: String? //removed default
  public var name: String? //rewritten to set namefirstchar correctly
  {
    set (new_name){
      self.willChangeValue(forKey: "name")
      self.setPrimitiveValue(new_name, forKey: "name")
      self.didChangeValue(forKey: "name")

      let namefirstchar_s:String = new_name!.substring(to: 1).capitalized
      if self.namefirstchar ?? "" != namefirstchar_s {
        self.namefirstchar = namefirstchar_s
      }
    }
    get {
      self.willAccessValue(forKey: "name")
      let text = self.primitiveValue(forKey: "name") as? String
      self.didAccessValue(forKey: "name")
      return text
    }
  }
  @NSManaged public var namefirstchar: String?

In case you don't like or can't add a new property or if you prefer to keep the code autogeneration on, this is my tweaked solution from pbasdf:

1) Extend NSString

extension NSString{
  @objc func firstUpperCaseChar() -> String{ // @objc is needed to avoid crash
    if self.length == 0 {
      return ""
    }
    return self.substring(to: 1).capitalized
  }
}

2) Do the sorting, no need to use sorting key name.firstUpperCaseChar

let fetchRequest =   NSFetchRequest<NSFetchRequestResult>(entityName: "Person")
let personNameSort = NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare))
let personFirstNameSort = NSSortDescriptor(key: "firstname", ascending: true, selector: #selector(NSString.caseInsensitiveCompare))
fetchRequest.sortDescriptors = [personNameSort, personFirstNameSort]

3) Then do the fetch with the right sectionNameKeyPath

fetchRequest.predicate = ... whatever you need to filter on ...
fetchRequest.fetchBatchSize = 50 //or 20 to speed things up
let fetchedResultsController = NSFetchedResultsController(   
  fetchRequest: fetchRequest,   
  managedObjectContext: managedObjectContext,   
  sectionNameKeyPath: "name.firstUpperCaseChar", //note no ()   
  cacheName: nil)

fetchedResultsController.delegate = self

let start = DispatchTime.now() // Start time
do {
  try fetchedResultsController.performFetch()
} catch {
  fatalError("Failed to initialize FetchedResultsController: \(error)")
}
let end = DispatchTime.now()   // End time
let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds // time difference in nanosecs 
let timeInterval = Double(nanoTime) / 1_000_000_000 
print("Fetchtime: \(timeInterval) seconds")
Rodge
  • 101
  • 4
1

Add a function to your DiveSite class to return the first letter of the country:

@objc func countryFirstCharacter() -> String {
    let startIndex = country.startIndex
    let first = country[...startIndex]
    return String(first)
}

Then use that function name (without the ()) as the sectionNameKeyPath:

let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
                                  managedObjectContext: (dataModel?.container.viewContext)!,
                                  sectionNameKeyPath: "countryFirstCharacter",
                                  cacheName: nil)

Note that the @objc is necessary here in order to make the (Swift) function visible to the (Objective-C) FRC. (Sadly you can't just add @objc to your extension of String.)

pbasdf
  • 21,386
  • 4
  • 43
  • 75