👀

【SwiftUI × CoreData】検索絞り込みを実装する

2022/03/27に公開

前提

先日SwiftUI × CoreDataを使ってCRUD機能を持つメモアプリを作成しました。
https://zenn.dev/tomsan96/articles/e76a1088bcf78d
このアプリに検索機能を追加します。

環境

Swift5
Xcode13.1
iOS15
(iOS15以上で使えるsearchableというモディファイアを利用します。)

検索絞り込み機能を実装する

メモアプリのソースコードから一覧表示部分を抜粋します。

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        entity: Memo.entity(),
        sortDescriptors: [NSSortDescriptor(key: "updatedAt", ascending: false)],
        animation: .default
    ) var fetchedMemoList: FetchedResults<Memo>

    var body: some View {
        NavigationView {
            List {
                ForEach(fetchedMemoList) { memo in
                    NavigationLink(destination: EditMemoView(memo: memo)) {
                        VStack {
                            Text(memo.title ?? "")
                               .font(.title)
                               .frame(maxWidth: .infinity,alignment: .leading)
                               .lineLimit(1)
                           HStack {
                               Text (memo.stringUpdatedAt)
                                   .font(.caption)
                                   .lineLimit(1)
                               Text(memo.content ?? "")
                                   .font(.caption)
                                   .lineLimit(1)
                               Spacer()
                           }
                        }
                    }
                }
                .onDelete(perform: deleteMemo)
            }
            .navigationTitle("メモ")
            .navigationBarTitleDisplayMode(.automatic)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    NavigationLink(destination: AddMemoView()) {
                        Text("新規作成")
                       
                    }
                }
            }
        }
    }
    
    private func deleteMemo(offsets: IndexSet) {
        offsets.forEach { index in
            viewContext.delete(fetchedMemoList[index])
        }
        try? viewContext.save()
    }
}

それではこの一覧表示に検索機能を追加していきます。

  1. 検索ワードを監視するためのプロパティを追加する
@State private var searchText: String = ""
  1. searchableモディファイアの実装
NavigationView {
// 省略
}
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "検索")

placementで表示位置を決められるのですが、今回は常に表示させるため.navigationBarDrawer(displayMode: .always)を使ってます。
またpromptでプレースホルダーの設定ができます。

  1. onChangeで入力をする度に検索条件の判定を行う
// searchableの下に追加
.onChange(of: searchText) { newValue in
    search(text: newValue)
}
  1. onChangeで呼ばれるsearchメソッドを実装
    CoreDataはNSPredicateを使うことで検索条件を指定することができます。
    private func search(text: String) {
	if text.isEmpty {
	    fetchedMemoList.nsPredicate = nil // ①
	} else {
	    let titlePredicate: NSPredicate = NSPredicate(format: "title contains %@", text) // ②
	    let contentPredicate: NSPredicate = NSPredicate(format: "content contains %@", text) // ③
	    fetchedMemoList.nsPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [titlePredicate, contentPredicate]) // ④
	}
    }

ポイント
①検索が空の場合、検索条件の絞り込みはなし
%@は文字列として扱うという意味
「titleに文字列textが含まれている」を検索条件とする
③「contentに文字列textが含まれている」を検索条件とする
NSCompoundPredicateで複数条件の連結
orPredicateWithSubpredicatesは配列の中身をOR条件で連結する

これにより、タイトルかメモの中身のどちらかが検索ワードに部分一致するメモだけを抽出することが可能となります。


全体像

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(
        entity: Memo.entity(),
        sortDescriptors: [NSSortDescriptor(key: "updatedAt", ascending: false)],
        animation: .default
    ) var fetchedMemoList: FetchedResults<Memo>
    @State private var searchText: String = ""
        
    var body: some View {
        NavigationView {
            List {
                ForEach(fetchedMemoList) { memo in
                    NavigationLink(destination: EditMemoView(memo: memo)) {
                        VStack {
                            Text(memo.title ?? "")
                           .font(.title)
                           .frame(maxWidth: .infinity,alignment: .leading)
                           .lineLimit(1)
                        HStack {
                            Text (memo.stringUpdatedAt)
                               .font(.caption)
                               .lineLimit(1)
                            Text(memo.content ?? "")
                               .font(.caption)
                               .lineLimit(1)
                            Spacer()
                           }
                        }
                    }
                }
                .onDelete(perform: deleteMemo)
            }
            .navigationTitle("メモ")
            .navigationBarTitleDisplayMode(.automatic)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    NavigationLink(destination: AddMemoView()) {
                        Text("新規作成")
                    }
                }
            }
        }
        .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "検索")
        .onChange(of: searchText) { newValue in
            search(text: newValue)
        }
    }
    
    private func deleteMemo(offsets: IndexSet) {
        offsets.forEach { index in
            viewContext.delete(fetchedMemoList[index])
        }
        try? viewContext.save()
    }
    
    private func search(text: String) {
        if text.isEmpty {
            fetchedMemoList.nsPredicate = nil
        } else {
            let titlePredicate: NSPredicate = NSPredicate(format: "title contains %@", text)
            let contentPredicate: NSPredicate = NSPredicate(format: "content contains %@", text)
            fetchedMemoList.nsPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [titlePredicate, contentPredicate])
        }
    }
}

参考

https://qiita.com/yusuga/items/8fd531ebd8f5e72bb97b
https://hachinobu.hateblo.jp/entry/20130123/1358904735
https://useyourloaf.com/blog/making-swiftui-views-searchable/

Discussion