SwiftUIでCoreDataを動的に検索・表示する[追記あり]

## はじめに

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を利用するための基本的な実装として以下のようなコードがよく紹介されています。


```swift:ItemListView1.swift
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` に設定するという考え方です。

※このコードではエラーとなります。

```swift:ItemListView2.swift
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` に対して行う必要があるのです。

```swift:ItemListView3.swift
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` を丸ごと渡します。


```swift:ItemListView4.swift
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 を組み合わせると本稿で説明した動的検索をもっとシンプルに行うことができます。

```swift:ItemListView5.swift
    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 はまだまだ情報が少なく、ちょっと突っ込んだ使い方をしたい場合、意外と情報が見つからないことが多いように思います。

この記事が少しでもお役に立てれば幸いです。

## 参考
- [SwiftUI プログラムへの Core Data データベース機能の追加](https://qiita.com/MaShunzhe/items/5cc294324f0ecc54c264)
- [SwiftUIとCoreDataを@FetchRequestで連携させる](https://qiita.com/coe/items/0f0585f314ab2624f548)
- 書籍 [『SwiftUI 徹底入門』著:金田 浩明](https://www.sbcr.jp/product/4815604066/)
- [SwiftUI View and @FetchRequest predicate with variable that can change](https://stackoverflow.com/questions/57871088/swiftui-view-and-fetchrequest-predicate-with-variable-that-can-change)
- [WWWDC21: Bring Core Data concurrency to Swift and SwiftUI](https://developer.apple.com/videos/play/wwdc2021/10017/)