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?
2 Answers 2
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]
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 :)
-
\$\begingroup\$ Many thanks for your answer - I hadn’t considered regular expressions. \$\endgroup\$Chris– Chris2020年02月01日 21:47:52 +00:00Commented 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\$Charles Merriam– Charles Merriam2020年02月02日 00:56:13 +00:00Commented Feb 2, 2020 at 0:56
searchableString
is a bad design.searchItems()
is not pure/honest. Thefilter
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\$