Searching a dynamically populated array

Hi all, I recently did the SearchController tutorial, https://www.raywenderlich.com/157864/uisearchcontroller-tutorial-getting-started .(If you don’t want to read the project, it’s a regular search controller with two arrays. There is a scope bar, which controls which array you’re searching). I had a question on how to search the .name in the Candy struct if I’m populating the array from a Firebase Database. Instead of having default names such as “Hard”, “Other”, etc, I’m populating the struct array with a username and their post. I can search for posts, but after experimenting and trying to change some code in the filterContentForSearchText() method to try to change it so I could search for the username(.name), it wouldn’t work. The problem is, where you would have “Hard” for the scope title, that would be someone’s username. I also tried assigning the “Hard” value to someone’s username with the cell indexPath, but still no luck. Anyways…I was hoping someone could help shed some light on how to search with a dynamic array. Thanks for any help you guys might be able to provide with this! (Sorry if this is a noob question, I’m 13, and just started with Swift!)

Specifically in this method: I want to be able to control which one is returned: .name, or .category, or if you’re searching the username or post but I cannot figure it out!

func filterContentForSearchText(_ searchText: String, scope: String = "All") {
        filteredCandies = candies.filter({( candy : Candy) -> Bool in
            let doesCategoryMatch = (scope == "All") || (candy.category == scope)
         
            if searchBarIsEmpty() {
                return doesCategoryMatch
            } else {
                **return doesCategoryMatch && candy.name.lowercased().contains(searchText.lowercased())**
            }
        })
        tableView.reloadData()
    }

@tomelliott Can you please help with this when you get a chance? Thank you - much appreciated! :]

1 Like

See if the following example helps. You can create a playground in Xcode, then you can copy and paste the code into the playground, which will allow you to play around with the code in a less complex setting than iOS.

Note that the candy category provides a way to divide all the candies into a small number of groups for display in the segmented control. In fact, that category property was specifically added to the struct for that purpose. How do you propose to divide your items into three groups? A username is unique, so you can’t make groups of people with the same username, and a post is probably unique, so that doesn’t provide a way to group your items. You can’t display all the usernames in the segmented control, nor can you display all the posts. What do you envision being displayed in the segmented control? Are you going to add a category property to your items for the purpose of grouping your items, e.g. with values like Sports, News, and General based on the posts? Or, maybe a category property based on the usernames like US, Europe, Far East?

//: Playground - noun: a place where people can play

import UIKit


struct Post {
    var username = ""
    var text = ""  
}

class MyViewController {
    
    var filteredPosts: [Post] = []
    
    let posts = [
        Post(username:"andy", text:"Nutter Bar"),
        Post(username:"joe", text:"Chocolate Bar"),
        Post(username:"jderbs", text:"Chocolate Chip"),
        Post(username:"kate", text:"Dark Chocolate"),
        Post(username:"joe", text:"Lollipop"),
        Post(username:"jderbs", text:"Candy Cane"),
        Post(username:"jderbs", text:"Jaw Breaker"),
        Post(username:"Kate", text:"Not on my watch."),
        Post(username:"jderbs", text:"It's night time."),
        Post(username:"zed", text:"Gold Token"),  
    ]
    
    func searchBarIsEmpty() -> Bool {
        return false
    }

    //Segmented control: All, a-i, j-r, s-z
    func filterContentForSearchText(_ searchText: String, scope: String = "All") {
        
        let usernameCategories = [
            "All": "a"..."z",
            "a-i": "a"..."i",
            "j-r": "j"..."r",
            "s-z": "s"..."z"
        ]
        
        let usernameCategory = usernameCategories[scope]!  //Note the bang!  If you pass anything other than the four legal keys in the dictionary=> error: found nil when unwrapping optional
        
        filteredPosts = posts.filter() { (post : Post) -> Bool in
            var doesCategoryMatch = false
            let username = post.username
            
            if let firstLetterChar = username.first {
                let firstLetterStr = String(describing: firstLetterChar)
                doesCategoryMatch = usernameCategory.contains(firstLetterStr)
            }
            
            if searchBarIsEmpty() {
                return doesCategoryMatch
            } else {
                return doesCategoryMatch && post.text.lowercased().contains(searchText.lowercased())
            }
        }
    }
    
}

let viewController = MyViewController()

viewController.filterContentForSearchText("n")

for post in viewController.filteredPosts {
    print(post.username)
    print(post.text)
    print("--")
}

print("\n--new search--\n")

viewController.filterContentForSearchText("n", scope:"a-i")

for post in viewController.filteredPosts {
    print(post.username)
    print(post.text)
    print("--")
}

print("\n--new search--\n")

viewController.filterContentForSearchText("n", scope:"j-r")

for post in viewController.filteredPosts {
    print(post.username)
    print(post.text)
    print("--")
}

Output:

andy
Nutter Bar
--
jderbs
Candy Cane
--
jderbs
It's night time.
--
zed
Gold Token
--

--new search--

andy
Nutter Bar
--

--new search--

jderbs
Candy Cane
--
jderbs
It's night time.
--

Note that when the last argument to a function, e.g. filter(), is an anonymous function, you can move the anonymous function outside of the argument list:

filter(...) {

}

Here’s a simple example:

func go(x: Int, dostuff: (Int) -> String) -> Void {
    let result = dostuff(x)
    print(result)
}

go(x: 3) { y in
    return "Hello \(y)"
}

--output:--
Hello 3

I left at least one mistake/improvement for you to find in the big example.

1 Like

Thanks, I used two if statements, and an else statement to return the text. SMH I don’t know how I didn’t think of that before.

Post your solution. I’m interested, and it may help someone else out.

Here is all of my code that now works. Note that some of the variable names are still from the tutorial so if you randomly see candy with the struct ‘Person’, don’t worry about it!

import UIKit
import Firebase

class SearchPostsController: UIViewController, UITableViewDataSource, UITableViewDelegate {

@IBOutlet weak var searchBar: UISearchBar!
@IBOutlet weak var tableView: UITableView!
var idArray:[String] = []

var detailViewController: DetailViewController? = nil
var candies = [Person]()
var filteredCandies = [Person]()
let searchController = UISearchController(searchResultsController: nil)

override func viewDidLoad() {
    super.viewDidLoad()
    
    let ourId = UserDefaults.standard.object(forKey: "SearchIds")
    print("Our ids\(ourId!)")
    self.idArray = ourId! as! [String]
    for singleId in idArray {
        Database.database().reference().child("\(UserData().mySchool!)/posts").child("\(singleId)/message").observe(.value, with: { (message) in
            
        
        Database.database().reference().child("\(UserData().mySchool!)/posts").child("\(singleId)/username").observe(.value, with: { (username) in
            let user = Person(category: (message.value as! String), name: (username.value as! String))
            self.candies.append(user)
            self.tableView.reloadData()
        })
        })
        print(candies)
    }
    searchController.searchBar.scopeButtonTitles = [ "Posts", "Users"]
    searchController.searchBar.delegate = self
    
    searchController.searchResultsUpdater = self
    searchController.obscuresBackgroundDuringPresentation = false
    searchController.searchBar.placeholder = "Search"
    navigationItem.searchController = searchController
    definesPresentationContext = true
    
    if let splitViewController = splitViewController {
        let controllers = splitViewController.viewControllers
        detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController
    }
}

override func viewWillAppear(_ animated: Bool) {
        if let selectionIndexPath = self.tableView.indexPathForSelectedRow {
            self.tableView.deselectRow(at: selectionIndexPath, animated: animated)
        }
    
    super.viewWillAppear(animated)
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "cell")
    let personUser: Person
    if isFiltering() {
        personUser = filteredCandies[indexPath.row]
    } else {
        personUser = candies[indexPath.row]
    }
    cell.textLabel!.text = personUser.category
    cell.detailTextLabel!.text = personUser.name
    return cell
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if isFiltering() {
        return filteredCandies.count
    }
    
    return candies.count
}


func numberOfSections(in tableView: UITableView) -> Int {
    return 1
}

func searchBarIsEmpty() -> Bool {
    // Returns true if the text is empty or nil
    return searchController.searchBar.text?.isEmpty ?? true
}

func filterContentForSearchText(_ searchText: String, scope: String = "All") {
    filteredCandies = candies.filter({(candy : Person) -> Bool in
        let doesCategoryMatch = (scope == "Posts") || (scope == "Users")

    
        if searchBarIsEmpty() {
            return doesCategoryMatch
        }
        if scope == "Users"{
            return doesCategoryMatch && candy.name.lowercased().contains(searchText.lowercased())
        }
        else{
            return doesCategoryMatch && candy.category.lowercased().contains(searchText.lowercased())
        }

            /*
        else if scope == "name"{
            return doesCategoryMatch && candy.name.lowercased().contains(searchText.lowercased())
        }
        else if scope == "category" {
            return doesCategoryMatch && candy.category.lowercased().contains(searchText.lowercased())
        }
         */
    })
    
    tableView.reloadData()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == "showDetail" {
        if let indexPath = tableView.indexPathForSelectedRow {
            let personalUser: Person
            if isFiltering() {
                personalUser = filteredCandies[indexPath.row]
            } else {
                personalUser = candies[indexPath.row]
            }
            let controller = (segue.destination as! UINavigationController).topViewController as! DetailViewController
            controller.detailCandy = personalUser
            controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem
            controller.navigationItem.leftItemsSupplementBackButton = true
        }
    }
}

override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
    // Dispose of any resources that can be recreated.
}


func isFiltering() -> Bool {
let searchBarScopeIsFiltering = searchController.searchBar.selectedScopeButtonIndex != 0
return searchController.isActive && (!searchBarIsEmpty() || searchBarScopeIsFiltering)
}

}
extension SearchPostsController: UISearchResultsUpdating {
// MARK: - UISearchResultsUpdating Delegate
func updateSearchResults(for searchController: UISearchController) {
let searchBar = searchController.searchBar
let scope = searchBar.scopeButtonTitles![searchBar.selectedScopeButtonIndex]
filterContentForSearchText(searchController.searchBar.text!, scope: scope)
}
}
extension SearchPostsController: UISearchBarDelegate {
// MARK: - UISearchBar Delegate
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
filterContentForSearchText(searchBar.text!, scope: searchBar.scopeButtonTitles![selectedScope])
}
}

Hi @jderbs,
Congratulations on achieving what you were after.

From your code there was something that stood out and I thought that I should mention it,

    let ourId = UserDefaults.standard.object(forKey: "SearchIds")
    print("Our ids\(ourId!)")
    self.idArray = ourId! as! [String]

This works for you because you have been running the code on your device/simulator, if this was a fresh start, this would crash. This is one of the most common reason for many apps that crash on first start.

The line
let ourId = UserDefaults.standard.object(forKey:"SearchIDs") will return nil the first time as there is nothing in the userdefaults. So the line that prints ourId! will crash. The way to manage this is wrap these in a if let condition or using guard

    if let ourId = UserDefaults.standard.object(forKey: "SearchIds") {
        print("Our ids\(ourId)")
        self.idArray = ourId as? [String]
        for singleId in ...

    }

hope that helps you write better error free code,

cheers,

Jayant

@jayantvarma
Thank you for your concern! Thankfully, for me, this view controller is the result of a segue, and before the segue I saved the idArray to user defaults instead of doing a prepare for segue method. Thank you for pointing out the if let statement I will add that!
-Jack