💾

【Swift】SwiftData を使ってみる

2024/04/28に公開

初めに

今回は Swift のデータ永続化フレームワークである SwiftData の実装をしてみたいと思います。

記事の対象者

  • Swift 学習者
  • Swift でデータ永続化を行いたい方

目的

今回は上記の通り SwiftData を用いた実装をしてみたいと思います。
最終的には以下の動画のように簡単なTODOアプリを作ってみたいと思います。

https://youtu.be/nxZltyzB0RU

環境

今回は visionOS を含む以下の環境で実装を行います。

  • Xcode 15.2
  • visionOS 1.0

実装

実装は以下の手順で行いたいと思います。

  1. データ構造の定義
  2. TODO追加、削除、一覧画面作成
  3. アプリの設定
  4. そのほかにできること

1. データ構造の定義

まずはTODOアプリで使用するデータ構造を定義します。
コードは以下の通りです。

TodoItem.swift
import Foundation
import SwiftData

@Model
final class TodoItem {
    var title: String
    var content: String
    var isDone: Bool
    var color: TodoColor
    
    init(title: String, content: String, isDone: Bool, color: TodoColor) {
        self.title = title
        self.content = content
        self.isDone = isDone
        self.color = color
    }
}
TodoColor.swift
import Foundation
import SwiftUI

enum TodoColor: String, CaseIterable, Codable {
    case black = "black"
    case blue = "blue"
    case red = "red"
    case green = "green"
    case yellow = "yellow"
    case orange = "orange"
    
    var color: Color {
        switch self {
        case .black: return Color.black
        case .blue:
            return Color.blue
        case .red:
            return Color.red
        case .green:
            return Color.green
        case .yellow:
            return Color.yellow
        case .orange:
            return Color.orange
        }
    }
}

SwiftData をインポートして @Modelアノテーションをつけることで、データ構造として認識され、データを定義することができるようになります。@Modelアノーテーションは PersistentModelMacro と呼ばれるマクロで実装されています。

2. TODO追加、削除、一覧画面作成

次にTODOを管理する画面を作成します。
コードは以下の通りです。

SwiftDataSampleContentView.swift
import SwiftUI
import SwiftData

struct SwiftDataSampleContentView: View {
    @Environment(\.modelContext) private var context
    @Query private var todoItemList: [TodoItem]
    @State private var titleTextInput = ""
    @State private var contentTextInput = ""
    @State private var selectedColor = TodoColor.black
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    VStack {
                        TextField("Title", text: $titleTextInput)
                            .padding()
                        TextField("Content", text: $contentTextInput)
                            .padding()
                    }
                    Picker("Color", selection: $selectedColor) {
                        ForEach(TodoColor.allCases, id: \.self) { todoColor in
                            HStack {
                                Circle()
                                    .fill(todoColor.color)
                                    .frame(width: 30, height: 30)
                                Spacer()
                                Text(todoColor.rawValue)
                            }
                            .padding()
                        }
                    }
                    .pickerStyle(.inline)
                    .frame(width: 300, height: 200)
                }
                Button {
                    add(title: $titleTextInput.wrappedValue, content: $contentTextInput.wrappedValue, todoColor: selectedColor)
                } label: {
                    Text("追 加")
                        .padding()
                }
                List {
                    ForEach(todoItemList) { todoItem in
                        TodoItemView(todoItem: todoItem)
                    }
                    .onDelete(perform: { indexList in
                        for index in indexList {
                            delete(todoItem: todoItemList[index])
                        }
                    })
                }
                .padding()
            }
            .navigationTitle("Sample Todo App")
        }
        .padding()
    }
    
    private func add(
        title: String,
        content: String,
        todoColor: TodoColor
    ) {
        let data = TodoItem(title: title, content: content, isDone: false, color: todoColor)
        context.insert(data)
        do {
            try context.save()
        } catch {
            print(error)
        }
    }
    
    private func delete(todoItem: TodoItem) {
        context.delete(todoItem)
        do {
            try context.save()
        } catch {
            print(error)
        }
    }
}

struct TodoItemView: View {
    var todoItem: TodoItem
    
    var body: some View {
        HStack {
            Circle()
                .fill(todoItem.color.color)
                .frame(width: 30, height: 30)
            VStack(alignment: .leading) {
                Text(todoItem.title)
                    .font(.title)
                    .padding(.horizontal)
                Text(todoItem.content)
                    .padding(.horizontal)
            }
        }
    }
}

それぞれ詳しく見ていきます。

以下の部分ではTODOの管理画面で使用する変数の定義を行なっています。

@Environment(\.modelContext) private var context
@Query private var todoItemList: [TodoItem]
@State private var titleTextInput = ""
@State private var contentTextInput = ""
@State private var selectedColor = TodoColor.black

@Environment(\.modelContext) はクエリやモデルを扱うための EnvironmentValues です。
@Query private var todoItemList: [TodoItem] では SwiftData に保存されている TodoItem のリストを取得することができます。データベースのCRUD処理のうちデータ一覧のReadはこの1行で完結します。
下の三つではそれぞれTODOのタイトル、詳細、色の状態を保持するための State を定義しています。

以下の部分ではTODOのタイトルと詳細を入力するための TextField と色を設定するための Picker を配置しています。

VStack {
  TextField("Title", text: $titleTextInput)
    .padding()
  TextField("Content", text: $contentTextInput)
    .padding()
}
Picker("Color", selection: $selectedColor) {
  ForEach(TodoColor.allCases, id: \.self) { todoColor in
    HStack {
      Circle()
        .fill(todoColor.color)
        .frame(width: 30, height: 30)
      Spacer()
      Text(todoColor.rawValue)
    }
    .padding()
  }
}
.pickerStyle(.inline)
.frame(width: 300, height: 200)

以下の部分では TodoItem の追加、削除処理の実装を行なっています。
add では登録したい TodoItem のタイトル、詳細、色を受け取り、context.insert でデータを追加しています。通常であればこれでデータを追加できるのですが、筆者の環境だとアプリを再起動した際にデータが保持されていなかったため、try context.save() の処理も追加しています。

delete では削除したい TodoItem を受け取り、それを context.delete の引数に渡すだけでデータの削除が行えます。

private func add(
  title: String,
  content: String,
  todoColor: TodoColor
) {
  let data = TodoItem(title: title, content: content, isDone: false, color: todoColor)
  context.insert(data)
  do {
    try context.save()
  } catch {
    print(error)
  }
}
    
private func delete(todoItem: TodoItem) {
  context.delete(todoItem)
  do {
    try context.save()
  } catch {
    print(error)
  }
}

context.save() に関して、データに変更があった際に自動的に保存する context.autosaveEnabled = true などの設定も行ってみたのですが、 save() なしだとデータが残らなかったので、原因が分かり次第追記したいと思います。

以下では先ほど述べた add を使って、ボタンタップ時の処理としてデータの追加を行なっています。

Button {
  add(
    title: $titleTextInput.wrappedValue,
    content: $contentTextInput.wrappedValue,
    todoColor: selectedColor)
} label: {
  Text("追 加")
    .padding()
}

以下では TodoItem の削除処理を実装しています。
List の ForEach に対して指定することで、リストに表示されている TodoItem を左側に引っ張り、「Delete」を押すことで削除できるようになります。

.onDelete(perform: { indexList in
  for index in indexList {
    delete(todoItem: todoItemList[index])
  }
})

これでTodoを管理する画面の実装は完了です。

3. アプリ側の設定

最後にアプリ側の設定を行います。コードは以下の通りです。

import SwiftUI

@main
struct SwiftDataSampleApp: App {
    var body: some Scene {
        WindowGroup {
            SwiftDataSampleContentView()
                .modelContainer(for: [TodoItem.self])
        }
    }
}

ModelContainerModelContext と永続ストレージの仲介を行うものであり、ページ内で使用するデータ構造を .modelContainer(for: [使用するデータ構造]) のように定義する必要があります。

なお、この ModelContainer の定義がない場合は以下のようなエラーになります。

Fatal error: failed to find a currently active container for TodoItem

4. そのほかにできること

独自の型を別のモデルに含める

今回は個人開発で扱ったデータモデルを例にとって、独自の型を別のモデルに含める実装を行います。
コードは以下の通りです。

import Foundation
import SwiftData

@Model
final class CityInfoDataModel {
    var imageName: String?
    var name: String?
    var prefecture: Prefecture?
    @Relationship(deleteRule: .cascade)
    var touristSpots: [TouristSpotDataModel] = []

    init(
        imageName: String,
        name: String,
        prefecture: Prefecture
    ) {
        self.imageName = imageName
        self.name = name
        self.prefecture = prefecture
    }
}
import Foundation
import SwiftData

@Model
final class TouristSpotDataModel: Codable {
    enum CodingKeys: CodingKey {
        case placeId,
             latitude,
             longitude,
             name,
             spotDescription,
             thumbnailImageUrl
    }
    
    @Attribute(.unique) let id: UUID
    var placeId: String?
    var latitude: Float? = 0.0
    var longitude: Float? = 0.0
    var name: String?
    var spotDescription: String?
    var thumbnailImageUrl: String?
    
    init(
        latitude: Float,
        longitude: Float,
        name: String,
        placeId: String,
        spotDescription: String,
        thumbnailImageUrl: String
    ) {
        self.id = UUID()
        self.latitude = latitude
        self.longitude = longitude
        self.name = name
        self.placeId = placeId
        self.spotDescription = spotDescription
        self.thumbnailImageUrl = thumbnailImageUrl
    }
    
    required init(from decoder: Decoder) throws {
        // init処理
    }
    
    func encode(to encoder: Encoder) throws {
        // encode 処理
    }
}

独自の型を持っているのは以下の部分になります。
@Relationship アノーテーションをつけることで別のデータモデルを関連づけることができるようになります。
また、 deleteRule: .cascade とすることで親のモデル(この場合は CityInfoDataModel)が削除されたときに関連づけられている子のモデル(この場合は TouristSpotDataModel)も削除されるようになります。
deleteRule: .deny を指定すると親のモデルに合わせて子のモデルが削除されるのを防ぐことができます。これは関連づけている親のモデルが複数個あり、子が削除されると困る場合に使用します。

@Relationship(deleteRule: .cascade)
var touristSpots: [TouristSpotDataModel] = []

子のモデルである TouristSpotDataModel 側に定義されている以下の部分では、子のモデルのIDを指定しており、@Attribute(.unique) とすることでそのプロパティの値が同じ型を持つ全てのモデルで一意であることを示します。

@Attribute(.unique) let id: UUID

データを並び替える

TodoItem が作成された順番を元にデータの並び替えを実装してみます。
それぞれのコードを以下のように追加します。

TodoItem.swift
import Foundation
import SwiftData

@Model
final class TodoItem {
    var title: String
    var content: String
    var isDone: Bool
+   var createdAt: Date
    var color: TodoColor
    
    init(
        title: String,
        content: String,
        isDone: Bool,
+       createdAt: Date,
        color: TodoColor
    ) {
        self.title = title
        self.content = content
        self.isDone = isDone
+       self.createdAt = createdAt
        self.color = color
    }
}
SwiftDataSampleContentView.swift
import SwiftUI
import SwiftData

struct SwiftDataSampleContentView: View {
    @Environment(\.modelContext) private var context
    // sort, order を追加
+   @Query(sort: \TodoItem.createdAt, order: .forward) private var todoItemList: [TodoItem]
    @State private var titleTextInput = ""
    @State private var contentTextInput = ""
    @State private var selectedColor = TodoColor.black
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    VStack {
                        TextField("Title", text: $titleTextInput)
                            .padding()
                        TextField("Content", text: $contentTextInput)
                            .padding()
                    }
                    Picker("Color", selection: $selectedColor) {
                        ForEach(TodoColor.allCases, id: \.self) { todoColor in
                            HStack {
                                Circle()
                                    .fill(todoColor.color)
                                    .frame(width: 30, height: 30)
                                Spacer()
                                Text(todoColor.rawValue)
                            }
                            .padding()
                        }
                    }
                    .pickerStyle(.inline)
                    .frame(width: 300, height: 200)
                }
                Button {
                    add(title: $titleTextInput.wrappedValue, content: $contentTextInput.wrappedValue, todoColor: selectedColor)
                } label: {
                    Text("追 加")
                        .padding()
                }
                List {
                    ForEach(todoItemList) { todoItem in
                        TodoItemView(todoItem: todoItem)
                    }
                    .onDelete(perform: { indexList in
                        for index in indexList {
                            delete(todoItem: todoItemList[index])
                        }
                    })
                }
                .padding()
            }
            .navigationTitle("Sample Todo App")
        }
        .padding()
        .onAppear {
            context.autosaveEnabled = true
        }
    }
    
    private func add(
        title: String,
        content: String,
        todoColor: TodoColor
    ) {
        // createdAtを追加
+       let data = TodoItem(title: title, content: content, isDone: false, createdAt: Date.now, color: todoColor)
        context.insert(data)
        do {
            try context.save()
        } catch {
            print(error)
        }
    }
    
    private func delete(todoItem: TodoItem) {
        context.delete(todoItem)
        do {
            try context.save()
        } catch {
            print(error)
        }
    }
}

struct TodoItemView: View {
    var todoItem: TodoItem
    
    var body: some View {
        HStack {
            Circle()
                .fill(todoItem.color.color)
                .frame(width: 30, height: 30)
            VStack(alignment: .leading) {
                Text(todoItem.title)
                    .font(.title)
                    .padding(.horizontal)
                Text(todoItem.content)
                    .padding(.horizontal)
+               Text(todoItem.createdAt.description)
+                   .padding(.horizontal)
            }
        }
    }
}

これで実行すると以下の画像のように古い TodoItem から新しい TodoItem の順番で並べることができるようになります。
なお、逆に新しい TodoItem から古い TodoItem の順番に並べ替えるためには @Query の中で order: .reverse を指定することで実装できます。

コード全文
TodoItem.swift
import Foundation
import SwiftData

@Model
final class TodoItem {
    var title: String
    var content: String
    var isDone: Bool
    var createdAt: Date
    var color: TodoColor
    
    init(
        title: String,
        content: String,
        isDone: Bool,
        createdAt: Date,
        color: TodoColor
    ) {
        self.title = title
        self.content = content
        self.isDone = isDone
        self.createdAt = createdAt
        self.color = color
    }
}
TodoColor.swift
enum TodoColor: String, CaseIterable, Codable {
    case black = "black"
    case blue = "blue"
    case red = "red"
    case green = "green"
    case yellow = "yellow"
    case orange = "orange"
    
    var color: Color {
        switch self {
        case .black: return Color.black
        case .blue:
            return Color.blue
        case .red:
            return Color.red
        case .green:
            return Color.green
        case .yellow:
            return Color.yellow
        case .orange:
            return Color.orange
        }
    }
}
SwiftDataSampleContentView.swift
import SwiftUI
import SwiftData

struct SwiftDataSampleContentView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \TodoItem.createdAt, order: .forward) private var todoItemList: [TodoItem]
    @State private var titleTextInput = ""
    @State private var contentTextInput = ""
    @State private var selectedColor = TodoColor.black
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    VStack {
                        TextField("Title", text: $titleTextInput)
                            .padding()
                        TextField("Content", text: $contentTextInput)
                            .padding()
                    }
                    Picker("Color", selection: $selectedColor) {
                        ForEach(TodoColor.allCases, id: \.self) { todoColor in
                            HStack {
                                Circle()
                                    .fill(todoColor.color)
                                    .frame(width: 30, height: 30)
                                Spacer()
                                Text(todoColor.rawValue)
                            }
                            .padding()
                        }
                    }
                    .pickerStyle(.inline)
                    .frame(width: 300, height: 200)
                }
                Button {
                    add(title: $titleTextInput.wrappedValue, content: $contentTextInput.wrappedValue, todoColor: selectedColor)
                } label: {
                    Text("追 加")
                        .padding()
                }
                List {
                    ForEach(todoItemList) { todoItem in
                        TodoItemView(todoItem: todoItem)
                    }
                    .onDelete(perform: { indexList in
                        for index in indexList {
                            delete(todoItem: todoItemList[index])
                        }
                    })
                }
                .padding()
            }
            .navigationTitle("Sample Todo App")
        }
        .padding()
    }
    
    private func add(
        title: String,
        content: String,
        todoColor: TodoColor
    ) {
        let data = TodoItem(title: title, content: content, isDone: false, createdAt: Date.now, color: todoColor)
        context.insert(data)
        do {
            try context.save()
        } catch {
            print(error)
        }
    }
    
    private func delete(todoItem: TodoItem) {
        context.delete(todoItem)
        do {
            try context.save()
        } catch {
            print(error)
        }
    }
}

struct TodoItemView: View {
    var todoItem: TodoItem
    
    var body: some View {
        HStack {
            Circle()
                .fill(todoItem.color.color)
                .frame(width: 30, height: 30)
            VStack(alignment: .leading) {
                Text(todoItem.title)
                    .font(.title)
                    .padding(.horizontal)
                Text(todoItem.content)
                    .padding(.horizontal)
                Text(todoItem.createdAt.description)
                    .padding(.horizontal)
            }
        }
    }
}
SwiftDataSampleApp.swift
import SwiftUI

@main
struct SwiftDataSampleApp: App {
    var body: some Scene {
        WindowGroup {
            SwiftDataSampleContentView()
                .modelContainer(for: [TodoItem.self])
        }
    }
}

完成イメージ

https://youtu.be/nxZltyzB0RU

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

今回は SwiftData を使ってデータの保存や削除を行う実装を行いました。
基本的には context.insert など非常に短い処理で記述でき、便利だと感じました。
iOSで使用する場合には iOS17 以降でなければ使用できないため、バージョンには気をつける必要があるかと思います。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://developer.apple.com/xcode/swiftdata/

https://developer.apple.com/documentation/swiftdata

Discussion