【SwiftData】アプリのデータモデルを永続化する

# この記事は何？

「Develop in Swift Tutorials」の[Models and persistence](https://developer.apple.com/tutorials/develop-in-swift/save-data#Connect-SwiftData-and-SwiftUI)を解説します。

> Swiftを基礎から学ぶには
自著、工学社より発売中の「[まるごと分かるSwiftプログラミング](https://amzn.asia/d/7YPWJwl)」をお勧めします。変数、関数、フロー制御構文、データ構造はもちろん、構造体からクロージャ、エクステンション、プロトコル、クロージャまでを基礎からわかりやすく解説しています。
> また、Swiftプログラミングを基礎から動画で学びたい方には、Udemyコース「[今日からはじめるプログラミング](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwii1qTuj_2BAxV4hlYBHbj5BqEQFnoECBYQAQ&url=https%3A%2F%2Fwww.udemy.com%2Fcourse%2Fstart-programming-today%2F&usg=AOvVaw2vs4Sccq-bpfA4ZD2rCyNm&opi=89978449)」をお勧めします。

## アプリのデータを保存するには

友達の誕生日を表示するアプリを構築して、データモデリングを学ぶ。
まず、データモデルを作成する。
そして、SwiftDataフレームワークと統合して、アプリ起動時にデータがリセットされないようにする。

## Birthdaysアプリケーションのコード

### データモデル

新しいiOSプロジェクトを作成する
StorageオプションはNone
Friend.swiftファイルを作成して、Friend構造体を定義する

```swift
import Foundation

struct Friend {
    let name: String
    let birthday: Date
}
```

### ビュー


![スクリーンショット 2024-06-05 1.54.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/51433f9b-a0aa-d5ea-ff4a-aba843018e08.png)


```swift
import SwiftUI

struct ContentView: View {
    @State private var friends: [Friend] = [
        Friend(name: "Elton Lin", birthday: .now),
        Friend(name: "Jenny Court", birthday: Date(timeIntervalSince1970: 0))
    ]
    
    @State private var newName = ""
    @State private var newDate = Date.now

    var body: some View {
        NavigationStack {
            List(friends, id: \.name) { friend in
                HStack {
                    Text(friend.name)
                    Spacer()
                    Text(friend.birthday, format: .dateTime.month(.wide).day().year())
                }
            }
            .navigationTitle("Birthdays")
            .safeAreaInset(edge: .bottom) {
                VStack(alignment: .center, spacing: 20) {
                    Text("New Birthday")
                        .font(.headline)
                    DatePicker(selection: $newDate, in: Date.distantPast...Date.now, displayedComponents: .date) {
                         TextField("Name", text: $newName)
                            .textFieldStyle(.roundedBorder)
                    }
                    Button("Save") {
                        let newFriend = Friend(name: newName, birthday: newDate)
                        friends.append(newFriend)

                        newName = ""
                        newDate = .now                    }
                    .bold()
                }
                .padding()
                .background(.bar)
            }
        }
    }
}
```

### 構造体をSwiftDataモデルに変換する

誕生日を思い出すためのアプリは、入力したデータを保存できなければ役に立たない。
ユーザーが作成した`Friend`型インスタンスをSwiftDataフレームワークで保存し、アプリを再起動してもデータが消えないようにする。

- *Friend.swift* ファイルを開き、インポートしてSwiftDataにアクセスする

SwiftDataはアプリ内のデータをモデル化して、永続性のあるデータを保存できる。
そのために、ユーザーがアプリから離れてもデータが消えないようにするツールを提供する。

- `Friend`構造体に`@Model`マクロをマークする

`@Model`マクロに使用するには、SwiftDataフレームワークが必要。
`@Model`は、SwiftクラスをSwiftDataによって管理される格納モデルに変換するための、隠しコードを生成する。

> Note
マクロが生成した隠しコードによって引き起こされるエラーは、以降のステップで解消します。

[Product > Build]を選択して、プロジェクトをビルドする。
ここで発生するエラーは、「`Friend`型の`@Model`を、構造体ではなくクラスに適用すべきある」ことを示している。

 - `Friend`型をクラスにする

構造体とは異なり、クラスのインスタンスにはアイデンティティの概念がある。
SwiftDataはアイデンティティを使用して、アプリ全体にわたってモデルデータを適切なビューと共有する。
いずれかのビューがモデルを変更すると、SwiftDataはそれをすぐに見つけます。

> Note
イシューナビゲーターには、ビルド時に発生したエラーが表示されます。エラーを解決してから、プロジェクトナビゲーターに切り替えてください。

- `Friend`型にイニシャライザを追加する
- そして、プロジェクトを再度ビルドして、エラーが解決されたことを確認する

イニシャライザは、すべてのプロパティを初期化することで、型のインスタンスを作成する。
Swiftは構造体にイニシャライザを自動的に提供するが、クラスには自動的にイニシャライザが提供されない。
したがって、クラスではプログラマーがイニシャライザを作成する。

```swift: 構造体をSwiftDataモデルに変換する
import Foundation
import SwiftData

@Model
class Friend {
    let name: String
    let birthday: Date
    
    init(name: String, birthday: Date) {
        self.name = name
        self.birthday = birthday
    }
}
```


### SwiftDataとSwiftUIの接続

SwiftDataモデルにアップグレードすると、SwiftUIの`@State`配列をSwiftDataクエリで操作できるようになる。
なお、このセクションの手順を完了するまで、アプリはビルドできなくなることを理解しておくこと。

![スクリーンショット 2024-06-08 22.16.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/ab91dd1c-ad69-b3a7-ce1f-0bfd33ae35db.png)

まず、コンテナを設定してから、`@State`配列をSwiftDataクエリに変更する。
そして、アプリが再び動作する前に、新しい友達データを正しく保存できるようにする。

1. *BirthdaysApp.swift* を開いて、SwiftDataをインポートする
2. そして、[`.modelContainer(for:)`モディファイア](https://developer.apple.com/documentation/swiftui/view/modelcontainer(for:inmemory:isautosaveenabled:isundoenabled:onsetup:)-18hhy)に`Friend`モデルを指定して、SwiftDataとSwiftUIを接続する

コンテナの役割は、友達データの保存場所と`ContentView`画面を仲介すること。
`Friend.self`は特定の友達データではなく、`Friend`型の定義を指す。
コンテナはモデルとなる型の定義を理解して、モデルデータを正しく保存する。

- *ContentView.swift*で、SwiftDataをインポートする
- `.modelContainer`モディファイアで、プレビューにモデルコンテナを追加する
- `.modelContainer`モディファイアの`inMemory`引数に`true`を指定する

プレビューはいつでも、更新するたびに同じ初期状態で開始する必要がある。
`inMemory`を`true`と指定することで、コンテナのストレージシステムがインメモリコンテナ方式になる。
つまり、アプリがメモリ上にある場合にのみ、データが保存される。

- `friends`配列の属性を`@State`から`@Query`に変更する

そうすることで、SwiftDataによって管理されている`Friend`型インスタンスを取得できるようになる。
`@Query`属性のプロパティは、SwiftDataに配列データ（この場合は`[Friend]`）を要求する。
SwiftDataによって管理される`Friend`型インスタンスを更新すると、（`@State`プロパティと同様に）クエリがビューを更新する。

> Note
SwiftDataのサンプルデータは、クエリで直に作成できません。このセクションの最後でエラーを解決したら、サンプルデータを元に戻します。

- SwiftDataで新しいアイテムを保存するには`ModelContext`が必要なので、環境値の`\.modelContext`にアクセスする`context`プロパティを宣言する

`ModelContext`は「ビューとモデルコンテナの接続」を提供し、コンテナ内のアイテムを取得、挿入、削除できるようにする。
`ContentView`の`.modelContainer`モディファイアは、SwiftUI環境に`modelContext`を挿入する。
そのおかげで、`modelContext`は「コンテナより下層のすべてのビュー」にアクセスできる。

- 「Saveボタン」のコードで、新しい友達インスタンスを追加する`append`メソッドを「`ModelContext`の[insert(_:)メソッド](https://developer.apple.com/documentation/swiftdata/modelcontext/insert(_:))」に置き換える

`ModelContext`に挿入すると、新しい友達がコンテナに保存される。
`@Query`もコンテナに接続されているので、明示的に`newFriend`を`friends`配列に追加する必要はない。
画面には、新しい友達がピックアップ表示されます。

- `.task`で以前のサンプルデータを作成し、SwiftDataに保存する

SwiftUIはビューが表示される直前に、`.task`のコードを実行する。
したがって、モデルオブジェクトを挿入すると、`@Query`がそれらをピックアップして`friends`プロパティを更新する。

> Experiment
シミュレータでアプリを実行（[Product > Run]を選択）します。その後、アプリをもう一度実行してください。プレビューと比較して、シミュレーターにはたくさんの友達がいる理由を考察してみましょう。

```swift: SwiftDataとSwiftUIの接続
/* 
 BirthdaysApp.swift
*/
import SwiftUI
import SwiftData

@main
struct BirthdaysApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: Friend.self)
        }
    }
}

/*
 ContentView.swift
*/
import SwiftUI
import SwiftData

struct ContentView: View {
    @Query private var friends: [Friend]
    @Environment(\.modelContext) private var context
    
    @State private var newName = ""
    @State private var newDate = Date.now

    var body: some View {
        NavigationStack {
            List(friends, id: \.name) { friend in
                HStack {
                    Text(friend.name)
                    Spacer()
                    Text(friend.birthday, format: .dateTime.month(.wide).day().year())
                }
            }
            .navigationTitle("Birthdays")
            .safeAreaInset(edge: .bottom) {
                VStack(alignment: .center, spacing: 20) {
                    Text("New Birthday")
                        .font(.headline)
                    DatePicker(selection: $newDate, in: Date.distantPast...Date.now, displayedComponents: .date) {
                         TextField("Name", text: $newName)
                            .textFieldStyle(.roundedBorder)
                    }
                    Button("Save") {
                        let newFriend = Friend(name: newName, birthday: newDate)
                        context.insert(newFriend)

                        newName = ""
                        newDate = .now                    }
                    .bold()
                }
                .padding()
                .background(.bar)
            }
            .task {
                context.insert(Friend(name: "Elton Lin", birthday: .now))
                context.insert(Friend(name: "Jenny Court", birthday: Date(timeIntervalSince1970: 0)))
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Friend.self, inMemory: true)
}
```


### Use model data to fill out the UI

友達を年齢順にソートして、今日が誕生日の友達を強調し、日付を人間が読みやい表示にします。
SwiftDataはモデルの各インスタンスに対して、データとは別に独自のアイデンティティを提供します。

- `@Model`が提供するアイデンティティを使用するために、リストの明示的なid指定を削除する

今まではリスト内の各`Friend`インスタンスを識別するために`name`プロパティを使用していたが、SwiftDataによってインスタンスを識別できるようになった。
もはや、同じ名前の友達が何人いても問題ない。

誕生日を使って、リストの友達を時系列で並べ替える。
前年の生年月日は月や日に関係なく、後年の誕生日より先の要素になる。

> Experiment
別の人を、エルトンとジェニーの間に追加してみましょう。

- `Friend`型に「友達の誕生日が今日か」を算出するための、計算プロパティを追加する

`Calendar`型は、絶対時点(日付)を年、月、分、秒などの単位に変換する。
世界にはさまざまなカレンダーシステムがあるが、`Calendar.current`は「アプリを実行しているデバイス」のカレンダー設定にアクセスする。

- *ContentView.swift* を開く
- [`isBirthdayToday`](https://developer.apple.com/documentation/foundation/calendar/2293243-isdateintoday)メソッドを使用して、「誕生日が今日の友人」について名前をボールドにする
- さらに、ケーキのSFシンボルで強調する。

> Experiment
ケーキのかわりになりそうな、パーティー関連のSFシンボルを探しましょう。

- アプリのデータを事前に入力する`.task`を削除しておく。

> Experiment
シミュレーターでアプリを起動して、アプリの全機能を試してください。
友達を追加し、誕生日の順にリスト表示されることを確認しましょう。


```swift:
/*
 Friend.swift
*/
import Foundation
import SwiftData

@Model
class Friend {
    let name: String
    let birthday: Date

    var isBirthdayToday: Bool {
        Calendar.current.isDateInToday(birthday)
    }
    
    init(name: String, birthday: Date) {
        self.name = name
        self.birthday = birthday
    }
}

/*
 ContentView.swift
*/
import SwiftUI
import SwiftData

struct ContentView: View {
    @Query(sort: \Friend.birthday) private var friends: [Friend]
    @Environment(\.modelContext) private var context
    
    @State private var newName = ""
    @State private var newDate = Date.now

    var body: some View {
        NavigationStack {
            List(friends) { friend in
                HStack {
                    if friend.isBirthdayToday {
                         Image(systemName: "birthday.cake")
                     }
                    Text(friend.name)
                        .bold(friend.isBirthdayToday)
                    Spacer()
                    Text(friend.birthday, format: .dateTime.month(.wide).day().year())
                }
            }
            .navigationTitle("Birthdays")
            .safeAreaInset(edge: .bottom) {
                VStack(alignment: .center, spacing: 20) {
                    Text("New Birthday")
                        .font(.headline)
                    DatePicker(selection: $newDate, in: Date.distantPast...Date.now, displayedComponents: .date) {
                         TextField("Name", text: $newName)
                            .textFieldStyle(.roundedBorder)
                    }
                    Button("Save") {
                        let newFriend = Friend(name: newName, birthday: newDate)
                        context.insert(newFriend)

                        newName = ""
                        newDate = .now                    }
                    .bold()
                }
                .padding()
                .background(.bar)
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Friend.self, inMemory: true)
}
```

## 参考

[Date](https://developer.apple.com/documentation/foundation/date)
[NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack)
[SwiftData](https://developer.apple.com/documentation/swiftdata)
[@Query](https://developer.apple.com/documentation/swiftdata/filtering-and-sorting-persistent-data)
[EnvironmentValues](https://developer.apple.com/documentation/swiftui/environmentvalues/)