Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 686
Aleš Oskar Kocur Avatar asked Sep 26 '22 13:09

Aleš Oskar Kocur


2 Answers

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.

like image 64
pbasdf Avatar answered Oct 16 '22 23:10

pbasdf


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.

like image 33
Andrew Bennet Avatar answered Oct 17 '22 01:10

Andrew Bennet