Aleš Kocur
Aleš Kocur

Reputation: 1908

NSFetchedResultsController different sort descriptors for each section

I have objects with NSDate property and I need to split them into two sections (first - future events, second - historic events) and then for the first section I need to sort them by the date property in ascending order and the second section in descending order. Any idea how to do the sorting?

Upvotes: 5

Views: 1394

Answers (3)

Andrew Bennet
Andrew Bennet

Reputation: 2717

To tackle the general problem -- desiring different ordering per section -- you can wrap multiple NSFetchedResultsController objects into a single object which flattens the section arrays into a single array, and remaps the results of functions like func object(at: IndexPath) as well as the index paths in the NSFetchedResultsControllerDelegate notifications.

This will allow you to solve the general problem of wanting to display any number of sections ordered in different ways.

I had a go at creating this wrapper object (a CompoundFetchedResultsController), and it seems to work well:

/**
 A CompoundFetchedResultsController is a wrapper of a number of inner NSFetchedResultsControllers.
 The wrapper flattens the sections produced by the inner controllers, behaving as if all sections
 were fetched by a single controller. Additionally, change notifications are mapped before being
 passed to the optional NSFetchedResultsControllerDelegate property, so that the section indices
 in the notifications reflect the flattened section indicies.
 
 Example use case: a table where sections should be ordered in mutually opposing ways. E.g., if
 section 1 should be ordered by propery A ascending, but section 2 should be ordered by property A
 descending. In this case, two controllers can be created - one ordering ascending, the other de-
 scending - and wrapped in a CompoundFetchedResultsController. This will maintain the ease of use
 in a UITableViewController, and the functionality provided by a NSFetchedResultsControllerDelegate.
 */
class CompoundFetchedResultsController<T: NSFetchRequestResult>: NSObject, NSFetchedResultsControllerDelegate {
    
    // The wrapperd controllers
    let controllers: [NSFetchedResultsController<T>]
    
    // A delegate to notify of changes. Each of the controllers' delegates are set to this class,
    // so that we can map the index paths in the notifications before forwarding to this delegate.
    var delegate: NSFetchedResultsControllerDelegate? {
        didSet { controllers.forEach{$0.delegate = self} }
    }
    
    init(controllers: [NSFetchedResultsController<T>]) { self.controllers = controllers }
    
    func performFetch() throws { controllers.forEach{try? $0.performFetch()} }
    
    var sections: [NSFetchedResultsSectionInfo]? {
        // To get the flattened sections array, we simply reduce-by-concatenation the inner controllers' sections arrays.
        get { return controllers.flatMap{$0.sections}.reduce([], +) }
    }

    private func sectionOffset(forController controller: NSFetchedResultsController<T>) -> Int {
        // Determine the index of the specified controller
        let controllerIndex = controllers.index(of: controller)!
        
        // Count the number of sections present in all controllers up to (but not including) the supplied controller
        return controllers.prefix(upTo: controllerIndex).map{$0.sections!.count}.reduce(0, +)
    }
    
    func object(at indexPath: IndexPath) -> T {
        // Sum the section counts of the controllers, in order, until we exceed the section of the supplied index path.
        // At that point, we have identifiers the controller which should be used to obtain the object, and just
        // adjust the supplied index path's section accordingly.
        var sectionCount = 0
        for controller in controllers {
            if sectionCount + controller.sections!.count <= indexPath.section {
                sectionCount += controller.sections!.count
            }
            else {
                return controller.object(at: IndexPath(row: indexPath.row, section: indexPath.section - sectionCount))
            }
        }
        fatalError("Could not find index path \(indexPath).")
    }
    
    func indexPath(forObject object: T) -> IndexPath? {
        // Given an object, to determine which controller it is in, we just query each controller in turn.
        for controller in controllers {
            if let indexPath = controller.indexPath(forObject: object) {
                return IndexPath(row: indexPath.row, section: sectionOffset(forController: controller) + indexPath.section)
            }
        }
        return nil
    }
    
    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        // Forward on the willChange notification
        delegate?.controllerWillChangeContent?(controller)
    }
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        // Forward on the didlChange notification
        delegate?.controllerDidChangeContent?(controller)
    }
    
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        
        let sectionOffset = self.sectionOffset(forController: controller as! NSFetchedResultsController<T>)
        
        // Index Paths should be adjusted by adding to the section offset to the section index
        func adjustIndexPath(_ indexPath: IndexPath?) -> IndexPath? {
            guard let indexPath = indexPath else { return nil }
            return IndexPath(row: indexPath.row, section: indexPath.section + sectionOffset)
        }
        
        // Forward on the notification with the adjusted index paths
        delegate?.controller?(controller, didChange: anObject, at: adjustIndexPath(indexPath), for: type, newIndexPath: adjustIndexPath(newIndexPath))
    }
    
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
        
        let sectionOffset = self.sectionOffset(forController: controller as! NSFetchedResultsController<T>)
        
        // Forward on the notification with the adjusted section index
        delegate?.controller?(controller, didChange: sectionInfo, atSectionIndex: sectionIndex + sectionOffset, for: type)
    }
}

Update (October 2021) Whilst the above solution was handy at the time, if you're targetting iOS 14 or up (or equivalent), you will probably be better placed to use a simpler solution with diffable data sources and section snapshots.

Upvotes: 1

pbasdf
pbasdf

Reputation: 21536

Assuming you are using a NSFetchedResultsController, the underlying fetch must be sorted one way or the other. I can think of two different solutions:

  1. Use two separate FRCs, with complementary predicates so that one handles the past events, while the other handles future events. One will be sorted ascending, the other descending. The problem is that both FRCs will generate indexPaths for section 0. You will therefore need to remap the second FRC's indexPaths to use section 1 of the tableView. For example, in cellForRowAtIndexPath you would need something like this:

    if (indexPath.section == 0) {
        objectToDisplay = self.futureFetchedResultsController.objectAtIndexPath(indexPath)
    } else { // use second FRC
        let frcIndexPath = NSIndexPath(forRow: indexPath.row, inSection: 0)
        objectToDisplay = self.pastFetchedResultsController.objectAtIndexPath(frcIndexPath)
    }
    
  2. Alternatively, stick with a single FRC, sorted ascending. Then remap the indexPath for the second section, so that the last object in the section is displayed in row 0, etc:

    if (indexPath.section == 0) {
        objectToDisplay = self.fetchedResultsController.objectAtIndexPath(indexPath)
    } else { // use remap to reverse sort order FRC
        let sectionInfo = self.fetchedResultsController.sections[1] as! NSFetchedResultsSectionInfo
        let sectionCount = sectionInfo.numberOfObjects
        let frcIndexPath = NSIndexPath(forRow: (sectionCount - 1 - indexPath.row), inSection:indexPath.section)
        objectToDisplay = self.fetchedResultsController.objectAtIndexPath(frcIndexPath)
    }
    

Personally I think the second option is the preferable. In each case, all the tableView datasource/delegate methods will need the same remapping, and the FRC delegate methods will need the reverse mapping.

Upvotes: 4

Will Zimmer
Will Zimmer

Reputation: 111

First set your sort descriptors in the fetchRequest

func itemFetchRequest() -> NSFetchRequest{

    let fetchRequest = NSFetchRequest(entityName: "Events")
    let primarySortDescription = NSSortDescriptor(key: "futureEvents", ascending: true)
    let secondarySortDescription = NSSortDescriptor(key: "historicEvents", ascending: false)
    fetchRequest.sortDescriptors = [primarySortDescription, secondarySortDescription]
    return fetchRequest
}

then set your number of sections

func numberOfSectionsInTableView(tableView: UITableView) -> Int {
    let numberOfSections = frc.sections?.count
    return numberOfSections!
}

and finally your section headers

func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String?{
    let sectionHeader = Future Events
    let sectionHeader1 = Historic Events
        if (section== "0") { 
            return sectionHeader
        } else {
            return sectionHeader1
        }
    } else {
        return nil
    }
}

Upvotes: -2

Related Questions