はじめに

こんにちは!システム開発部のYです。

前回の記事ではCoreDataの使い方について詳しく解説しました。CoreDataに興味を持った方は、ぜひご覧ください。以下が記事のURLです。
https://gaprot.jp/2023/12/11/coredata/

WWDC2023で、SwiftDataが発表されました。iOS17から利用することができる、データを永続化するためのフレームワークです。使い方などをまとめていこうと思います。
https://developer.apple.com/xcode/swiftdata/

環境

  • Swift Version: 5.9
  • Xcode Version: 15
  • iOS: 17.0
  • macOS: 13.5

目次

SwiftDataとは

SwiftDataはiOS17以降で利用可能で、宣言型コードを使用してデータを簡単に永続化することができます。外部ファイル形式がなくコードのみで実装可能です。
また、Swift Macrosを使用することでSwiftUIと自然的に統合し、シームレスな連携を実現しています。

SwiftDataの使い方、サンプルの実装

今回は簡単な連絡先管理アプリを実装します。
連絡先情報を入力後SwiftDataで保存し一覧画面で表示をします。
データは永続化されているので、アプリを起動し直しても表示されています。

また、加えて動的な抽出条件(検索バー)を実装しデータを絞り込む処理とデータを削除する処理を実装します。

SwiftData使用準備

@Modelの定義

@Modelは新しいSwift macroで、@ModelをつけるだけでClassの格納型プロパティを永続型プロパティに変えます。

連絡先管理アプリということなので、以下7つのプロパティを宣言します。
・id: 識別するための一意になる値(UUID)
・name: 名前(String)
・phone: 電話番号(String)
・email: メールアドレス(String)
・address: 住所(String)
・otherInfo: その他連絡先に関する情報(String)
・createdTime: 作成時間(Date)

import Foundation
import SwiftData

/// @Modelを定義すると、SwiftDataで永続型プロパティに変更される
@Model
final class ContactsModel {

/// ID
@Attribute(.unique)
var id: UUID
/// 名前
var name: String
/// 電話番号
var phone: String
/// メールアドレス
var email: String
/// 住所
var address: String
/// そのほかの連絡先に関する情報
var otherInfo: String
/// 作成日時
var createdTime: Date

init(name: String, phone: String = "", email: String = "", address: String = "", otherInfo: String = "") {
self.id = UUID()
self.name = name
self.phone = phone
self.email = email
self.address = address
self.otherInfo = otherInfo
self.createdTime = Date()
}
}

また、上記では @Attribute マクロを使用しユニーク制約をIdにつけています。そうすることで一意制約がつきます。

他にも、 @Relationship マクロ等ありますが今回はModelが一つなので使用していません。

ModelContainer

アプリのスキーマとモデルのストレージ構成を管理するオブジェクトです。
https://developer.apple.com/documentation/swiftdata/modelcontainer

modifierが提供されており、引数には扱いたいモデルを指定します。また、複数のモデルも定義できます。

@main
struct SampleSwiftDataApp: App {

var body: some Scene {
WindowGroup {
ContentView()
}
// 以下のように.modelContainerを付与する
.modelContainer(for: ContactsModel.self)
}
}

ModelContext

データのフェッチ、挿入、削除、および変更のディスクへの保存を可能にするオブジェクトです。
https://developer.apple.com/documentation/swiftdata/modelcontext

以下のように、EnvironmentからmodelContextを取得します。

struct ContentView: View {

/// contextを宣言
@Environment(\.modelContext) private var context

}

データの取得、保存、削除

データの取得(ソートなし)

@Query プロパティラッパーを使用して、取得できます。

/// データベースから取得したいデータのプロパティの前に @Query を付与する
@Query private var contactsModels: [ContactsModel]

取得した、連絡先情報一覧を表示する場合の実装です。
連絡先情報の名前を表示しています。

struct ContentView: View {
/// データベースから取得したいデータのプロパティの前に @Query を付与する
@Query private var contactsModels: [ContactsModel]

var body: some View {

NavigationStack {
List {
ForEach(contactsModels) { contactsModel in
NavigationLink(value: contactsModel) {
VStack(alignment: .leading) {
Text(contactsModel.name)
}
}
}
}
}

}
}

データの取得(ソートあり)

@Query プロパティラッパーに、追加のオプション sort に指定することで実現できます。
以下は、createdTime(作成日時)の昇順でデータの取得を行っています。

@Query(sort: \ContactsModel.createdTime) private var contactsModels: [ContactsModel]

また、降順にデータを並び替えするには以下のように order を指定することでできます。
指定なしのデフォルトは forward で、昇順になっています。

@Query(sort: \ContactsModel.createdTime, order: .reverse) private var contactsModels: [ContactsModel]

データの取得(抽出条件の指定)

@Query プロパティラッパーに、追加のオプション filter に指定することで実現できます。
以下は、name(名前)が “太郎” に絞り込まれています。

@Query(filter: #Predicate<ContactsModel> { $0.name == "太郎" } ) private var contactsModels: [ContactsModel]

データの保存

ModelContext.insert(_:) を使用して保存することができます。
以下は、仮データのContactsModelを追加しています。

    private func testAdd() {

let contactsModel = ContactsModel.init(name: "太郎", phone: "00011112222", email: "testes@gmail.com", address: "東京都渋谷区神宮前", otherInfo: "")

context.insert(contactsModel)
}

データの削除

ModelContext.delete(_:) を使用して削除することができます。
以下はIndexSetを受け取りそのインデックスに対応するデータを取得し、削除しています。

    private func deleteContactsItem(_ indexSet: IndexSet) {
for index in indexSet {
let deleteContactsItem = contactsModels[index]
context.delete(deleteContactsItem)
}
}

Previewについて

SwiftUIのPreview機能を使用する場合、現状のままだとCrashします。なので、Preview用の初期データを用意します。

@MainActor
let previewContainer: ModelContainer = {

do {
let schema = Schema([ContactsModel.self])

let container = try ModelContainer(for: schema, configurations: .init(isStoredInMemoryOnly: true))

for i in 0..<10 {
let contactsModel = ContactsModel(name: "名前 \(i)")
container.mainContext.insert(contactsModel)
}

return container

} catch {
fatalError()
}
}()

そして、Previewで上記のpreviewContainerを呼び出します。

#Preview {
ContentView()
.modelContainer(previewContainer)
}

またそのままPreviewに書くこともできます。

#Preview {
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: ContactsModel.self, configurations: config)

for i in 0..<10 {
let contactsModel = ContactsModel(name: "名前 \(i)")
container.mainContext.insert(contactsModel)
}

return ContentView()
.modelContainer(container)
} catch {
fatalError()
}
}

動的な抽出条件の実装(検索バー)

@Query で生成されるプロパティには、利用可能なシンプルな sortOrder プロパティがないためビューのイニシャライザを使用して並び替えます。
なので、並び替えを挿入できるサブビューと切り分けます。

① ContactsListingView(並び替えを挿入できるサブビュー)を作成

import SwiftUI

struct ContactsListingView: View {

var body: some View {

}
}

② contactsModelsプロパティを移動

import SwiftUI
import SwiftData

struct ContactsListingView: View {

/// データベースから取得したいデータのプロパティの前に @Query を付与する
@Query(sort: \ContactsModel.createdTime) private var contactsModels: [ContactsModel]

var body: some View {

}
}

③ List配下を全て移動

import SwiftUI
import SwiftData

struct ContactsListingView: View {

/// データベースから取得したいデータのプロパティの前に @Query を付与する
@Query(sort: \ContactsModel.createdTime) private var contactsModels: [ContactsModel]

var body: some View {

List {
ForEach(contactsModels) { contactsModel in
NavigationLink(value: contactsModel) {

VStack(alignment: .leading) {
Text(contactsModel.name)
}
}
}
}
}
}

④ 検索バーで入力したテキストを受け取るinitを追加、またクエリ自体を変更しようとしているため、次のようにアンダースコア付きのプロパティ名を使用する必要があります。

import SwiftUI
import SwiftData

struct ContactsListingView: View {

/// データベースから取得したいデータのプロパティの前に @Query を付与する
@Query(sort: \ContactsModel.createdTime) private var contactsModels: [ContactsModel]

var body: some View {

List {
ForEach(contactsModels) { contactsModel in
NavigationLink(value: contactsModel) {

VStack(alignment: .leading) {
Text(contactsModel.name)
}
}
}
}
}

init(searchString: String) {

// 検索対象を受け取り、init
_contactsModels = Query(filter: #Predicate {
if searchString.isEmpty {
return true
} else {
return $0.name.contains(searchString)
}
})
}
}

⑤ deleteContactsItem()と、modelContextを追加

import SwiftUI
import SwiftData

struct ContactsListingView: View {

@Environment(\.modelContext) var context
/// データベースから取得したいデータのプロパティの前に @Query を付与する
@Query(sort: \ContactsModel.createdTime) private var contactsModels: [ContactsModel]

var body: some View {

List {
ForEach(contactsModels) { contactsModel in
NavigationLink(value: contactsModel) {

VStack(alignment: .leading) {
Text(contactsModel.name)
}
}
}
.onDelete(perform: deleteContactsItem)
}
}

init(searchString: String) {

// 検索対象を受け取り、init
_contactsModels = Query(filter: #Predicate {
if searchString.isEmpty {
return true
} else {
return $0.name.contains(searchString)
}

})
}

/// データの削除
private func deleteContactsItem(_ indexSet: IndexSet) {
for index in indexSet {
let deleteContactsItem = contactsModels[index]
context.delete(deleteContactsItem)
}
}
}

⑥ 親Viewに入力内容を監視するための変数用意

@State private var searchText: String = ""

⑦ ContactsListingView(並び替えを挿入できるサブビュー)を親Viewから呼び出します。

ContactsListingView(searchString: searchText)

⑧ 検索バーの実装、ContactsListingViewにsearchable(text:placement:)モディファイアを実装
※ iOS15以上から、下記参照
https://developer.apple.com/documentation/swiftui/view/searchable(text:placement:prompt:)-18a8f

        ContactsListingView(searchString: searchText) {
...
}
.searchable(text: $searchText)

最終的なコード

ContactsModel.swift

    @Model
final class ContactsModel {

/// ID
@Attribute(.unique)
var id: UUID
/// 名前
var name: String
/// 電話番号
var phone: String
/// メールアドレス
var email: String
/// 住所
var address: String
/// そのほかの連絡先に関する情報
var otherInfo: String
/// 作成日時
var createdTime: Date

init(name: String, phone: String = "", email: String = "", address: String = "", otherInfo: String = "") {
self.id = UUID()
self.name = name
self.phone = phone
self.email = email
self.address = address
self.otherInfo = otherInfo
self.createdTime = Date()
}


init(inputContactsModel: InputContactsModel) {
self.id = UUID()
self.name = inputContactsModel.name
self.phone = inputContactsModel.phone
self.email = inputContactsModel.email
self.address = inputContactsModel.address
self.otherInfo = inputContactsModel.otherInfo
self.createdTime = Date()
}
}

ContentView.swift

import SwiftUI
import SwiftData

@Observable class InputContactsModel {
var name = ""
var phone = ""
var email = ""
var address = ""
var otherInfo = ""
}

@MainActor
let previewContainer: ModelContainer = {

do {
let schema = Schema([ContactsModel.self])

let container = try ModelContainer(for: schema, configurations: .init(isStoredInMemoryOnly: true))

for i in 0..<10 {
let contactsModel = ContactsModel(name: "名前 \(i)")
container.mainContext.insert(contactsModel)
}

return container

} catch {
fatalError()
}
}()


struct ContentView: View {
/// contextを宣言
@Environment(\.modelContext) private var context

// Sheetを表示、制御フラグ
@State private var showingSheet = false

@State private var inputContactsModel: InputContactsModel = .init()
@State private var contactsEntryViewComplete = false

@State private var searchText: String = ""

var body: some View {

NavigationStack {
ContactsListingView(searchString: searchText)
.navigationTitle("連絡先")
.toolbar {
ToolbarItem {
Button {
showingSheet = true
} label: {
Label("Add Item", systemImage: "plus")
}
}
}
.navigationDestination(for: ContactsModel.self) { contactsModel in
ContactsDetailView(contactsModel: contactsModel)
}
.sheet(isPresented: $showingSheet, onDismiss: {
addContactsItem()

}, content: {
ContactsEntryView(inputContactsModel: $inputContactsModel, inputComplete: $contactsEntryViewComplete)
})
.searchable(text: $searchText)

}
}

/// データの追加
private func addContactsItem() {
defer {
inputContactsModel = InputContactsModel()
contactsEntryViewComplete = false
}

if inputContactsModel.name.isEmpty || !contactsEntryViewComplete {
return
}

let contactsModel = ContactsModel(inputContactsModel: inputContactsModel)
context.insert(contactsModel)
}
}

#Preview {
ContentView()
.modelContainer(previewContainer)
}

ContactsListingView.swift

import SwiftUI
import SwiftData

struct ContactsListingView: View {

@Environment(\.modelContext) var context
/// データベースから取得したいデータのプロパティの前に @Query を付与する
@Query(sort: \ContactsModel.createdTime) private var contactsModels: [ContactsModel]

var body: some View {

List {
ForEach(contactsModels) { contactsModel in
NavigationLink(value: contactsModel) {

VStack(alignment: .leading) {
Text(contactsModel.name)
}
}
}
.onDelete(perform: deleteContactsItem)
}
}

init(searchString: String) {

// 検索対象を受け取り、init
_contactsModels = Query(filter: #Predicate {
if searchString.isEmpty {
return true
} else {
return $0.name.contains(searchString)
}

})
}

/// データの削除
private func deleteContactsItem(_ indexSet: IndexSet) {
for index in indexSet {
let deleteContactsItem = contactsModels[index]
context.delete(deleteContactsItem)
}
}
}

ContactsEntryView.swift

import SwiftUI

struct ContactsEntryView: View {

@Binding var inputContactsModel: InputContactsModel

@Binding var inputComplete: Bool

@State private var buttonIsDisabled = true

@Environment(\.presentationMode) var presentationMode

var body: some View {

NavigationStack {
VStack {
TextField(
"名前",
text: $inputContactsModel.name
)
.padding()

TextField(
"電話番号",
text: $inputContactsModel.phone
)
.padding()

TextField(
"メールアドレス",
text: $inputContactsModel.email
)
.padding()

TextField(
"住所",
text: $inputContactsModel.address
)
.padding()

TextField(
"その他",
text: $inputContactsModel.otherInfo
)
.padding()
}
.navigationTitle("新規連絡先")
.toolbarTitleDisplayMode(.inline)
.toolbar {

ToolbarItem(placement: .topBarLeading) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("キャンセル")
}
}

ToolbarItem(placement: .topBarTrailing) {
Button {
inputComplete = true

presentationMode.wrappedValue.dismiss()
} label: {
Text("完了")
}
.disabled(buttonIsDisabled)
}
}
.onChange(of: inputContactsModel.name) { _, _ in

checkButtonIsDisabled()
}
}
}

private func checkButtonIsDisabled() {
buttonIsDisabled = inputContactsModel.name.isEmpty
}
}

#Preview {
ContactsEntryView(inputContactsModel: .constant(.init()), inputComplete: .constant(false))
}

ContactsDetailView.swift

import SwiftUI
import SwiftData

struct ContactsDetailView: View {

var contactsModel: ContactsModel

var body: some View {
List {
Section(header: Text("名前")) {
Text(contactsModel.name)
}

Section(header: Text("電話番号")) {
Text(contactsModel.phone)
}

Section(header: Text("メールアドレス")) {
Text(contactsModel.email)
}

Section(header: Text("住所")) {
Text(contactsModel.address)
}

Section(header: Text("そのほかの連絡先に関する情報")) {
Text(contactsModel.otherInfo)
}
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("連絡先詳細")

}
}

#Preview {
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: ContactsModel.self, configurations: config)
let exampleModel = ContactsModel(name: "aaa", phone: "123", email: "", address: "", otherInfo: "")
return ContactsDetailView(contactsModel: exampleModel)
.modelContainer(container)
} catch {
fatalError()
}
}

まとめ

SwiftDataの使い方などについてまとめました。
プロパティラッパーのおかげで、SwiftUIで非常に使いやすく感じます。
また、組み込まれているので導入の手間がかかりません。
しかしiOS17以降、Xcode15以降が必要なので実際プロジェクトで使えるのは遠くなりそうです。
この記事がお役に立てれば幸いです。また、間違っている点などありましたら、教えていただけると幸いです。

参考リンク



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる