🚖

SwiftUIとSwiftDataで簡単todoアプリ

2024/09/07に公開

対象読者

  • SwiftUI触り始めた人
  • プログラミングに興味がある人

ある程度知識のある方にはこの記事は退屈かもしれません…

環境

system_profiler SPHardwareDataType
Hardware:

    Hardware Overview:

      Model Name: MacBook Pro
      Model Identifier: Mac14,7
      Chip: Apple M2
      Total Number of Cores: 8 (4 performance and 4 efficiency)
      Memory: 16 GB
      以下略
xcodebuild -version
Xcode 15.4
Build version 15F31d
swift --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0

どんなものを作るか

完成品のスクショを貼ります

実機にビルドしたので前に試していたSwiftDataが残っていました

右上のプラスマークを押すとtodo項目が追加できます

プラスマークを押すと下からシートが出てきて

todoの内容と重要度を選択してSaveボタンを押すとSwiftDataに保存するようにします

こんな感じ

todoアプリ作成

まずはxcodeでプロジェクトの作成から

新しいプロジェクト作成から「App」→「Next」

  1. アプリ名入力
  2. SwiftData選択
  3. Next

これで雛形が作成されます

最初のコード説明

プロジェクトが作成されると以下のようなファイルができます

ZennTodoApp

これが一番最初の起点のファイルです
今回は特に触る必要はありません

このファイルのコードを見てみるとContentViewを生成しているので
なんとなくContentViewが起点になっていることがわかりますね

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

ContentView

最初はプラスマークを押すと時間が追加されるようなサンプルがあります
これを参考にしてtodoアプリを作成していきます

ですが色々コードがあって邪魔なので以下のように減らします

ContentView.swift

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]

    var body: some View {
       Text("hello zenn!")
    }
}

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

Item

Item.swift
import Foundation
import SwiftData

@Model
final class Item {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

なんかクラスが作られて「@Model」でアノテーションをされています
なんとなくDBで使いそうな気がしますよね

ここでは「timestamp」という変数を宣言して初期化しているのがわかります

todoアプリではタイトルなどの項目も必要になってくるので後でここを編集します

Assets

ここは画像などを置いておくところです
flutterやっている人ならassetsでイメージが湧くはず…

アプリのアイコンなどここに格納するだけで簡単に設定できるので超便利です

コードを触っていく

Item.swiftの編集

まずは先ほどのItem.swiftを編集してSwiftDataに保存するデータを定義します

Item.swift(変更後)
import Foundation
import SwiftData

@Model
final class Item {
    var id: UUID
    var taskName: String
    var priority: String
    var timestamp: Date
    init(id: UUID, taskName: String, priority: String, timestamp: Date) {
        self.id = id
        self.taskName = taskName
        self.priority = priority
        self.timestamp = timestamp
    }
}

Item.swiftの中身は以下です

  • idをUUIDで宣言
    → UUIDはランダムで固有のIDを作成してくれます
  • taskName
    → todoアプリに保存する項目名です
  • priority
    → todo項目の重要度を保存するための変数です。重要度「低」, 「中」, 「高」で保存しようと思います
  • timestamp 作成時間とか後で役立ちそうなので宣言しておきます

init()のところはxcodeで「init」と入力すると初期化のコードが自動で生成されるので超便利です
こんな感じで

SwiftDataに保存していくデータはざっとこんな感じにしておきます
後で必要な項目があればここに追記するだけなので

AddTodoView.swift

todoアプリでボタンを押すと下から出てくるシートの画面を作成します

「AddTodoView」というファイルを作成します

(面倒なのでoption+ドラッグでContentViewファイルをコピーします)

AddTodoView.swift
import SwiftUI
import SwiftData

struct AddTodoView: View {
    // 変数とか宣言する所
    @FocusState private var isTextFieldFocused: Bool
    @Environment(\.dismiss) private var dismiss
    @State private var taskName: String = ""
    @State private var priority: String = "低"
    @State private var showAlert = false
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    
    let priorities = ["低","中","高"]
    // 画面作成する所
    var body: some View {
        NavigationStack {
            VStack {
                TextField("todo here", text: $taskName)
                    .padding()
                    .background(Color(.systemGray6))
                    .cornerRadius(10)
                    .padding(.horizontal)
                    .shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 5)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        isTextFieldFocused = true
                    }
                    .focused($isTextFieldFocused)
                Picker("優先度", selection: $priority) {
                    ForEach (priorities, id: \.self) {
                        Text($0)
                    }
                }
                .pickerStyle(SegmentedPickerStyle())
                .frame(height: 50)
                .shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 5)
                
                HStack {
                    Button(action: addItem) {
                        HStack {
                            Image(systemName: "checkmark.circle.fill")
                            Text("Save")
                        }
                        .fontWeight(.medium)
                        .padding(.vertical, 10)
                        .padding(.horizontal, 20)
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                        .shadow(color: Color.black.opacity(0.4), radius: 5, x: 0, y: 5)
                    }
                    .frame(width: 150)
                }
                
                Spacer()
            }
            .padding()
            .navigationTitle("New Todo")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        dismiss()
                    } label: {
                        Label("Back", systemImage: "xmark.app.fill")
                           
                    }
                }
            }
            .alert(isPresented: $showAlert) {
                Alert(
                    title: Text("エラー"),
                    message: Text("タスク名を入力してください"),
                    dismissButton: .default(Text("OK"))
                )
            }
        }
    }
    // 関数宣言する所
    private func addItem() {
        if taskName.isEmpty {
            showAlert = true
        } else {
            withAnimation {
                let newItem = Item(id: UUID(), taskName: taskName, priority: priority, timestamp: Date())
                modelContext.insert(newItem)
                dismiss()
            }
        }
    }
}
// プレビュー画面
#Preview {
    AddTodoView()
        .modelContainer(for: Item.self, inMemory: true)
}

コードが長いのでポイントだけ解説していきます

テキストフィールドのどこかをタップすれば入力モード

          .onTapGesture {
                        isTextFieldFocused = true
                    }
                    .focused($isTextFieldFocused)

これがないとテキストフィールドの左端を狙ってタップしないといけなくて面倒です

pickerにSegmentedPickerStyle適用

ピッカーのところです

(picker使用)
                Picker("優先度", selection: $priority) {
                    ForEach (priorities, id: \.self) {
                        Text($0)
                    }
                }
                .pickerStyle(SegmentedPickerStyle())
                .frame(height: 50)
                .shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 5)
pickerのポイント
.pickerStyle(SegmentedPickerStyle())

以上のところプルダウンではないようにしています

個人的にプルダウン開いて選択するのは手間なので
項目が少ない時はユーザーにとってもこれが良いと思います

ばつボタンを押して前の画面に戻る処理

    @Environment(\.dismiss) private var dismiss
~~~ 省略 ~~~
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        dismiss()
                    } label: {
                        Label("Back", systemImage: "xmark.app.fill")
                           
                    }
                }
            }

dismissという変数を宣言してボタンのところで実行します
xcodeのバージョンが古い人はコードが異なると思います

ボタンを押してSwiftDataに保存

    private func addItem() {
        // テキストフィールドが空ならアラート表示
        if taskName.isEmpty {
            showAlert = true
        } else {
            withAnimation {
                // newItem変数に生成したItemクラスを格納
                // taskName => テキストフィールド, priority => picker 
                let newItem = Item(id: UUID(), taskName: taskName, priority: priority, timestamp: Date())
                // 格納したnewItemを保存してdismissでシート閉じる
                modelContext.insert(newItem)
                dismiss()
            }
        }
    }

データを保存するときの処理が個人的に好きです
modelContextという変数を宣言してinsertするだけ…楽ですね

@Environment(\.modelContext) private var modelContext
~~~ 省略 ~~~
modelContext.insert(newItem)

これでAddTodoView完成です

ContentView作成

ここからはSwiftDataからデータ取得して表示する画面を作っていきます

ContentView.swift
import SwiftData
import SwiftUI

struct ContentView: View {
    @State private var showAddView = false
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]

    var body: some View {
        NavigationStack {
            VStack {
                if items.isEmpty {
                    Spacer()
                    Text("No Data")
                        .foregroundColor(.gray)
                        .font(.title)
                    Spacer()
                } else {
                    List {
                        ForEach(items.sorted(by: { $0.timestamp < $1.timestamp })) { item in
                            HStack {
                                Image(systemName: "checkmark.circle.fill")
                                    .foregroundColor(
                                        item.priority == "高" ? .red :
                                            item.priority == "中" ? .yellow :
                                            item.priority == "低" ? .green :
                                            .gray
                                    )
                                Text(item.taskName)
                            }
                            .swipeActions {
                                Button(role: .destructive) {
                                    deleteItem(item)
                                } label: {
                                    Label("Delete", systemImage: "trash")
                                }
                            }
                        }
                    }
                }
            }
            .navigationTitle("Todo")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showAddView.toggle()
                    } label: {
                        Label("Add Task", systemImage: "plus.app")
                    }
                }
            }
            .sheet(isPresented: $showAddView) {
                AddTodoView()
            }
        }
    }
    
    // アイテム削除処理
    private func deleteItem(_ item: Item) {
        modelContext.delete(item)  // contextを使ってItemを削除
        try? modelContext.save()   // 保存
    }
}

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

ここもポイントだけ解説します

データを並べる

@Query private var items: [Item]
~~~ 省略 ~~~
List {
    ForEach(items.sorted(by: { $0.timestamp < $1.timestamp })) { item in
         HStack {

Item.swiftで作成したItemクラスをitemsとして宣言します

その後ForEachでitemsを展開しつつtimestamp変数で作成時間で並び替えています

todo項目の重要度によって色を変える

 Image(systemName: "checkmark.circle.fill")
                                    .foregroundColor(
                                        item.priority == "高" ? .red :
                                            item.priority == "中" ? .yellow :
                                            item.priority == "低" ? .green :
                                            .gray
                                    )

入力したデータをスワイプで削除する

 .swipeActions {
    // ボタンの目的の説明を提供。データの削除を実行することを示す
    Button(role: .destructive) {
        deleteItem(item)
            } label: {
                Label("Delete", systemImage: "trash")
                }
            }

HStackに「.swipeActions」を設定します

ボタンロールは、ボタンの目的の説明を提供します。たとえば、destructive ロールは、ボタンがユーザーデータの削除などの破壊的なアクションを実行することを示します。

ボタンを押すとシートを表示する

@State private var showAddView = false
~~~ 省略 ~~~
.toolbar {
    ToolbarItem(placement: .topBarTrailing) {
        Button {
            showAddView.toggle()
            } label: {
                Label("Add Task", systemImage: "plus.app")
                }
            }
        }
        .sheet(isPresented: $showAddView) {
                AddTodoView()
            }

ボタンを押すとshowAddview変数とtoggleで反転して
.sheetのところでAddTodoViewを表示

SwiftDataのデータを削除

    private func deleteItem(_ item: Item) {
        modelContext.delete(item)
        // 変更した項目を保存
        try? modelContext.save()
    }

これでtodoアプリの完成です

完成品の確認

一度実機にビルドしてみましょう
xcodeはすぐに実機ビルドできるので便利です

コードの抜けがあったらすみません…笑

ここから色々アレンジをして面白いアイデアを織り込むのもありかもです

Discussion