Deleting from UISearchController's filtered se

2019-01-26 07:31发布

问题:

I have a tableView sourcing its cell content from CoreData and have been replacing the SearchDisplayController (deprecated) with the new SearchController. I am using the same tableView controller to present both the full list of objects and also the filtered/searched objects.

I have managed to get the search/filtering working fine and can move from the filtered list to detail views for those items, then edit and save changes back successfully to the filtered tableView. My problem is swiping to delete cells from the filtered list causes an run time error. Previously with the SearchDisplayController I could do this easily as I had access to the SearchDisplayController's results tableView and so the following (pseudo) code would work fine:

func controllerDidChangeContent(controller: NSFetchedResultsController) {
    // If the search is active do this
          searchDisplayController!.searchResultsTableView.endUpdates()
    // else it isn't active so do this
          tableView.endUpdates()
    }
}

Unfortunately no such tableView is exposed for the UISearchController and Im at a loss. I have tried making the tableView.beginUpdates() and tableView.endUpdates() conditional on tableView not being the search tableView but with no success.

For the record this is my error message:

Assertion failure in -[UITableView _endCellAnimationsWithContext:], /SourceCache/UIKit_Sim/UIKit-3318.65/UITableView.m:1582

* EDIT *

My tableView uses a FetchedResultsController to populate itself from CoreData. This tableViewController also the one used by the SearchController to display filtered results.

var searchController: UISearchController!

Then in ViewDidLoad

searchController = UISearchController(searchResultsController: nil)
searchController.dimsBackgroundDuringPresentation = false
searchController.searchResultsUpdater = self
searchController.searchBar.sizeToFit()
self.tableView.tableHeaderView = searchController?.searchBar
self.tableView.delegate = self
self.definesPresentationContext = true

and

func updateSearchResultsForSearchController(searchController: UISearchController) {
    let searchText = self.searchController?.searchBar.text
    if let searchText = searchText {
        searchPredicate = searchText.isEmpty ? nil : NSPredicate(format: "locationName contains[c] %@", searchText)
        self.tableView.reloadData()
    }
}

So far as the error message is concerned, I'm not sure how much I can add. The app hangs immediately after pressing the red delete button (Which remains showing) revealed by swiping. This is the thread error log for 1 - 5. The app seems to hang on number 4.

#0  0x00000001042fab8a in objc_exception_throw ()
#1  0x000000010204b9da in +[NSException raise:format:arguments:] ()
#2  0x00000001027b14cf in -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] ()
#3  0x000000010311169a in -[UITableView _endCellAnimationsWithContext:] ()
#4  0x00000001019b16f3 in iLocations.LocationViewController.controllerDidChangeContent (iLocations.LocationViewController)(ObjectiveC.NSFetchedResultsController) -> () at /Users/neilmckay/Dropbox/Programming/My Projects/iLocations/iLocations/LocationViewController.swift:303
#5  0x00000001019b178a in @objc iLocations.LocationViewController.controllerDidChangeContent (iLocations.LocationViewController)(ObjectiveC.NSFetchedResultsController) -> () ()

I hope some of this helps.

* EDIT 2 *

override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
    if editingStyle == .Delete {
        let location: Location = self.fetchedResultsController.objectAtIndexPath(indexPath) as Location
        location.removePhotoFile()

        let context = self.fetchedResultsController.managedObjectContext
        context.deleteObject(location)

        var error: NSError? = nil
        if !context.save(&error) {
            abort()
        }
    }
}

override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if self.searchPredicate == nil {
        let sectionInfo = self.fetchedResultsController.sections![section] as NSFetchedResultsSectionInfo
        return sectionInfo.numberOfObjects
    } else {
        let filteredObjects = self.fetchedResultsController.fetchedObjects?.filter() {
            return self.searchPredicate!.evaluateWithObject($0)
        }
        return filteredObjects == nil ? 0 : filteredObjects!.count
    }
}

// MARK: - NSFetchedResultsController methods

var fetchedResultsController: NSFetchedResultsController {
    if _fetchedResultsController != nil {
        return _fetchedResultsController!
    }

    let fetchRequest = NSFetchRequest()
    // Edit the entity name as appropriate.
    let entity = NSEntityDescription.entityForName("Location", inManagedObjectContext: self.managedObjectContext!)
    fetchRequest.entity = entity

    // Set the batch size to a suitable number.
    fetchRequest.fetchBatchSize = 20

    // Edit the sort key as appropriate.
    if sectionNameKeyPathString1 != nil {
        let sortDescriptor1 = NSSortDescriptor(key: sectionNameKeyPathString1!, ascending: true)
        let sortDescriptor2 = NSSortDescriptor(key: sectionNameKeyPathString2!, ascending: true)
        fetchRequest.sortDescriptors = [sortDescriptor1, sortDescriptor2]
    } else {
        let sortDescriptor = NSSortDescriptor(key: "firstLetter", ascending: true)
        fetchRequest.sortDescriptors = [sortDescriptor]
    }

    var sectionNameKeyPath: String
    if sectionNameKeyPathString1 == nil {
        sectionNameKeyPath = "firstLetter"
    } else {
        sectionNameKeyPath = sectionNameKeyPathString1!
    }

    // Edit the section name key path and cache name if appropriate.
    // nil for section name key path means "no sections".
    let aFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.managedObjectContext!, sectionNameKeyPath: sectionNameKeyPath, cacheName: nil /*"Locations"*/)
    aFetchedResultsController.delegate = self
    _fetchedResultsController = aFetchedResultsController

    var error: NSError? = nil
    if !_fetchedResultsController!.performFetch(&error) {
        fatalCoreDataError(error)
    }

    return _fetchedResultsController!
}

var _fetchedResultsController: NSFetchedResultsController? = nil

func controllerWillChangeContent(controller: NSFetchedResultsController) {
    if searchPredicate == nil {
        tableView.beginUpdates()
    } else {
        (searchController.searchResultsUpdater as LocationViewController).tableView.beginUpdates()
    }

// tableView.beginUpdates() }

func controller(controller: NSFetchedResultsController, didChangeSection sectionInfo: NSFetchedResultsSectionInfo, atIndex sectionIndex: Int, forChangeType type: NSFetchedResultsChangeType) {
    var tableView = UITableView()
    if searchPredicate == nil {
        tableView = self.tableView
    } else {
        tableView = (searchController.searchResultsUpdater as LocationViewController).tableView
    }

    switch type {
    case .Insert:
        tableView.insertSections(NSIndexSet(index: sectionIndex), withRowAnimation: .Fade)
    case .Delete:
        tableView.deleteSections(NSIndexSet(index: sectionIndex), withRowAnimation: .Fade)
    default:
        return
    }
}

func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath) {
    var tableView = UITableView()
    if searchPredicate == nil {
        tableView = self.tableView
    } else {
        tableView = (searchController.searchResultsUpdater as LocationViewController).tableView
    }

    switch type {
    case .Insert:
        println("*** NSFetchedResultsChangeInsert (object)")
        tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Fade)

    case .Delete:
        println("*** NSFetchedResultsChangeDelete (object)")
            tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
    case .Update:
        println("*** NSFetchedResultsChangeUpdate (object)")
        if searchPredicate == nil {
            let cell = tableView.cellForRowAtIndexPath(indexPath) as LocationCell
            let location = controller.objectAtIndexPath(indexPath) as Location
            cell.configureForLocation(location)
        } else {
            let cell = tableView.cellForRowAtIndexPath(searchIndexPath) as LocationCell
            let location = controller.objectAtIndexPath(searchIndexPath) as Location
            cell.configureForLocation(location)
        }
    case .Move:
        println("*** NSFetchedResultsChangeMove (object)")
        tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
        tableView.insertRowsAtIndexPaths([newIndexPath], withRowAnimation: .Fade)
    }
}

func controllerDidChangeContent(controller: NSFetchedResultsController) {
    if searchPredicate == nil {
        tableView.endUpdates()
    } else {
        (searchController.searchResultsUpdater as LocationViewController).tableView.endUpdates()
    }
}

回答1:

The problem arises because of a mismatch between the indexPath used by the fetched results controller and the indexPath for the corresponding row in the tableView.

Whilst the search controller is active, the existing tableView is reused to display the search results. Hence your logic to differentiate the two tableViews:

if searchPredicate == nil {
    tableView = self.tableView
} else {
    tableView = (searchController.searchResultsUpdater as LocationViewController).tableView
}

is unnecessary. It works, because you set searchController.searchResultsUpdater = self when you initialise the searchController, so there is no need to change it, but the same tableView is used in either case.

The difference lies in the way the tableView is populated whilst the searchController is active. In that case, it looks (from the numberOfRowsInSection code) as though the filtered results are all displayed in one section. (I assume cellForRowAtIndexPath works similarly.) Suppose you delete the item at section 0, row 7, in the filtered results. Then commitEditingStyle will be called with indexPath 0-7, and the following line:

let location: Location = self.fetchedResultsController.objectAtIndexPath(indexPath) as Location

will try to get the object at index 0-7 from the FRC. But the item at index 0-7 of the FRC might be a completely different object. Hence you delete the wrong object. Then the FRC delegate methods fire, and tell the tableView to delete the row at index 0-7. Now, if the object really deleted was NOT in the filtered results, then the count of rows will be unchanged, even though a row has been deleted: hence the error.

So, to fix it, amend your commitEditingStyle so that it finds the correct object to delete, if the searchController is active:

override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
    if editingStyle == .Delete {
        var location : Location
        if searchPredicate == nil {
            location = self.fetchedResultsController.objectAtIndexPath(indexPath) as Location
        } else {
            let filteredObjects = self.fetchedResultsController.fetchedObjects?.filter() {
                return self.searchPredicate!.evaluateWithObject($0)
            }
            location = filteredObjects![indexPath.row] as Location
        }
        location.removePhotoFile()

        let context = self.fetchedResultsController.managedObjectContext
        context.deleteObject(location)

        var error: NSError? = nil
        if !context.save(&error) {
            abort()
        }
    }
}

I haven't been able to test the above; apologies if some errors slipped in. But it should at least point in the right direction; hope it helps. Note that similar changes may be required in some of the other tableView delegate/datasource methods.