SmartNews風のアプリを作成しているのですが、複数の単語での記事検索機能を実装したので、そのコードの解説を紹介します。
ポイントはreduceとfilterの組み合わせです。
var preArticleArray:[ArticleQueryData] = [] //データベースからfetchした全記事情報を入れる配列
var searchArticleArray:[ArticleQueryData] = [] //検索によるフィルタリング後の記事情報を入れる配列
var conditions = [(ArticleQueryData) -> Bool]() //複数単語の検索で使用
//省略
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
searchArticleArray.removeAll()
self.view.endEditing(true)
if let word = searchBar.text {
if(word == "") {
return
} else if word.contains(" ") { //検索窓に入力された文字列を、半角スペース分割する。
let words:[String] = word.components(separatedBy: " ")
searchFetchArticleData(searchWords:words)
SVProgressHUD.show()
} else {
searchFetchArticleData(searchWords:[word])
SVProgressHUD.show()
}
}
}
func searchFetchArticleData(searchWords:[String]) {
if let user = Auth.auth().currentUser {
//Firestoreの初期化、reference先を変数に格納
let ref = Firestore.firestore().collection("articleData")
let uid = user.uid
//.addSnapshotListenerを使うと"いいね"などの更新をキャッチできます。
ref.addSnapshotListener { querySnapshot, err in
if let err = err {
print("Error fetching documents: \(err)")
} else {
self.searchArticleArray = [] //fetchする前に空にする
self.preArticleArray = [] //こっちも空にしないと、fetchするたびに増えていってしまう。
self.conditions = [(ArticleQueryData) -> Bool]() //conditionsも初期化しないと、2回目以降の検索がうまくいかなくなる。
for document in querySnapshot!.documents {
let articleData = ArticleQueryData(snapshot: document, myId: uid)
//全記事データがpreArticleArrayに格納される
self.preArticleArray.insert(articleData, at: 0)
}
//$0にはconditions:[(ArticleQueryData) -> Bool]のelement、すなわちArticleQueryData型のelementが入ることになります。
//なお、summaryもtagsもtitleStrも記事情報のpropertyで、String型です。
for word in searchWords {
self.conditions.append { $0.titleStr!.contains(word) || $0.summary!.contains(word) || $0.tags!.contains(word) }
}
//$0には初めtrueが初期値として設定されています。$1にはconditionsの戻り値の要素(Bool値)が順々に入っていくことになります。
//conditionsにはsearchWords.countの数だけBool値があり、それがreduceによって畳み込まれます。つまり、全てtrueならtrueとなりfilterを通過し、1つでもfalseならfalseとなり弾かれます。
self.searchArticleArray = self.preArticleArray.filter { article in
self.conditions.reduce(true) { $0 && $1 (article) }
}
if self.searchArticleArray.isEmpty {
self.resultLabel.text = "検索条件に一致する記事は見つかりませんでした。"
}
//中略
SVProgressHUD.dismiss()
}
}
}
}
複数検索の実装にあたり、参考にしたのは、こちらの記事[Swift] 複数のOR条件をAND検索してArrayをフィルタリングするです。
検索文字2つと銘打っていますが、3つでも4つでも10個でもこの方法なら対応できると思います。
Swiftのfilterとreduceについて理解が浅かったため、少々理解と応用に時間がかかりました。
よく書かれている形以下のような形だと思います。
let numbers = [1, 2, 3, 4, 5]
//filter
let newNumbers = numbers.filter { $0 < 3 }
print(newNumbers) //[1, 2]
//reduce
let sum = numbers.reduce(0) {$0 + $1}
print(sum) //15
ただ、これだと他の言語とかでreduceやfilter使ったりしたことがないswift初心者は多少面食らうわけです。
そもそもこれは少し簡略化されています。簡略化せずに書くと、
let numbers = [1, 2, 3, 4, 5]
//filter
let newNumbers = numbers.filter { (value: Int) -> Bool in
return value < 3
}
print(newNumbers) //[1, 2]
//reduce
let sum = numbers.reduce(0) { (sum: Int, value: Int) -> Int in
return total + value
}
print(sum) //15
という形になります。
つまり、filterの方では第一引数が$0に、
reduceの方では第一引数が$0、第二引数が$1に簡略して書くことができるということです。
これらを踏まえて先のコードを、順に辿っていくと、
for word in searchWords {
self.conditions.append { $0.titleStr!.contains(word) || $0.summary!.contains(word) || $0.tags!.contains(word) }
}
self.searchArticleArray = self.preArticleArray.filter { article in
self.conditions.reduce(true) { $0 && $1 (article) }
}
まず、preArticleArrayに格納された一つ一つの記事情報articleを取り出します。※filterのクロージャーの中。
articleはconditionsの引数として取り込まれ、searchWordsの数だけBool値を返します。
そのBool値はreduceによって畳み込まれます。
初期値をtrueとして、全てtrueならtrue、一つでもfalseがあればfalseになります。
そのBool値がfilterの結果となり、trueだったarticleのみが抽出されます。
※preArticleArrayを宣言せずに、最初にfetchした記事データをそのままsearchArticleArrayに格納し、
self.searchArticleArray = self.searchArticleArrayfilter { article in
self.conditions.reduce(true) { $0 && $1 (article) }
と上書きさえるような形をとっても上手く動作するとは思います。配列を分けたほうがわかりやすいかと思って今回は分けました。
以上です!
参考記事
[Swift] 複数のOR条件をAND検索してArrayをフィルタリングする
Swiftの配列、辞書関連メソッドの基本
Swiftのmap, filter, reduce(などなど)はこんな時に使う!