Search code examples
swiftcore-datafilteringuisearchcontrolleruisearchresultscontroller

Swift UISearchController wired up in Core Data Project, app runs, but search not updating


I have been wrestling the past few days with creating a filter in a Swift project for a TableViewController that uses Core Data. I finally figured out I need to use a UISearchController, create an NSPredicate for the searchController.searchBar, etc.

I found this post EXTREMELY helpful, but after modeling my TVC after this project, I find "all the lights are on, but nobody's home". I can search, predicate is created in searchBar, add, remove, etc. but the cells don't update for the search. I'm missing something, but I don't know what.

Here are the relevant portions of my code.

class MasterViewController: UITableViewController, NSFetchedResultsControllerDelegate, UISearchControllerDelegate, UISearchResultsUpdating

// Properties that get instantiated later

var detailViewController: DetailViewController? = nil
var addNoteViewController:AddNoteViewController? = nil  // I added this
var managedObjectContext: NSManagedObjectContext? = nil

// Added variable for UISearchController
var searchController: UISearchController!
var searchPredicate: NSPredicate? // I added this. It's optional on and gets set later

    override func viewDidLoad() {
        super.viewDidLoad()

        self.navigationItem.leftBarButtonItem = self.editButtonItem()

        if let split = self.splitViewController {
            let controllers = split.viewControllers
            let context = self.fetchedResultsController.managedObjectContext
            let entity = self.fetchedResultsController.fetchRequest.entity!
            self.detailViewController = controllers[controllers.count-1].topViewController as? DetailViewController
        }

        // UISearchController setup
        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
    }

    // MARK: - UISearchResultsUpdating Delegate Method
    // Called when the search bar's text or scope has changed or when the search bar becomes first responder.
    func updateSearchResultsForSearchController(searchController: UISearchController) {
        let searchText = self.searchController?.searchBar.text // steve put breakpoint
        println(searchController.searchBar.text)
        if let searchText = searchText {
            searchPredicate = NSPredicate(format: "noteBody contains[c] %@", searchText)
            self.tableView.reloadData()
            println(searchPredicate)
        }
    }

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

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

    // MARK: - Fetched results controller

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

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

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

        // Edit the sort key as appropriate.
        let sortDescriptor = NSSortDescriptor(key: "noteTitle", ascending: false)
        let sortDescriptors = [sortDescriptor]

        fetchRequest.sortDescriptors = [sortDescriptor]

        // 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: nil, cacheName: "Master")
        aFetchedResultsController.delegate = self
        _fetchedResultsController = aFetchedResultsController

        var error: NSError? = nil
        if !_fetchedResultsController!.performFetch(&error) {
            // Replace this implementation with code to handle the error appropriately.
            // abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
            println("Unresolved error \(error), \(error?.userInfo)")
            abort()
        }

        return _fetchedResultsController!
    }

    var _fetchedResultsController: NSFetchedResultsController? = nil

    func controllerWillChangeContent(controller: NSFetchedResultsController) {
        //        self.tableView.beginUpdates() // ** original code, change if doesn't work** steve put breakpoint here
        // ANSWER said this section is redundant, but keeping it b/c it doesn't crash
        if searchPredicate == nil {
            tableView.beginUpdates()
        } else {
            (searchController.searchResultsUpdater as! MasterViewController).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! MasterViewController).tableView
        }

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

I think my problem "lives" here in this section. Autocomplete has been my friend up to here, but I'm don't see "searchIndex" referenced in AutoComplete. I think I'm missing something, but I'm not sure what or how.

If you've made it this far, thanks for reading. Here's the GitHub repo for the branch I'm working on.

    func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {

        var tableView = UITableView()

        if self.searchPredicate == nil {
            tableView = self.tableView
        } else {
            tableView = (self.searchController.searchResultsUpdater as! MasterViewController).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)")
            // TODO: need fix this

            // ORIGINAL CODE
            // self.configureCell(tableView.cellForRowAtIndexPath(indexPath!)!, atIndexPath: indexPath!) // original code

            // PROSPECTIVE SOLUTION CODE
            println("*** NSFetchedResultsChangeUpdate (object)")
            if searchPredicate == nil {
                self.configureCell(tableView.cellForRowAtIndexPath(indexPath!)!, atIndexPath: indexPath!) // original code
            } else {
                // Should search the do something w/ the UISearchControllerDelegate or UISearchResultsUpdating
                // Instead of "indexPath", it should be "searchIndexPath"--How?
                let cell = tableView.cellForRowAtIndexPath(searchIndexPath) as LocationCell // My cell is a vanilla cell, not a xib
                let location = controller.objectAtIndexPath(searchIndexPath) as Location // My object is a "Note"
                cell.configureForLocation(location) // This is from the other guy's code, don't think it's applicable to me
            }

        case .Move:
            println("*** NSFetchedResultsChangeMove (object)")
            tableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: .Fade)
            tableView.insertRowsAtIndexPaths([newIndexPath!], withRowAnimation: .Fade)
        default:
            return
        }
    }



    func controllerDidChangeContent(controller: NSFetchedResultsController) {
        if self.searchPredicate == nil {
            self.tableView.endUpdates()
        } else {
            println("controllerDidChangeContent")
            (self.searchController.searchResultsUpdater as! MasterViewController).tableView.endUpdates()
        }

    }

Edit: Per @pbasdf, I'm adding the TableView methods.

// MARK: - Table View

override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
    return self.fetchedResultsController.sections?.count ?? 0
}

override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if self.searchPredicate == nil {
        let sectionInfo = self.fetchedResultsController.sections![section] as! NSFetchedResultsSectionInfo
        return sectionInfo.numberOfObjects
    }

    let sectionInfo = self.fetchedResultsController.sections![section] as! NSFetchedResultsSectionInfo
    return sectionInfo.numberOfObjects
}

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as! UITableViewCell
    self.configureCell(cell, atIndexPath: indexPath)
    return cell
}

override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
    // Return false if you do not want the specified item to be editable.
    return true
}

Solution

  • The problems lie in your tableView datasource methods: numberOfSectionsInTableview:, tableView:numberOfRowsInSection:, and tableView:cellForRowAtIndexPath:. You need each of those methods to return different results if the searchPredicate is not nil - much like your tableView:commitEditingStyle: method does. I would make filteredObjects an instance property (defined at the start of the class) so that all those methods can access it:

    var filteredObjects : [Note]? = nil
    

    Now, when the search text changes, you want to rebuild the filteredObjects array. So in updateSearchResultsForSearchController, add a line to recompute it based on the new predicate:

        if let searchText = searchText {
            searchPredicate = NSPredicate(format: "noteBody contains[c] %@", searchText)
            filteredObjects = self.fetchedResultsController.fetchedObjects?.filter() {
                return self.searchPredicate!.evaluateWithObject($0)
            } as [Note]?
            self.tableView.reloadData()
            println(searchPredicate)
        }
    

    I would also recommend (for simplicity) that when you activate the search, the results are displayed all in one section (otherwise you have to work out how many sections your filtered results fall into - possible but tiresome):

    override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        if searchPredicate == nil {
            return self.fetchedResultsController.sections?.count ?? 0
        } else {
            return 1
        }
    }
    

    Next, if the searchPredicate is not nil, the number of rows in the section will be the count of filteredObjects:

    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 {
            return filteredObjects?.count ?? 0
        }
    }
    

    Finally, if the searchPredicate is not nil, you need to build the cell using the filteredObjects data, rather than the fetchedResultsController:

    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as! UITableViewCell
        if searchPredicate == nil {
            self.configureCell(cell, atIndexPath: indexPath)
            return cell
        } else {
            // configure the cell based on filteredObjects data
            ...
            return cell
        }
    }
    

    Not sure what labels etc you have in your cells, so I leave it to you to sort that bit.