2
\$\begingroup\$

I have a standard tableview app that displays information from a data model array that uses a custom class. There is a UISearchController to filter the data.

A simplified version of the custom class is shown. I use a computed property for a string that is checked when searching the data.

class myObject: NSObject {
 // Inherits from NSObject as I use NSCoding for persistence
 var id: String = UUID().uuidString // These are unique for each item
 var name: String = ""
 var type: String = ""
 var batch: String = ""
 var searchableString: String {
 // This is a concatenation of some of the properties
 return self.name + " " + self.type + " " + self.batch
 }
 // etc...
}

Every time the user types a new character into the search bar of the UISearchController, the following method is called, which filters the main data array into a filteredItems array. Initially, I used a very basic form where the entire search text had to match.

func searchItems() {
 if let searchText = searchController.searchBar.text {
 filteredItems = allItems.filter { item in
 return item.searchableString.lowercased().contains(searchText.lowercased())
 }
 }
}

filteredItems is then displayed in the tableview.

I wanted to improve this so that each word typed in the search bar is searched for separately. I tried this, but of course it filters using OR logic (any word can match).

func searchItems() {
 if let searchText = searchController.searchBar.text?.split(separator: " ") {
 // Need to convert substring from split back to string
 let searchArray = searchText.map() { substring in
 String(substring)
 }
 filteredItems = allItems.filter { item in
 return searchArray.contains(where: drug.searchableString.lowercased().contains)
 }
 }
}

In order to get the desired AND logic (every word typed must match), I had to use this.

func searchItems() {
 if let searchText = searchController.searchBar.text?.split(separator: " ") {
 // Need to convert substring from split back to string
 let searchArray = searchText.map() { substring in
 String(substring)
 }
 filteredItems = allItems.filter { item in
 for word in searchArray {
 if !item.searchableString.lowercased().contains(word) {
 return false
 }
 }
 return true
 }
 }
}

This works as intended, but it does not seem elegant. Is there a better way to filter the array allItems to filteredItems based on matching text in those String properties of the objects in my data model?

asked Jan 20, 2020 at 21:26
\$\endgroup\$
2
  • 1
    \$\begingroup\$ Using searchableString is a bad design. searchItems() is not pure/honest. The filter closure could be written as .filter { item in return searchArray.allSatisfy({ item.searchableString.lowercased().contains(0ドル) }) }. Either way, it's far from being efficient. \$\endgroup\$ Commented Jan 28, 2020 at 20:31
  • \$\begingroup\$ @ielyamani Thanks for the advice. I suspected it was not a good maintainable or extensible design. \$\endgroup\$ Commented Feb 1, 2020 at 21:00

2 Answers 2

2
+50
\$\begingroup\$

Charles is completely right: a regex is the best solution for such task.

First I would define extension for String to match regex

extension String {
 func match(regex: String) -> Bool {
 return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: self)
 }
}

Then your search function can be done like this:

// OR case
func searchItems() -> [Model] {
 guard let searchWords = searchController.searchBar.text?.split(separator: " ") {
 return [allItems] // Or you can return empty array, depending on your requirements
 }
 // .* = any characters
 // (?i) = case insensitive
 // (word1|word2) = words to search using OR
 // .* = any characters
 let searchRegex = ".*(?i)(\(searchWords.joined(separator: "|"))).*"
 return allItems.filter { 0ドル.drug.match(regex: searchRegex) }
}
// AND case
func searchItems() -> [Model] {
 guard let searchWords = searchController.searchBar.text?.split(separator: " ") {
 return [allItems] // Or you can return empty array, depending on your requirements
 }
 let mappedSearchWords = searchWords.map { "(?=.*\(0ドル))" }
 // .* = any characters
 // (?i) = case insensitive
 // (?=.*word1)(?=.*word2)) = words to search using AND
 // .* = any characters
 let searchRegex = ".*(?i)" + mappedSearchWords.joined() + ".*"
 return allItems.filter { 0ドル.drug.match(regex: searchRegex) }
}
// My general recommendation is to declare search function with search text and items as input
// Then it can be easily used for testing
// OR case
func searchAnyWords(from text: String, in items: [Model]) -> [Model]
// AND case
func searchAllWords(from text: String, in items: [Model]) -> [Model]
Toby Speight
87.1k14 gold badges104 silver badges322 bronze badges
answered Feb 1, 2020 at 11:21
\$\endgroup\$
3
\$\begingroup\$

You have picked a hard data structure and hard algorithm to solve it. Specifically, you have your searchable list as a list of strings and need to parse and convert it for each search. You then do linear searches.

If you are going to precompute or cache your search information, consider creating a dictionary where the keys are the words and the values are sets of item indices. Then you search is anding the set of indices with the value of each word.

Alternately, if you are optimizing for code brevity, use a regular expression. If your search strings were like ':MyName:MyType:MyBatch:' You want to create an expression from your search like :(word1|word2|word3): and use

NSUInteger numberOfMatches = [regex numberOfMatchesInString:string
 options:0
 range:NSMakeRange(0, [string length])];

and make sure it matches the number of words. Yes, more work if you want to avoid the, er, inventive user, searching for "MyBatch MyBatch MyBatch".

Keep trying to make it prettier :)

answered Jan 30, 2020 at 5:29
\$\endgroup\$
2
  • \$\begingroup\$ Many thanks for your answer - I hadn’t considered regular expressions. \$\endgroup\$ Commented Feb 1, 2020 at 21:47
  • 2
    \$\begingroup\$ Let me recommend regex101.com as a resource for creating and testing the regular expressions. It always saves me a few cycles. \$\endgroup\$ Commented Feb 2, 2020 at 0:56

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.