SwiftDataを使ったToDoアプリの開発メモ

個人開発でSwiftDataを使ってみたいと思ったがいきなり導入しようとしてちょっと詰まったので、まずSwiftDataに慣れようと思いサンプルアプリを作ってみた記録。


やりたいこと

SwiftDataを使った簡単なToDoアプリの作成

ToDoアプリの要件

  • タスクの追加ができる

  • 追加したタスクを一覧で表示できる

  • タスクを完了、未完了にできる

  • タスクの削除ができる

  • タスクは追加した順に上から並べる

環境

  • Xcode 26.0.1

  • Swift 5

  • iOS 26

  • macOS Tahoe 26.0.1

プロジェクト作成

「SwiftDataToDoSample」という名前で新規プロジェクトを作成。
Storageの項目はNoneで作成。(SwiftDataを選択した場合はタイムスタンプを追加、削除できるサンプルが生成される)

Modelの作成

// Task.swift

import Foundation
import SwiftData

@Model
final class Task: Identifiable {
    private(set) var id: UUID
    var title: String
    var isDone: Bool
    private(set) var createdAt: Date
    
    init(title: String) {
        self.id = UUID()
        self.title = title
        self.isDone = false
        self.createdAt = Date()
    }
}

タスクのModelを作成。
idとcreatedAtは外部から変更できないようにprivate(set)に指定。

ModelContainerの実装

実際のアプリでデータ保存ができるように、SwiftDataToDoSampleApp.swiftの方でModelContainerを実装する。

ModelContainerの用意

var sharedModelContainer: ModelContainer = {
    let schema = Schema([
        Task.self
    ])
    let modelConfiguration = ModelConfiguration(schema: schema)
    
    do {
        return try ModelContainer(for: schema, configurations: [modelConfiguration])
    } catch {
        fatalError("Could not create ModelContainer: \(error)")
    }
}()

ModelContainerの引数にはSchemaとModelConfigurationの配列を渡すのでまずその2つを用意する。

Schemaには使用するModelを配列で渡す。
今回はTaskのみなのでTask.selfを渡す。

ModelConfigurationにはSchemaを渡す。

用意したschemaとmodelConfigurationをModelContainerの引数に渡して初期化する。
schemaはそのまま渡し、configurationsにはModelConfigurationの配列を渡すので、用意したmodelConfigurationを配列に入れて渡す。
また、ModelContainerのinitはthrowsが付いているのでdo-catchで括る。

ModelContainerを渡す

var body: some Scene {
    WindowGroup {
        ContentView()
    }
    .modelContainer(sharedModelContainer)
}

最後にModelContainerをWindowGroupの.modelContainerで渡すことでデータの保存ができるようになる。

この段階でのSwiftDataToDoSampleApp.swiftの全体。

// SwiftDataToDoSampleApp.swift

import SwiftUI
import SwiftData

@main
struct SwiftDataToDoSampleApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Task.self
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        
        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

タスクの追加(タイトル固定)

一旦ボタンを押したらタスクが追加されるところまで実装する。

画面下の+ボタンを押したらhogehogeというタイトルのタスクが追加される。

プロパティの宣言

まずContenetViewのプロパティとしてmodelContextとtasksを宣言。

@Environment(\.modelContext) private var modelContext
@Query private var tasks: [Task]

modelContextはデータの保存の際に使用する。
tasksは@QueryをつけることでTaskを全件取得してくれる。

タスク一覧のUI実装

次にViewを実装。

var body: some View {
    List {
        Section {
            ForEach(tasks) { task in
                Text(task.title)
            }
        }
    }
    .toolbar {
        ToolbarItem(placement: .bottomBar) {
            Button(action: addTask) {
                Image(systemName: "plus")
            }
        }
    }
}

Listでタスクを一覧で表示し、セルにはタスクのタイトルを表示。
タスクの追加ボタンはツールバーで実装。

タスクの追加処理

次に固定の文字列のタスクを追加するだけの処理を実装。

private extension ContentView {
    func addTask() {
        let task = Task(title: "hogehoge")
        modelContext.insert(task)
    }
}

hogehogeというタイトルのタスクを作成し、modelContext.insert()で作成したタスクを渡すことで保存される。
保存が成功すると一覧に自動で作成したタスクが表示されるようになる。

プレビュー

最後に画面の動作確認をするためにプレビューにmodelContainerを設定する。

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

今回ModelがTaskだけなのでTask.selfのみを渡す。
inMemoryはtrueにすることでプレビューが更新されるたびにデータがリセットされるようになる。

ここまでの実装でタスクの保存と一覧表示ができた。

この段階でのContenetView.swiftの全体。

// ContentView.swift

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var tasks: [Task]
    
    var body: some View {
        List {
            Section {
                ForEach(tasks) { task in
                    Text(task.title)
                }
            }
        }
        .toolbar {
            ToolbarItem(placement: .bottomBar) {
                Button(action: addTask) {
                    Image(systemName: "plus")
                }
            }
        }
    }
}

private extension ContentView {
    func addTask() {
        let task = Task(title: "hogehoge")
        modelContext.insert(task)
    }
}

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

タスク入力欄の実装

タスクの入力欄を追加し、入力した内容でタスク登録できるようにする。

TextFieldの追加

まずTextField用のStateをプロパティに追加する。

@State var inputTitle: String = ""

次にViewを編集する。

var body: some View {
    VStack {
        HStack {
            TextField("ToDo...", text: $inputTitle)
            
            Button("Add") {
                addTask()
            }
            .disabled(inputTitle.isEmpty)
        }
        .padding()
        
        List {
            Section {
                ForEach(tasks) { task in
                    Text(task.title)
                }
            }
        }
        .scrollDismissesKeyboard(.immediately)
    }
}

主な変更点は下記の通り。

  • 画面上部にタスクの入力欄と追加ボタンを横並びに配置。

  • 追加ボタンはinputTitleが空の時は押せないように制御。(.disabled(inputTitle.isEmpty))

  • Listに.scrollDismissesKeyboard(.immediately)を追加し、スクロール時にキーボードが閉じるように変更。

  • toolbarの実装は削除。

  • タスクの入力欄と追加ボタンは画面上部に固定されるように全体をVStackで括る。

addTask()の修正

最後にinputTitleの内容が反映されるようにaddTask()を修正する。

func addTask() {
    let task = Task(title: inputTitle)
    modelContext.insert(task)
    inputTitle = ""
}

主な変更点は下記の通り。

  • Taskの初期化時にinputTitleを渡すように変更。

  • データの保存後、inputTitleを空にする処理を追加。

これで入力した内容でタスク登録ができるようになった。

この段階でのContentView.swiftの全体。

// ContentView.swift

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var tasks: [Task]
    
    @State var inputTitle: String = ""
    
    var body: some View {
        VStack {
            HStack {
                TextField("ToDo...", text: $inputTitle)
                
                Button("Add") {
                    addTask()
                }
                .disabled(inputTitle.isEmpty)
            }
            .padding()
            
            List {
                Section {
                    ForEach(tasks) { task in
                        Text(task.title)
                    }
                }
            }
            .scrollDismissesKeyboard(.immediately)
        }
    }
}

private extension ContentView {
    func addTask() {
        let task = Task(title: inputTitle)
        modelContext.insert(task)
        inputTitle = ""
    }
}

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

タスクの完了

タスクの完了・未完了を切り替えられるようにする。

セルに完了ボタンを追加

まず分かりやすいようにセルの実装をTaskCell.swiftに切り分ける。

// TaskCell.swift

import SwiftUI

struct TaskCell: View {
    var body: some View {
        Text(task.title)
    }
}

#Preview {
    TaskCell()
}

切り分けたら、TaskCellのプロパティとしてTaskと、完了ボタンをタップしたときに呼ぶクロージャを宣言しておく。

let task: Task
let onTapDoneButton: () -> Void

次にビューの変更を加える。

var body: some View {
    HStack {
        Button {
            onTapDoneButton()
        } label: {
            if task.isDone {
                Image(systemName: "checkmark.square.fill")
                    .font(.system(size: 30))
            } else {
                Image(systemName: "square")
                    .font(.system(size: 30))
            }
        }
        .buttonStyle(.plain)

        Text(task.title)
            .foregroundStyle(task.isDone ? .gray : .black)
            .strikethrough(task.isDone)
    }
}

主な変更点は下記の通り。

  • 完了ボタンを追加

    • 完了ボタン部分にだけ当たり判定を持たせるために完了ボタンにbuttonStyle(.plain)を付与

      • これを付けないとセル全体のタップ判定が完了アクションになる

  • 完了ボタンとタスクタイトルのTextをHStackで括る

  • 完了ボタンのactionでonTapDoneButtonを呼ぶ

  • 完了と未完了それぞれの表示の反映

    • 未完了

      • 完了ボタンにsquareを表示

      • タスクタイトルのTextの色はblackを指定

    • 完了

      • 完了ボタンにcheckmark.square.fillを表示

      • タスクタイトルのTextに取り消し線を付け、色はgrayを指定

TaskCellのプレビュー

TaskCellを実装したらここまでの変更をプレビューで完了・未完了両方の状態を見れるようにする。

TaskのisDoneを初期化時に指定できるようにTask.swiftに手を加える。

// Task.swift

import Foundation
import SwiftData

@Model
final class Task: Identifiable {
    private(set) var id: UUID
    var title: String
    var isDone: Bool
    private(set) var createdAt: Date
    
    init(title: String, isDone: Bool = false) {
        self.id = UUID()
        self.title = title
        self.isDone = isDone
        self.createdAt = Date()
    }
}

主な変更点は下記の通り。

  • initの引数にisDoneを追加

    • デフォルト引数としてfalseを指定し、通常利用する場合はtitleだけ指定すれば使えるようにする

  • 引数で渡されるisDoneをinitの中でself.isDoneに仕込むように変更

Taskに手を加えたらプレビューの中でTaskCellを二つ並べ、それぞれisDone: trueとfalseを渡す。

#Preview {
    TaskCell(task: .init(title: "Test1", isDone: false), onTapDoneButton: {})
    TaskCell(task: .init(title: "Test2", isDone: true), onTapDoneButton: {})
}

これでプレビューで完了・未完了の状態を確認できるようになった。

この段階でのTaskCell.swiftの全体。

// TaskCell.swift

import SwiftUI

struct TaskCell: View {
    
    let task: Task
    let onTapDoneButton: () -> Void
    
    var body: some View {
        HStack {
            Button {
                onTapDoneButton()
            } label: {
                if task.isDone {
                    Image(systemName: "checkmark.square.fill")
                        .font(.system(size: 30))
                } else {
                    Image(systemName: "square")
                        .font(.system(size: 30))
                }
            }
            .buttonStyle(.plain)

            Text(task.title)
                .foregroundStyle(task.isDone ? .gray : .black)
                .strikethrough(task.isDone)
        }
    }
}

#Preview {
    TaskCell(task: .init(title: "Test1", isDone: false), onTapDoneButton: {})
    TaskCell(task: .init(title: "Test2", isDone: true), onTapDoneButton: {})
}

タスクの完了状態の切り替えと保存

まずContentView.swiftの方でTaskCellを使うように修正する。

List {
    Section {
        ForEach(tasks) { task in
            TaskCell(task: task, onTapDoneButton: {
                // TODO: 完了状態の切り替え処理
            })
        }
    }
}
.scrollDismissesKeyboard(.immediately)

次にタスクの完了状態の切り替えと保存を行う処理を実装する。

func toggleDone(task: Task) {
    task.isDone.toggle()
    
    do {
        try modelContext.save()
    } catch {
        print(error.localizedDescription)
    }
}

引数で渡されたtaskのisDoneをtoggleで切り替え、modelContext.save()を呼ぶことで保存される。
modelContext.save()はthrowsが付いているのでdo-catchで括る。

最後にtoggleDone(task:)を完了ボタンタップ時のアクションとして呼び出す。

TaskCell(task: task, onTapDoneButton: {
    toggleDone(task: task)
})

これでタスクの完了状態の切り替えができるようになった。

この段階でのContentView.swiftの全体。

//  ContentView.swift

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var tasks: [Task]
    
    @State var inputTitle: String = ""
    
    var body: some View {
        VStack {
            HStack {
                TextField("ToDo...", text: $inputTitle)
                
                Button("Add") {
                    addTask()
                }
                .disabled(inputTitle.isEmpty)
            }
            .padding()
            
            List {
                Section {
                    ForEach(tasks) { task in
                        TaskCell(task: task, onTapDoneButton: {
                            toggleDone(task: task)
                        })
                    }
                }
            }
            .scrollDismissesKeyboard(.immediately)
        }
    }
}

private extension ContentView {
    func addTask() {
        let task = Task(title: inputTitle)
        modelContext.insert(task)
        inputTitle = ""
    }
    
    func toggleDone(task: Task) {
        task.isDone.toggle()
        
        do {
            try modelContext.save()
        } catch {
            print(error.localizedDescription)
        }
    }
}

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

タスクの削除

タスクを削除できるようにする。

セルを横スワイプして削除できるようにする

まず横スワイプ削除のUIの実装をする。

TaskCellを括ってるForEachに.onDeleteを付与することで横スワイプで削除するUIが実現できる。

ForEach(tasks) { task in
    TaskCell(task: task, onTapDoneButton: {
        toggleDone(task: task)
    })
}
.onDelete { offsets in
    // TODO: タスクの削除処理
}

タスクの削除処理

次にタスクの削除処理を実装する。

引数にはonDeleteで渡されるIndexSetを受けるようにする。
受け取ったIndexSetをfor-inで回し、削除対象のタスクのindexを用いて削除処理を行う。

func deleteTask(offsets: IndexSet) {
    for index in offsets {
        modelContext.delete(tasks[index])
    }
}

最後に.onDeleteでdeleteTask(offsets:)を呼び出す。

ForEach(tasks) { task in
    TaskCell(task: task, onTapDoneButton: {
        toggleDone(task: task)
    })
}
.onDelete { offsets in
    deleteTask(offsets: offsets)
}

これでタスクの削除ができるようになった。

この段階でのContentView.swiftの全体。

// ContentView.swift

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var tasks: [Task]
    
    @State var inputTitle: String = ""
    
    var body: some View {
        VStack {
            HStack {
                TextField("ToDo...", text: $inputTitle)
                
                Button("Add") {
                    addTask()
                }
                .disabled(inputTitle.isEmpty)
            }
            .padding()
            
            List {
                Section {
                    ForEach(tasks) { task in
                        TaskCell(task: task, onTapDoneButton: {
                            toggleDone(task: task)
                        })
                    }
                    .onDelete { offsets in
                        deleteTask(offsets: offsets)
                    }
                }
            }
            .scrollDismissesKeyboard(.immediately)
        }
    }
}

private extension ContentView {
    func addTask() {
        let task = Task(title: inputTitle)
        modelContext.insert(task)
        inputTitle = ""
    }
    
    func toggleDone(task: Task) {
        task.isDone.toggle()
        
        do {
            try modelContext.save()
        } catch {
            print(error.localizedDescription)
        }
    }
    
    func deleteTask(offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(tasks[index])
        }
    }
}

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

タスクのソート

タスクを作成した順に上から並べる。

ソートは@Queryにsortとorderを指定することで実現できる。
sortにソートに使用するパラメータを設定し、orderに順番を設定する。

今回の場合はsortにTask.createdAt、orderに.reverseを設定。

@Query(sort: \Task.createdAt, order: .reverse) private var tasks: [Task]

ここまでの実装でひとまずサンプルアプリの要件を満たすことができた。

気になったこと

ここまで実装してみて気になったこと。(追々調査する)

  • 作成・削除に関してはmodelContext.save()を呼ばなくても自動保存されるが、保存処理が走るまでにラグがある模様。

    • modelContext.save()を明示的に呼べば解決する?

    • 作成・削除の処理をしているスレッドがおかしい?

    • 新規プロジェクト作成時にStorageをSwiftDataに指定して作成されるサンプルでも保存と削除にラグがあったからこれが仕様?

  • ViewStateなどにビジネスロジックを分離する場合@Queryが使えないので、別の方法での取得処理の実装や、ViewStateへのmodelContextの受け渡しが必要になりそう。

  • onDeleteのUIがタイムスタンプのサンプルと見た目が違くてどこでその差分が発生してるのか分からない。(セルの高さ?)

  • テキスト入力で英語だとinputTitleがリセットされるが日本語だと残ってた。(iOS 26のバグ?)

  • シミュレーターで動作チェック中、テキスト入力するとログにめっちゃ警告出てた。

リポジトリ

ここまでの実装をまとめたリポジトリはこちら。


いいなと思ったら応援しよう!