はじめに
SwiftUI で CoreData を利用してデータを管理するアプリを作成する中で、UIKit における UISearchBar のような機能が欲しくなりました。
ユーザーの入力に応じて動的に表示データを変えるというのは、まさに SwiftUI の得意技だと思いますが、SwiftUI には同等の機能を提供する View が用意されておらず、また、CoreData との連携方法にひと工夫必要です。
本稿ではこのような UI を作る上で必要となる、ユーザーの入力に応じて動的に CoreData のDBからデータを検索し、結果を表示する方法について説明します。
なお、本稿では、SwiftUI ならびに CoreData それぞれの基本的な説明は割愛します。
追記(2022年2月15日)
iOS15 より、.searchable
というビューモディファイアが登場し、CoreData の FetchRequest
を動的に構成することができるようになったので、本稿で説明したような動的検索をすっきりと実現できるようになりました。
余裕ができたら記事化したいと思います。
追記しました(2022年3月4日)
開発環境等
- MacBook (Retina, 12inch, Early 2015)
- macOS Catalina Version 10.15.7
- Xcode Version 12.1 (12A7403)
- iOS 14.1 (iPhone X 実機で動作確認済み)
課題
入門記事などでは、SwiftUIでCoreDataを利用するための基本的な実装として以下のようなコードがよく紹介されています。
struct ItemListView1: View {
@Environment(\.managedObjectContext) var context
@FetchRequest(entity: Item.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Item.title, ascending: true)],
predicate: NSPredicate(format: "title CONTAINS[C] \'hoge\'"),
animation: .default) var items: FetchedResults<Item>
var body: some View {
List {
ForEach(items) { item in
HStack {
Text(item.title!)
}
}
}
}
}
注) CoreDataを利用するために、AppDelegate.swift などで、NSPersistentCloudKitContainer
オブジェクトを生成するなどの準備も必要ですが、本稿では CoreData 利用の基本説明は割愛しています。
これは、文字列型の title プロパティに文字列"hoge"を含む item オブジェクトをDBから取得しリスト表示する例ですが、検索する文字列は固定となっています。
やりたいのは、TextField でユーザーが入力した文字列をもとに、動的にデータを検索・取得することです。どうしたら良いでしょうか?
試行錯誤
まず考えられるのは以下のようなコードです。
TextField でユーザーが入力したデータを serchWord
に代入し、それを@FetchRequest
のpredicate
に設定するという考え方です。
※このコードではエラーとなります。
struct ItemListView2: View {
@Environment(\.managedObjectContext) var context
@FetchRequest(entity: Item.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Item.title, ascending: true)],
predicate: NSPredicate(format: "title CONTAINS[C] %@", searchWord),
animation: .default) var items: FetchedResults<Item>
@State private var searchWord: String
var body: some View {
VStack {
TextField("search word", text: $searchWord)
List {
ForEach(items) { item in
HStack {
Text(item.title!)
}
}
}
}
}
}
実際にコードを書いてみると、@FetchRequest()
の predicate
を設定する部分でエラーとなります。
プロパティの初期化に、初期化されていない他のプロパティを使うことができないのです。
では、どうすれば良いのでしょうか?
解決策
パターンA
まず、検索文字列を入力する View と、それをもとに List 表示する View を sub view として分けます。
後者の sub view の init()
で、検索文字列を受け取り、FetchRequest
を設定します。
init()
の中で、FetchRequest
を設定するのは、 self.items
ではなく、self._items
であるところがミソです。
sel.items
は get only なので、何かを set するにはその本体である self._items
に対して行う必要があるのです。
struct ItemListView3: View {
@Environment(\.managedObjectContext) var context
@State private var searchWord: String = ""
var body: some View {
VStack {
TextField("search word", text: $searchWord)
ItemListView3sub(searchWord: searchWord)
}
}
}
struct ItemListView3sub: View {
@Environment(\.managedObjectContext) var context
@FetchRequest var items: FetchedResults<Item>
var searchWord: String
init(searchWord: String) {
self.searchWord = searchWord
self._items = FetchRequest(entity: Item.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Item.title, ascending: true)],
predicate: NSPredicate(format: "title CONTAINS[C] %@", searchWord),
animation: .default)
}
var body: some View {
List {
ForEach(items) { item in
HStack {
Text(item.title!)
}
}
}
}
}
パターンB
まず、検索文字列を入力する View と、それをもとに List 表示する View を分ける所は一緒ですが、子View に対して、FetchRequest
を丸ごと渡します。
struct ItemListView4: View {
@Environment(\.managedObjectContext) var context
@State private var searchWord: String = ""
var body: some View {
VStack {
TextField("search word", text: $searchWord)
ItemListView4sub(items: FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Item.title, ascending: true)], predicate: NSPredicate(format: "title CONTAINS[C] %@", searchWord)))
}
}
}
struct ItemListView4sub: View {
@Environment(\.managedObjectContext) var context
@FetchRequest var items: FetchedResults<Item>
var body: some View {
List {
ForEach(items) { item in
HStack {
Text(item.title!)
}
}
}
}
}
パターンAに比べパターンBの方がシンプルに思えます。
ただし、パターンAの方は、例えば、受け取った文字列が空文字列だった場合は検索条件を変える、などといったキメの細かい処理を行うことが可能になります。
iOS15以降の方法(2022年3月4日追記)
iOS15で、CoreDataにFetchRequestの動的構成(Dynamic configuration)が導入されました。
これによって、Viewの初期化時(init()
)以外で Predicate や Sort Descriptor を構成することができます。
これと、SwiftUI に導入された.searchable
という View modifier を組み合わせると本稿で説明した動的検索をもっとシンプルに行うことができます。
struct ItemListView5: View {
@Environment(\.managedObjectContext) var context
@FetchRequest(entity: Item.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Item.title, ascending: true)],
animation: .default) var items: FetchedResults<Item>
@State private var str: String = ""
var query: Binding<String> {
Binding {
str
} set: { newValue in
str = newValue
items.nsPredicate = (newValue.isEmpty
? nil
: NSPredicate(format: "title CONTAINS[C] %@", newValue))
}
}
var body: some View {
VStack {
Text("-ItemListView5-")
List {
ForEach(items) { item in
HStack {
Text(item.title!)
}
}
}
.searchable(text: query, prompt: Text("input str for search"))
}
}
}
これで、init()
をごちゃごちゃ書いたり、Viewをふたつに分けたりしなくてよくなりました。
※この追記分に関しては下記環境で検証しています。
- MacBook Air (M1, 2020)
- macOS Monterey Version 12.2
- Xcode Version 13.2.1 (13C100)
- iOS 15.0
最後に
SwiftUI や CoreData はまだまだ情報が少なく、ちょっと突っ込んだ使い方をしたい場合、意外と情報が見つからないことが多いように思います。
この記事が少しでもお役に立てれば幸いです。