【Swift】SwiftData を使ってみる
初めに
今回は Swift のデータ永続化フレームワークである SwiftData の実装をしてみたいと思います。
記事の対象者
- Swift 学習者
- Swift でデータ永続化を行いたい方
目的
今回は上記の通り SwiftData を用いた実装をしてみたいと思います。
最終的には以下の動画のように簡単なTODOアプリを作ってみたいと思います。
環境
今回は visionOS を含む以下の環境で実装を行います。
- Xcode 15.2
- visionOS 1.0
実装
実装は以下の手順で行いたいと思います。
- データ構造の定義
- TODO追加、削除、一覧画面作成
- アプリの設定
- そのほかにできること
1. データ構造の定義
まずはTODOアプリで使用するデータ構造を定義します。
コードは以下の通りです。
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
}
}
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を管理する画面を作成します。
コードは以下の通りです。
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])
}
}
}
ModelContainer
は ModelContext
と永続ストレージの仲介を行うものであり、ページ内で使用するデータ構造を .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
が作成された順番を元にデータの並び替えを実装してみます。
それぞれのコードを以下のように追加します。
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
}
}
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
を指定することで実装できます。
コード全文
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
}
}
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
}
}
}
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)
}
}
}
}
import SwiftUI
@main
struct SwiftDataSampleApp: App {
var body: some Scene {
WindowGroup {
SwiftDataSampleContentView()
.modelContainer(for: [TodoItem.self])
}
}
}
完成イメージ
以上です。
まとめ
最後まで読んでいただいてありがとうございました。
今回は SwiftData を使ってデータの保存や削除を行う実装を行いました。
基本的には context.insert
など非常に短い処理で記述でき、便利だと感じました。
iOSで使用する場合には iOS17 以降でなければ使用できないため、バージョンには気をつける必要があるかと思います。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion