📱

AI小説でSwiftUIのTodoアプリを作った話

に公開

こんにちは!
生成AIにSwiftUIの小説を書いてもらうという、
少し変わった方法でSwiftUIの学習に挑戦してみた体験を共有します。
きっかけはこちらのPodcast 深津貴之のGUILD TALK ゲスト けんすうさんを聞いたから。
https://open.spotify.com/episode/4o3uxbK92SvyTUXJi7Baaz?si=Q0MaHqfkRbiO0mzisAb0zA&nd=1&dlsi=2be09bfd641d422c

自分の現在のSwiftUI学習方法は大きく分けて2つ、
・Apple公式のチュートリアルを進める
・海外SwiftUI Youtuberの動画を見て学習する

この2つに続く3つ目の学習方法になるかもしれないと思って試しました。
小説執筆はGemini2.5 pro先生にお願いします。
下記がプロンプトです。
SwiftUIのTodoアプリ作成を元に20000字の小説を書いて
技術の習得を目標にしている。
1行読んだら面白くて次が読みたくなるように。

下記が実際に生成された小説の一章です。

タイトル:灰色のディスプレイに灯る光
第一章:カーソルが点滅する夜
午前一時。星野カイトの部屋は、MacBook Airから放たれる冷たい光だけが支配していた。壁には貼りっぱなしの映画ポスター、床には読みかけのデザイン雑誌が散乱し、彼の心の混沌を映し出しているかのようだ。カイトは二十八歳。Web制作会社のアシスタントデザイナーとして三年が過ぎたが、最近、自分がただ歯車の一部として摩耗しているだけではないか、という疑念が胸の中で黒い染みのように広がっていた。
「…これで、いいのか?」
吐き出した息が、白く濁ってモニターに反射する。クライアントの要望と上司の指示の間で、デザインデータの色味を微調整する毎日。創造性という言葉は、入社時に抱いていた輝きを失い、ただの作業指示に成り下がっていた。何か、自分の意志で、ゼロから形あるものを生み出したい。そんな渇望が、息苦しさとなってカイトの喉元までせり上がってくる。
その渇望が、彼を未知の領域へと誘った。プログラミング。特に、毎日手にするiPhoneのアプリを自分の手で作れたなら。漠然とした憧れが、具体的な形を取り始めたのは数週間前。ネットサーフィン中に偶然見つけた、あるブログ記事がきっかけだった。
『初心者でも大丈夫!SwiftUIで作る、おしゃれなTodoアプリ』
SwiftUI。Appleが提唱する、宣言的で直感的なUIフレームワーク。記事には、洗練されたデザインのTodoアプリのスクリーンショットと、ステップ・バイ・ステップの解説が並んでいた。Todoアプリ。カイト自身、日々のタスク管理に苦労していた。散らばる付箋、忘れ去られるメモ。もし、自分でその問題を解決できるツールを作れたなら? それは、単なるスキルアップ以上の意味を持つかもしれない。
「SwiftUI…」
呟きながら、震える指でXcodeを起動する。数日前にインストールしたばかりの開発ツール。アイコンをクリックするだけで、胸が高鳴る。複雑そうなインターフェースが開き、新しいプロジェクトを作成する。「App」を選び、「Interface」に「SwiftUI」を選択する瞬間、まるで異世界への扉を開けるような感覚に襲われた。
プロジェクト名は「MyFirstTodo」。安直だが、今のカイトにはそれが精一杯だった。Enterキーを押すと、画面中央にコードが表示される。

import SwiftUI

@main
struct MyFirstTodoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

意味は分からない。アットマーク、コロン、波括弧。未知の記号が並ぶ。だが、右側のプレビューエリアには、すでに「Hello, world!」と表示されたiPhoneの画面が見えた。
「…え?」
まだ何もしていない。なのに、もうアプリの原型がここにある。これがSwiftUIなのか? カイトはゴクリと喉を鳴らした。ブログ記事の指示に従い、ContentView.swiftを開く。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}

Text("Hello, world!")。これがあの文字を表示しているのか。記事はまず、このテキストを「今日のタスク」に変え、さらにいくつかのテキストを追加するように指示していた。恐る恐る、キーボードを叩く。

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) { // 垂直に並べるコンテナ
            Text("今日のタスク")
                .font(.largeTitle) // フォントサイズを大きく
                .padding(.bottom, 1) // 下に少し余白

            Text("朝:デザイン案作成")
            Text("昼:クライアントMTG")
            Text("夕:SwiftUIの勉強 ← イマココ") // 自分へのツッコミ

            Spacer() // 残りのスペースを埋める
        }
        .padding() // 全体に余白
    }
}

VStackは要素を縦に並べる。Textを追加し、.fontや.paddingといった「モディファイア」で見た目を調整する。まるで、デザインツールでレイヤーを重ね、プロパティを設定していく感覚に近いかもしれない。タイプミスに怯えながら打ち終えると、プレビューが瞬時に更新された。
「おお…!」
思わず声が漏れた。自分が書いたコードが、リアルタイムで画面に反映される。テキストが縦に並び、フォントサイズや余白も意図した通りになっている。「イマココ」の文字に、少しだけ笑みがこぼれた。これは、面白いかもしれない。
時刻は午前一時半を回っていた。外の喧騒は遠のき、部屋にはキーボードを叩く音と、カイト自身の心臓の音だけが響いている。まだ、ほんの入口に立っただけだ。それでも、灰色のディスプレイの中に、確かな光が灯ったように感じられた。この光を追いかければ、何かが見つかるかもしれない。カイトは、冷えたコーヒーを一口飲み、次のステップへと進む決意を固めた。カーソルの点滅が、まるで彼を急かすように、速まっている気がした。

以上が一章です!
自分は普通に小説としても面白かったのと、普段格闘しているSwiftUIがそれっぽい小説として生成されて笑ってしまいました。
また現在私は4章までの実装ですがストーリーにそって実際に動くTodoアプリを作成できています。

まとめ
小説を使ったストーリー仕立てのコーディングの良さ
従来のチュートリアルと比べて、小説形式で学ぶことで以下のようなメリットを感じました。

・文脈が理解しやすい: 単なるステップバイステップの手順ではなく、「なぜこのコードを書くのか」という背景が物語として描かれているので、理解が深まる
・動画より実際に動くアプリを作るのが早い 動画を元にプログラミングすると20分ほどの動画でも一日がかりになることがあるので、動画学習より実際に動くアプリを作るスピードが格段に早い(気がします)
・退屈さが減る: 技術書を読むときの「次は何ページまで」という重圧感がなく、ストーリーを追うように自然に進められる

自分はこの学習法かなり「アリ」な気がしました。
もちろん小説の中に出てくるコードがハルシネーションを起こしたりしているのでエラーについて個別に調べて修正もしています。

小説全文が気になった方のために下記に小説全文を掲載します。

タイトル:灰色のディスプレイに灯る光
第一章:カーソルが点滅する夜
午前一時。星野カイトの部屋は、MacBook Airから放たれる冷たい光だけが支配していた。壁には貼りっぱなしの映画ポスター、床には読みかけのデザイン雑誌が散乱し、彼の心の混沌を映し出しているかのようだ。カイトは二十八歳。Web制作会社のアシスタントデザイナーとして三年が過ぎたが、最近、自分がただ歯車の一部として摩耗しているだけではないか、という疑念が胸の中で黒い染みのように広がっていた。
「…これで、いいのか?」
吐き出した息が、白く濁ってモニターに反射する。クライアントの要望と上司の指示の間で、デザインデータの色味を微調整する毎日。創造性という言葉は、入社時に抱いていた輝きを失い、ただの作業指示に成り下がっていた。何か、自分の意志で、ゼロから形あるものを生み出したい。そんな渇望が、息苦しさとなってカイトの喉元までせり上がってくる。
その渇望が、彼を未知の領域へと誘った。プログラミング。特に、毎日手にするiPhoneのアプリを自分の手で作れたなら。漠然とした憧れが、具体的な形を取り始めたのは数週間前。ネットサーフィン中に偶然見つけた、あるブログ記事がきっかけだった。
『初心者でも大丈夫!SwiftUIで作る、おしゃれなTodoアプリ』
SwiftUI。Appleが提唱する、宣言的で直感的なUIフレームワーク。記事には、洗練されたデザインのTodoアプリのスクリーンショットと、ステップ・バイ・ステップの解説が並んでいた。Todoアプリ。カイト自身、日々のタスク管理に苦労していた。散らばる付箋、忘れ去られるメモ。もし、自分でその問題を解決できるツールを作れたなら? それは、単なるスキルアップ以上の意味を持つかもしれない。
「SwiftUI…」
呟きながら、震える指でXcodeを起動する。数日前にインストールしたばかりの開発ツール。アイコンをクリックするだけで、胸が高鳴る。複雑そうなインターフェースが開き、新しいプロジェクトを作成する。「App」を選び、「Interface」に「SwiftUI」を選択する瞬間、まるで異世界への扉を開けるような感覚に襲われた。
プロジェクト名は「MyFirstTodo」。安直だが、今のカイトにはそれが精一杯だった。Enterキーを押すと、画面中央にコードが表示される。

import SwiftUI

@main
struct MyFirstTodoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

意味は分からない。アットマーク、コロン、波括弧。未知の記号が並ぶ。だが、右側のプレビューエリアには、すでに「Hello, world!」と表示されたiPhoneの画面が見えた。
「…え?」
まだ何もしていない。なのに、もうアプリの原型がここにある。これがSwiftUIなのか? カイトはゴクリと喉を鳴らした。ブログ記事の指示に従い、ContentView.swiftを開く。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}

Text("Hello, world!")。これがあの文字を表示しているのか。記事はまず、このテキストを「今日のタスク」に変え、さらにいくつかのテキストを追加するように指示していた。恐る恐る、キーボードを叩く。

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) { // 垂直に並べるコンテナ
            Text("今日のタスク")
                .font(.largeTitle) // フォントサイズを大きく
                .padding(.bottom, 1) // 下に少し余白

            Text("朝:デザイン案作成")
            Text("昼:クライアントMTG")
            Text("夕:SwiftUIの勉強 ← イマココ") // 自分へのツッコミ

            Spacer() // 残りのスペースを埋める
        }
        .padding() // 全体に余白
    }
}

VStackは要素を縦に並べる。Textを追加し、.fontや.paddingといった「モディファイア」で見た目を調整する。まるで、デザインツールでレイヤーを重ね、プロパティを設定していく感覚に近いかもしれない。タイプミスに怯えながら打ち終えると、プレビューが瞬時に更新された。
「おお…!」
思わず声が漏れた。自分が書いたコードが、リアルタイムで画面に反映される。テキストが縦に並び、フォントサイズや余白も意図した通りになっている。「イマココ」の文字に、少しだけ笑みがこぼれた。これは、面白いかもしれない。
時刻は午前一時半を回っていた。外の喧騒は遠のき、部屋にはキーボードを叩く音と、カイト自身の心臓の音だけが響いている。まだ、ほんの入口に立っただけだ。それでも、灰色のディスプレイの中に、確かな光が灯ったように感じられた。この光を追いかければ、何かが見つかるかもしれない。カイトは、冷えたコーヒーを一口飲み、次のステップへと進む決意を固めた。カーソルの点滅が、まるで彼を急かすように、速まっている気がした。
第二章:データの器、心の形
「Hello, world!」の感動から一夜明け、カイトは再びMacBookの前に座っていた。昨日の興奮がまだ冷めやらぬ中、ブログ記事の続きを読む。次は、実際にTodoタスクをデータとして扱い、それをリスト表示する段階だ。静的なテキストを並べるだけでは、Todoアプリとは言えない。
記事は「データモデルの作成」を指示していた。タスク一つ一つを表すための「設計図」を作るのだという。新しいSwiftファイルを作成し、「Task.swift」と名付ける。

import Foundation

struct Task: Identifiable { // Identifiableプロトコルに準拠
    let id = UUID() // 各タスクに固有のIDを自動生成
    var title: String // タスクの内容 (変更可能なので var)
    var isCompleted: Bool // 完了したかどうか (変更可能なので var)
}

struct Task。これがタスクの設計図。「構造体」というらしい。Identifiableという呪文のような言葉は、リストで各項目を区別するために必要だと書かれている。id = UUID()は、ユニークなIDを自動で割り振ってくれる便利なやつだ。titleはタスク名、isCompletedは完了状態。シンプルだが、Todoアプリの核となる情報だ。
次に、この設計図を使って、具体的なタスクデータ(サンプル)を作る。ContentView.swiftに戻り、ContentView構造体の中に、タスクの配列を保持するプロパティを追加する。
```swift
struct ContentView: View {
    // @State をつけて、このデータが変更されたらUIを更新するように指示
    @State private var tasks: [Task] = [
        Task(title: "朝:デザイン案のラフ作成", isCompleted: true),
        Task(title: "昼:クライアントとの打ち合わせ", isCompleted: false),
        Task(title: "15時:SwiftUIチュートリアルを読む", isCompleted: false),
        Task(title: "夜:ポートフォリオサイトの修正", isCompleted: false),
        Task(title: "寝る前:明日の計画を立てる", isCompleted: false)
    ]

    var body: some View {
        // ... (VStackの中身は後で書き換える)
    }
}

@State private var tasks: [Task]。またアットマークだ。@State。記事の説明によれば、これは「状態変数」を宣言するためのもので、このtasks配列に変更(追加、削除、更新)があった場合、SwiftUIがそれを検知して、関連するUI部分を自動的に再描画してくれる、という魔法のような仕組みらしい。privateは、このデータがContentViewの中だけで使われることを示している。
カイトは、サンプルデータを打ち込みながら、自分の日常を重ね合わせていた。「デザイン案」「クライアント」「ポートフォリオ」… まるで自分のためのリストだ。isCompleted: trueとなっている項目を見て、少しだけ達成感を覚える。
データの準備はできた。いよいよ、これをリストとして表示する。昨日のVStackとTextの羅列を、ListとForEachを使って書き換える。

struct ContentView: View {
    @State private var tasks: [Task] = [ /* ... サンプルデータ ... */ ]

    var body: some View {
        NavigationView { // ナビゲーションバーを追加できるようにするため
            List {
                ForEach($tasks) { $task in // $tasksでBindingを渡し、$taskで各要素へのBindingを受け取る
                    HStack { // 横に並べる
                        Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") // 条件でアイコン切り替え
                            .onTapGesture { // アイコンをタップ可能に
                                task.isCompleted.toggle() // 完了状態を反転させる
                            }
                        Text(task.title)
                            .strikethrough(task.isCompleted, color: .gray) // 完了なら打ち消し線
                            .foregroundColor(task.isCompleted ? .gray : .primary) // 完了なら文字色をグレーに
                    }
                }
            }
            .navigationTitle("今日のタスク") // ナビゲーションバーにタイトル
        }
    }
}

コードが少し複雑になってきた。NavigationViewで全体を囲むと、画面上部にタイトルバーが表示される。Listの中にForEach。これはtasks配列の各要素 (task) に対して、波括弧の中の処理を繰り返す。ForEach(tasks)のようにを付けることで、配列そのものと、その要素への「Binding(バインディング)」を渡せるらしい。これにより、ForEachの中でtaskの値を変更すると、元のtasks配列にも反映される…?
HStackで、アイコンとテキストを横に並べる。Image(systemName: ...)は、システム提供のアイコンを表示する。task.isCompletedがtrueならチェックマーク付きの円、falseならただの円を表示する(三項演算子)。そして、.onTapGesture { ... }。これでアイコンがタップ可能になり、タップされたらtask.isCompleted.toggle()が実行される。.toggle()はBool値を反転させるメソッドだ。つまり、タップするたびに完了・未完了が切り替わるはず。
さらに、Text(task.title)の後ろには.strikethroughと.foregroundColorモディファイア。これもtask.isCompletedの値に応じて、打ち消し線を引いたり、文字色を変えたりする。
「これで…動くのか?」
半信半疑でプレビューを確認する。リストが表示された! 各行には円アイコンとタスク名が並んでいる。最初の「デザイン案のラフ作成」だけ、アイコンがチェックマーク付きで、打ち消し線が引かれ、文字がグレーになっている。サンプルデータ通りだ。
カイトは唾を飲み込み、二番目のタスク「クライアントとの打ち合わせ」の行にある、ただの円アイコンを、震える指でクリックした。
カチッ。
瞬間、円アイコンがチェックマーク付きに変わり、テキストに打ち消し線が引かれ、文字色がグレーになった!
「うわっ!?」
思わず椅子から飛び上がりそうになった。もう一度、同じアイコンをクリックする。元に戻る。別の未完了タスクのアイコンをクリックする。チェックが付く。
「動いてる…! データとUIが繋がってる!」
@StateとBinding、そしてForEach。それらの仕組みが連携し、ユーザーの操作に応じてデータが更新され、その結果が即座にUIに反映される。これがSwiftUIのリアクティブ(反応的)なプログラミングなのか。まるで、コードに命が吹き込まれたようだ。
カイトは夢中で、リストのチェックを付けたり外したりを繰り返した。単純な操作だが、自分が書いたコードがインタラクティブに動作する、その事実がとてつもない達成感を与えてくれた。データという「魂」が、UIという「体」を得て、ユーザーの意思に応える。プログラミングの深淵を、少しだけ垣間見た気がした。
ただ、この感動には一つ、大きな落とし穴があった。アプリを再起動したらどうなる…? まだ、データの永続化という壁が、彼の前に立ちはだかっていることを、この時のカイトは知る由もなかった。それでも今は、目の前で動くリストの小気味よい反応に、ただただ心を躍らせていた。灰色のディスプレイは、確実に彼の世界を照らし始めていた。
第三章:消えゆく記録、永続化への渇望
チェックマークを付けたり外したりする小気味よいインタラクションに、カイトはしばらく酔いしれていた。自分で書いたコードが、ユーザーの操作にリアルタイムで反応する。それは、デザインツールで静的な画面を作っていた時には決して味わえなかった感覚だった。
「よし、今日はここまでにして、明日また続きをやろう」
満足感と共にXcodeを終了し、MacBookを閉じた。達成感と心地よい疲労感に包まれ、カイトはその夜、久しぶりに深く眠ることができた。
翌日の昼休み。会社の自席で、こっそりと自分のMacBook Airを開き、昨日作ったMyFirstTodoプロジェクトを起動した。昨日チェックをつけたタスクの状態がどうなっているか、確認したかったのだ。
アプリがシミュレータで起動する。表示されたリストを見て、カイトは目を疑った。
「あれ…?」
すべてのタスクが、未完了の状態に戻っている。昨日、確かに「クライアントとの打ち合わせ」にチェックを付けたはずなのに、アイコンはただの円で、打ち消し線もない。まるで、昨日の操作がすべて幻だったかのように。
「なんで…? ああ、そうか…」
すぐに理由に思い至った。@Stateで宣言したtasks配列は、ContentViewの「状態」としてメモリ上に保持されているだけだ。アプリが終了すれば、そのメモリ上のデータは消えてしまう。次に起動したときは、またコードに書かれた初期値(サンプルデータ)から始まるのだ。
「これじゃ、Todoアプリとして使えないじゃないか…」
愕然とした。せっかくタスクを記録し、完了状態を管理しても、アプリを閉じるたびにリセットされてしまうのでは意味がない。データの「永続化」が必要だ。アプリを閉じても、次に開いたときに前回の状態が復元されるようにしなければ。
ブログ記事の続きを確認する。やはり、次のセクションは「データの永続化」だった。いくつかの方法が紹介されている。UserDefaults、Core Data、Realm、そして比較的新しいSwiftData。記事では、最も手軽な方法としてAppStorageを使ったやり方が紹介されていた。
AppStorage? UserDefaultsをSwiftUIから簡単に使うためのプロパティラッパーらしい。キーと値のペアで、シンプルなデータを永続的に保存する仕組みだという。
「これなら、なんとかできそうか…?」
複雑なデータベースはまだハードルが高い。まずはAppStorageで試してみよう。記事の指示に従い、ContentView.swiftを修正する。
まず、Task構造体をCodableプロトコルに準拠させる必要があった。UserDefaults(ひいてはAppStorage)は、データを保存する際に特定の形式(プロパティリストやJSON)に変換(エンコード)し、読み込む際に元のデータ構造に復元(デコード)する必要がある。Codableはその変換ルールを自動で提供してくれる便利なプロトコルだ。

import Foundation

// Codable を追加
struct Task: Identifiable, Codable {
    let id = UUID()
    var title: String
    var isCompleted: Bool
}
Use code with caution.
Swift
次に、ContentView@State@AppStorageに置き換える。
struct ContentView: View {
    // @State の代わりに @AppStorage を使う
    // "tasks" というキーで UserDefaults に保存
    // 保存するデータがない場合の初期値として空の配列 [] を指定
    @AppStorage("tasks") private var tasks: [Task] = [] // 初期値は空にする

    // サンプルデータは初期データとして一度だけ読み込むなどの処理が必要になるが、
    // まずは AppStorage の基本的な使い方を試すため、一旦削除するかコメントアウトする。
    // (※後で初期データ投入ロジックを追加する)

    var body: some View {
        NavigationView {
            List {
                // ForEach の部分は変更なし ($tasks を使う)
                ForEach($tasks) { $task in
                    HStack {
                        Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                            .onTapGesture {
                                task.isCompleted.toggle()
                                // ★注意:AppStorage の配列要素を直接変更しても
                                //   自動で保存されない場合がある。配列全体を再代入する必要があるかも。
                                //   (後で修正ポイントとなる)
                            }
                        Text(task.title)
                            .strikethrough(task.isCompleted, color: .gray)
                            .foregroundColor(task.isCompleted ? .gray : .primary)
                    }
                }
            }
            .navigationTitle("今日のタスク")
            .onAppear { // ビューが表示されたときに一度だけ実行
                // もし tasks が空なら、初期データ(サンプルデータ)を読み込む
                if tasks.isEmpty {
                    tasks = [
                        Task(title: "朝:デザイン案のラフ作成", isCompleted: true),
                        Task(title: "昼:クライアントとの打ち合わせ", isCompleted: false),
                        Task(title: "15時:SwiftUIチュートリアルを読む", isCompleted: false),
                        Task(title: "夜:ポートフォリオサイトの修正", isCompleted: false),
                        Task(title: "寝る前:明日の計画を立てる", isCompleted: false)
                    ]
                }
            }
        }
    }
}
// Task 配列を UserDefaults に保存・読み込み可能にするための拡張
// (AppStorage が直接 [Codable] をサポートしていない場合があるため)
extension Array: RawRepresentable where Element: Codable {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
              let result = try? JSONDecoder().decode([Element].self, from: data)
        else {
            return nil
        }
        self = result
    }

    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
              let result = String(data: data, encoding: .utf8)
        else {
            return "[]" // エンコード失敗時は空のJSON配列文字列を返す
        }
        return result
    }
}

@AppStorage("tasks") private var tasks: [Task] = []。これで、tasksという名前のキーでUserDefaultsにデータが保存され、アプリ起動時に自動で読み込まれる…はず。初期値は空の配列[]にしておき、.onAppearモディファイアを使って、ビューが最初に表示されたときにtasksが空だったらサンプルデータを読み込むようにした。これで、初回起動時だけサンプルデータが入り、次回以降は保存されたデータが使われる算段だ。
さらに、Arrayの拡張が必要だった。AppStorageは基本的な型(Int, String, Bool, Data, URL)しか直接サポートしていないため、[Task]のようなカスタム型の配列を保存するには、それをDataやStringに変換(エンコード/デコード)する処理を書く必要がある。RawRepresentableプロトコルとCodableを利用して、JSON文字列として保存・復元するようにした。(この部分はブログ記事からほぼコピー&ペーストだが、意味を理解しようと必死にコメントを読んだ)
「これで…今度こそ…」
祈るような気持ちで、シミュレータを再起動する。まず、初回起動(のつもり)。サンプルデータが表示された。いくつかのタスクにチェックを入れる。そして、アプリを一度終了させる。
心臓が早鐘を打つ。再び、アプリのアイコンをクリックする。
起動した画面には… チェックを入れた状態が、そのまま表示されていた!
「やった!保存されてる!」
カイトは思わずガッツポーズをした。消えないデータ。これだよ、これ! アプリがちゃんと「記憶」を持っている。これでようやく、実用的なツールへの道が開けた気がした。
しかし、ブログ記事には注意書きがあった。「AppStorageで配列の要素を直接変更した場合、変更が検知されず保存されないことがある」と。アイコンタップ時のtask.isCompleted.toggle()だけでは不十分かもしれない。
試してみると、やはり挙動が不安定なことがあった。チェックを付けても、アプリを再起動すると元に戻ってしまうことがある。記事の推奨通り、配列の要素を変更した後に、配列全体をtasksに再代入する処理を追加する必要がありそうだ。
// onTapGesture の中
task.isCompleted.toggle()
// 変更を確実に AppStorage に反映させるために配列全体を再代入
// (より効率的な方法もあるが、まずは確実に動く方法で)
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index] = task.wrappedValue // wrappedValue で Binding の中身を取得
}

Binding ($task) 経由で変更した後、その変更を含むtaskデータ(task.wrappedValueで取得)を使って、元のtasks配列の該当要素を更新し、配列全体を再代イリュージョンさせる。少し冗長に見えるが、これでAppStorageに変更が確実に通知されるはずだ。
修正して再度試すと、今度は安定して状態が保存されるようになった。
データの永続化。それは、アプリ開発における基本的ながら非常に重要なステップだ。その壁を一つ乗り越えたことで、カイトは大きな自信を得た。同時に、AppStorageの限界(大量のデータや複雑なリレーションには向かないこと)も理解し始めた。もっと高度なデータ管理方法も学ばなければならない。
だが今は、目の前の課題をクリアした喜びを噛み締めていた。消えゆく記録の問題を解決し、永続的な価値を持つアプリへの第一歩を踏み出したのだ。次は、新しいタスクを追加する機能だ。カイトの目は、すでに次のステップへと向けられていた。ディスプレイの光が、彼の未来を明るく照らし出すように感じられた。
第四章:空白からの創造、入力という対話
タスクリストが表示され、チェック状態も保存されるようになった。カイトのTodoアプリは、少しずつ形になってきている。しかし、まだ肝心な機能が欠けていた。新しいタスクを追加する機能だ。今のままでは、最初に用意したサンプルタスクしか扱えない。
「どうやって、ユーザーが新しいタスクを入力できるようにするんだろう?」
ブログ記事の次のセクションは「タスクの追加」。SwiftUIでユーザー入力を扱う方法が解説されていた。主に使うのはTextFieldとButtonだ。
記事に従い、まずはContentView.swiftに、新しいタスクのタイトルを入力するための@State変数を追加する。
```swift
struct ContentView: View {
    @AppStorage("tasks") private var tasks: [Task] = []
    @State private var newTaskTitle: String = "" // 新しいタスクのタイトルを保持する状態変数

    var body: some View {
        // ...
    }
}
Use code with caution.
Swift
@State private var newTaskTitle: String = ""。空の文字列で初期化された、新しい状態変数だ。ここに、ユーザーがTextFieldに入力した内容がリアルタイムで反映されることになる。
次に、Listの上に、TextFieldと「追加」ボタンをHStackで配置する。
struct ContentView: View {
    @AppStorage("tasks") private var tasks: [Task] = []
    @State private var newTaskTitle: String = ""

    var body: some View {
        NavigationView {
            VStack { // List の外側に VStack を追加して、入力欄とリストを縦に並べる
                HStack { // TextField と Button を横に並べる
                    TextField("新しいタスクを入力", text: $newTaskTitle) // $newTaskTitle で Binding
                        .textFieldStyle(.roundedBorder) // 入力欄に枠線をつける

                    Button("追加") {
                        // ボタンが押されたときの処理(後で記述)
                        addTask()
                    }
                    .disabled(newTaskTitle.isEmpty) // 入力が空ならボタンを無効化
                }
                .padding() // HStack の周りに余白

                List {
                    // ... (ForEach の部分は変更なし)
                }
            }
            .navigationTitle("今日のタスク")
            // .onAppear は VStack の中か NavigationView につける
            .onAppear {
                // ... (初期データ読み込み処理)
            }
        }
    }

    // 新しいタスクを追加するメソッド
    private func addTask() {
        guard !newTaskTitle.isEmpty else { return } // タイトルが空なら何もしない

        let newTask = Task(title: newTaskTitle, isCompleted: false) // 新しいTaskインスタンスを作成
        tasks.append(newTask) // tasks 配列の末尾に追加
        newTaskTitle = "" // 追加後、入力欄を空にする
    }
}

VStackで全体を囲み、上部に入力用のHStack、その下にListを配置する構成にした。HStackの中にはTextFieldとButton。
TextField("新しいタスクを入力", text: newTaskTitle)。第一引数はプレースホルダー(入力欄が空の時に表示される薄い文字)。text:引数にはnewTaskTitleを渡す。ここでも$マークが登場。TextFieldへの入力をnewTaskTitle状態変数に双方向にバインディングする、という意味だ。ユーザーが文字を入力するたびにnewTaskTitleが更新され、逆にコードでnewTaskTitleを変更すればTextFieldの表示も変わる。
Button("追加") { ... }。ボタンのラベルは「追加」。波括弧の中に、ボタンがタップされたときに実行される処理を書く。ここではaddTask()という別のメソッドを呼び出すようにした。.disabled(newTaskTitle.isEmpty)は、newTaskTitleが空文字列の場合、ボタンをグレーアウトして押せなくする。入力がないのにタスクを追加できないようにするためだ。
そして、addTask()メソッドの中身。まずguard !newTaskTitle.isEmpty else { return }で、タイトルが空の場合は処理を中断する(早期リターン)。タイトルが入力されていれば、let newTask = Task(title: newTaskTitle, isCompleted: false)で新しいTaskインスタンスを作成。tasks.append(newTask)で、それをtasks配列の末尾に追加する。最後にnewTaskTitle = ""で入力欄を空に戻す。
「これで…追加できるはず…!」
コードを打ち終え、プレビューまたはシミュレータで確認する。画面上部に、入力欄と「追加」ボタンが表示された。ボタンはまだグレーアウトしている。
入力欄をタップし、キーボードから文字を打ち込んでみる。「SwiftUIのドキュメントを読む」。一文字入力するたびに、「追加」ボタンが有効色に変わる。newTaskTitleが空でなくなったからだ。
入力し終え、「追加」ボタンをタップする。
…リストの一番下に、今入力した「SwiftUIのドキュメントを読む」という新しいタスクが、未完了の状態でスッと表示された!
「おおおおっ!!」
今度こそ、カイトは声を上げて喜んだ。空白の入力欄から、自分の手で新しいデータを生み出し、それがリストに反映されたのだ。これは、単なる表示や状態変更とは違う、「創造」の感覚だった。ユーザーとの対話(入力)を通じて、アプリが成長していく。
続けて、「部屋の掃除をする」「牛乳を買う」など、思いつくままにタスクを追加していく。入力し、ボタンを押すたびに、リストが下に伸びていく。まるで、自分の思考がそのままアプリに記録されていくようだ。
追加したタスクのチェックマークをタップすれば、ちゃんと完了状態になり、その状態もAppStorageによって保存される。アプリを再起動しても、追加したタスクもチェック状態も残っている。
「できた…! Todoアプリの基本機能が、これで揃った!」
カイトは、自分が作り上げた画面を感慨深く眺めた。シンプルな見た目だが、そこにはデータの定義、リスト表示、状態変更、永続化、そして新規追加という、アプリ開発の基本的な要素が詰まっている。ゼロからコードを書き始め、一つ一つ壁を乗り越えて、ここまでたどり着いたのだ。
もちろん、まだ改善点は山ほどある。タスクの削除機能がない。編集もできない。見た目ももっと洗練させたい。エラー処理も甘いだろう。
それでも、カイトの心は達成感で満たされていた。灰色のディスプレイに映る自作のTodoアプリは、彼にとって、単なるプログラムではなく、努力と成長の証そのものだった。技術を習得するという目標が、具体的な形となって目の前にある。
「次は、削除機能だな」
カイトは呟き、再びブログ記事に目を向けた。スワイプしてタスクを削除する、あのiOSらしい動きを実装するのだ。道のりはまだ続く。だが、今の彼には、どんな困難も乗り越えられるような気がしていた。コードを書く指先に、確かな力が宿り始めていた。
第五章:別れと再生、スワイプに込める意志
新しいタスクを追加できるようになったカイトのTodoアプリ。リストは彼の思考と共に伸びていく。しかし、増え続けるタスクの中に、もう不要になったものや、間違えて入力してしまったものも現れ始めた。
「この『ああああ』って入力したやつ、消したいんだけど…」
リストを眺めながら、カイトは呟いた。現状、タスクをリストから取り除く手段がない。アプリとして、これは致命的な欠陥だ。ブログ記事の次のステップは、まさに「タスクの削除機能」。iOSアプリではお馴染みの、リスト項目を左にスワイプして「削除」ボタンを表示させる、あの機能の実装だ。
記事によれば、SwiftUIのListには、このスワイプ削除機能を驚くほど簡単に追加できるモディファイアが用意されているという。.onDeleteだ。
ContentView.swiftを開き、Listの中のForEachに.onDeleteモディファイアを追加する。

struct ContentView: View {
    @AppStorage("tasks") private var tasks: [Task] = []
    @State private var newTaskTitle: String = ""

    var body: some View {
        NavigationView {
            VStack {
                // ... (入力部分の HStack)

                List {
                    ForEach($tasks) { $task in
                        HStack {
                            // ... (アイコンとテキスト)
                        }
                    }
                    .onDelete(perform: deleteTask) // ForEach に onDelete を追加
                }
            }
            .navigationTitle("今日のタスク")
            // ... (.onAppear)
        }
    }

    // ... (addTask メソッド)

    // タスクを削除するメソッド
    private func deleteTask(at offsets: IndexSet) {
        tasks.remove(atOffsets: offsets) // 指定されたインデックスの要素を削除
        // AppStorage は配列全体の変更を検知するので、これで永続化もされるはず
    }
}

変更点は、ForEach($tasks) { ... } の後ろに .onDelete(perform: deleteTask) を一行追加したこと。そして、deleteTask(at:) という新しいメソッドを定義したことだけだ。
onDeleteモディファイアは、リストの行がスワイプされ、削除操作が行われたときに、引数として渡されたメソッド(ここではdeleteTask)を呼び出す。その際、削除対象となる行のインデックス(位置情報)がIndexSetという型で渡される。
deleteTask(at offsets: IndexSet)メソッドの中身はさらにシンプルだ。tasks.remove(atOffsets: offsets)。これだけで、渡されたインデックスセットに該当する要素がtasks配列から削除される。@AppStorageで管理されているtasks配列が変更されるため、UIは自動的に更新され、変更後の配列がUserDefaultsに保存されるはずだ。
「たったこれだけで、あのスワイプ削除ができるのか…?」
あまりの簡単さに、カイトは半信半疑だった。シミュレータでアプリを実行し、リストの中の適当なタスク(例えば、あの「ああああ」)の行を、指で(あるいはマウスカーソルで)左にスワイプしてみる。
シュッ、という効果音とともに、行が左にずれ、右側に赤い「削除」ボタンが現れた!
「で、出た!」
思わず声が出る。iOSユーザーなら誰もが見慣れたあの光景が、自分のアプリで再現されたのだ。そして、その赤い「削除」ボタンをタップする。
…該当の行がリストから消えた!
「すごい…簡単すぎる…!」
カイトは興奮気味に、他の不要なタスクも次々とスワイプして削除していく。リストが整理され、必要なタスクだけが残っていく。これは快感だ。単に追加するだけでなく、不要なものを整理し、リストを最適な状態に保つ。これもまた、アプリの重要な機能だ。
別れ(削除)があるからこそ、新しいもの(追加)が活きる。リストは、さながら人生の縮図のようだ、などと少し感傷的な気分にもなった。
勢いに乗って、ブログ記事のさらに先、「タスクの編集機能」にも挑戦してみることにした。記事では、タスク行をタップしたら詳細画面に遷移し、そこでタイトルを編集できるようにする、という流れが紹介されていた。NavigationLinkと、新しいDetailViewを作る必要がある。
まず、DetailView.swiftという新しいSwiftUI Viewファイルを作成する。

import SwiftUI

struct DetailView: View {
    @Binding var task: Task // 変更可能な Binding として Task を受け取る

    var body: some View {
        VStack(alignment: .leading) {
            TextField("タスクタイトル", text: $task.title) // Binding でタイトルを TextField に接続
                .textFieldStyle(.roundedBorder)
                .padding()

            Toggle("完了", isOn: $task.isCompleted) // Toggle で完了状態を切り替え
                .padding()

            Spacer()
        }
        .navigationTitle("タスク編集")
    }
}

DetailViewは、@Binding var task: Taskとして、編集対象のタスクへのBindingを受け取る。bodyの中では、TextFieldを使ってタスクのタイトルを編集可能にし、Toggleスイッチで完了状態を切り替えられるようにした。どちらもtask.titleやtask.isCompletedのように$を付けてBindingしているので、ここでの変更は呼び出し元のContentViewのtasks配列にも反映されるはずだ。
次に、ContentView.swiftのListの中を修正する。HStack全体をNavigationLinkでラップし、遷移先としてDetailViewを指定する。

struct ContentView: View {
    // ... (プロパティ定義は変更なし)

    var body: some View {
        NavigationView {
            VStack {
                // ... (入力部分の HStack)

                List {
                    // $tasks の代わりに tasks を使う (ForEach で Binding は受け取る)
                    ForEach($tasks) { $task in
                        // HStack 全体を NavigationLink でラップ
                        NavigationLink(destination: DetailView(task: $task)) { // $task を DetailView に渡す
                            HStack {
                                Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                                    .onTapGesture {
                                        // タップでの完了切り替えは DetailView に任せるので削除しても良い
                                        // もしくは残しておいても動作はする
                                        task.isCompleted.toggle()
                                        // AppStorage への反映処理も必要に応じて残すか DetailView 側で行う
                                        if let index = tasks.firstIndex(where: { $0.id == task.id }) {
                                            tasks[index] = task.wrappedValue
                                        }
                                    }
                                Text(task.title)
                                    .strikethrough(task.isCompleted, color: .gray)
                                    .foregroundColor(task.isCompleted ? .gray : .primary)
                            }
                        } // NavigationLink の閉じ括弧
                    }
                    .onDelete(perform: deleteTask) // onDelete は ForEach につける
                } // List の閉じ括弧
            } // VStack の閉じ括弧
            .navigationTitle("今日のタスク")
            // ... (.onAppear)
        } // NavigationView の閉じ括弧
    }
    // ... (addTask, deleteTask メソッド)
}

ForEachの中で、各タスクを表示していたHStackをNavigationLink(destination: DetailView(task: task)) { ... }で囲んだ。これで、リストの各行がタップ可能なナビゲーションリンクになる。タップされると、対応するタスクへのBinding (task) を渡してDetailViewに遷移する。
修正後、アプリを実行する。リストの行をタップしてみる。
画面がスッと切り替わり、「タスク編集」というタイトルのDetailViewが表示された! 中央には、タップしたタスクのタイトルが入力されたTextFieldと、「完了」状態を示すToggleスイッチがある。
TextFieldでタイトルを修正してみる。「クライアントとの打ち合わせ」を「クライアントとの最終打ち合わせ」に書き換える。そして、左上の「< 今日のタスク」ボタンでリスト画面に戻る。
リストの表示が変わっている! ちゃんと「クライアントとの最終打ち合わせ」になっている。もう一度詳細画面に入り、今度はToggleスイッチをオン(完了)にして戻る。リスト上でも、そのタスクが完了状態(チェックマーク、打ち消し線、グレー文字)に変わった。
「編集も…できた!」
追加、削除、そして編集。これでCRUD(Create, Read, Update, Delete)と呼ばれる基本的なデータ操作が一通り実装できたことになる。カイトは、自分の手で作り上げたTodoアプリが、どんどん本格的なツールへと進化していくのを実感していた。
スワイプという直感的なジェスチャーで「別れ(削除)」を告げ、タップという意志ある行動で「再生(編集)」を促す。コードを通じて、ユーザーの意図をアプリに伝え、その結果を目に見える形で返す。この対話こそが、プログラミングの醍醐味なのかもしれない。
しかし、コードは確実に複雑になってきていた。ContentView.swiftファイルが、データ管理、リスト表示、入力、追加、削除、編集のロジックをすべて抱え込み、肥大化し始めている。
「このままじゃ、メンテナンスが大変になりそうだ…」
カイトは漠然とした不安を感じた。ブログ記事も、最後に「リファクタリングと今後の展望」という章を用意していた。コードを整理し、より良い構造にするための次なるステップ。技術習得の道は、まだ長く、そして奥深いことを、彼は予感していた。
第六章:構造の森へ、リファクタリングの地図
基本的な機能が揃い、カイトのTodoアプリは実用的なレベルに近づいてきた。彼は実際に自分の日々のタスクをこのアプリで管理し始めた。デザインの仕事、プログラミングの勉強、プライベートな用事。リストに追加し、完了したらチェックを入れ、不要になったらスワイプで削除する。自分で作ったツールが、自分の生活を少しだけ効率化してくれる。それは、これまでの受動的な仕事では得られなかった、能動的な喜びだった。
しかし、アプリを使い込み、コードを見返すうちに、新たな課題が見えてきた。ContentView.swiftがあまりにも多くの役割を担いすぎているのだ。
タスクデータの保持と永続化 (@AppStorage, tasks配列)
新しいタスク入力用の状態管理 (newTaskTitle)
タスク追加のロジック (addTaskメソッド)
タスク削除のロジック (deleteTaskメソッド)
メインのUIレイアウト (入力欄、リスト表示)
リストの各行の見た目とインタラクション (ForEachの中身)
詳細画面へのナビゲーション (NavigationLink)
これらすべてが一つのファイルに詰め込まれている。このまま機能を追加していけば、コードはますます複雑化し、修正や機能追加が困難になるだろう。「スパゲッティコード」という言葉が頭をよぎった。
「なんとかしないと…」
ブログ記事の最終章、「リファクタリングと今後の展望」を読む。そこには、「ViewModel」という概念の導入が提案されていた。UI(View)から、データとその操作ロジック(Modelやビジネスロジック)を分離するための設計パターンだという。
具体的には、ObservableObjectプロトコルに準拠したクラス(ViewModel)を作成し、そこに@Publishedプロパティとしてタスクデータを保持させ、タスクの追加や削除といった操作メソッドもそのクラス内に実装する。そして、ContentViewは、そのViewModelのインスタンスを@StateObjectまたは@ObservedObjectとして参照し、UIの表示とユーザー操作の伝達に専念する、という流れだ。
「ViewModel… ObservableObject… @Published… また新しい概念か」
カイトは少し身構えたが、コードを整理し、より良い構造にするためには避けて通れない道だと感じた。深呼吸し、新しいファイル「TaskViewModel.swift」を作成する。

import Foundation
import SwiftUI // AppStorage を使うため import

// ObservableObjectプロトコルに準拠したViewModelクラス
class TaskViewModel: ObservableObject {
    // @Published をつけることで、この配列への変更が自動的にViewに通知される
    // AppStorage を ViewModel 内で使う (Viewから永続化ロジックを分離)
    @AppStorage("tasks") private var tasksData: Data = Data() // UserDefaultsにはData型で保存

    // View に公開するタスク配列 (デコードして保持)
    @Published var tasks: [Task] = []

    // 新しいタスクのタイトルもViewModelで管理
    @Published var newTaskTitle: String = ""

    init() {
        // 初期化時にUserDefaultsからデータをデコードしてtasksに読み込む
        loadTasks()
    }

    // タスクを読み込むメソッド
    private func loadTasks() {
        guard let decodedTasks = try? JSONDecoder().decode([Task].self, from: tasksData) else {
            // デコード失敗、または初回起動時は空配列のまま or サンプルデータ投入
            // ここでは空のままにする
            self.tasks = []
            // 必要ならサンプルデータ投入ロジックをここに追加
            // if tasks.isEmpty { self.tasks = sampleTasks }
            return
        }
        self.tasks = decodedTasks
    }

    // タスクを保存するメソッド (変更があるたびに呼び出す)
    private func saveTasks() {
        guard let encodedTasks = try? JSONEncoder().encode(tasks) else {
            print("タスクのエンコードに失敗しました。")
            return
        }
        tasksData = encodedTasks
    }

    // タスクを追加するメソッド
    func addTask() {
        guard !newTaskTitle.isEmpty else { return }
        let newTask = Task(title: newTaskTitle, isCompleted: false)
        tasks.append(newTask) // @Published なので変更が通知される
        newTaskTitle = "" // 入力欄をクリア
        saveTasks() // 変更を保存
    }

    // タスクを削除するメソッド
    func deleteTask(at offsets: IndexSet) {
        tasks.remove(atOffsets: offsets)
        saveTasks() // 変更を保存
    }

    // タスクの完了状態を切り替えるメソッド (IDでタスクを特定)
    func toggleCompletion(for task: Task) {
        if let index = tasks.firstIndex(where: { $0.id == task.id }) {
            tasks[index].isCompleted.toggle()
            saveTasks() // 変更を保存
        }
    }

    // タスクのタイトルを更新するメソッド (IDでタスクを特定)
    func updateTaskTitle(for task: Task, newTitle: String) {
        if let index = tasks.firstIndex(where: { $0.id == task.id }) {
            tasks[index].title = newTitle
            saveTasks() // 変更を保存
        }
    }
}

// Task構造体は Codable のまま
struct Task: Identifiable, Codable {
    let id = UUID()
    var title: String
    var isCompleted: Bool
}

TaskViewModelクラスを作成し、ObservableObjectに準拠させた。@AppStorageを使ってUserDefaultsからデータを読み書きする部分もViewModel内に移動させた。ただし、AppStorageが直接カスタム配列を扱えない問題を回避するため、ここではData型で保存し、JSONEncoder/Decoderを使って手動でエンコード/デコードする方式に変更した。@Published var tasks: [Task]でViewに公開するタスク配列を宣言。タスクの追加、削除、完了状態の切り替え、タイトル更新といった操作を行うメソッド (addTask, deleteTask, toggleCompletion, updateTaskTitle) もすべてViewModel内に実装し、変更があるたびにsaveTasks()を呼んで永続化するようにした。newTaskTitleもViewModelで管理する。
次に、ContentViewとDetailViewを修正し、このTaskViewModelを使うように変更する。
まず、アプリのエントリーポイントであるMyFirstTodoApp.swiftでViewModelのインスタンスを生成し、ContentViewに渡す。

@main
struct MyFirstTodoApp: App {
    // @StateObjectでViewModelのインスタンスを生成・管理
    @StateObject private var viewModel = TaskViewModel()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(viewModel) // ContentViewとその子ViewでViewModelを共有
        }
    }
}
Use code with caution.
Swift
@StateObjectTaskViewModelのインスタンスを生成し、.environmentObject(viewModel)を使って、ContentView以下の全てのビュー階層でこのviewModelインスタンスを共有できるようにする。
そしてContentView.swiftを大幅に書き換える。
import SwiftUI

struct ContentView: View {
    // EnvironmentObject を使って共有された ViewModel を受け取る
    @EnvironmentObject var viewModel: TaskViewModel

    var body: some View {
        NavigationView {
            VStack {
                HStack {
                    // TextField の text は ViewModel の newTaskTitle にバインディング
                    TextField("新しいタスクを入力", text: $viewModel.newTaskTitle)
                        .textFieldStyle(.roundedBorder)

                    // Button の action は ViewModel の addTask メソッドを呼び出す
                    Button("追加") {
                        viewModel.addTask()
                    }
                    .disabled(viewModel.newTaskTitle.isEmpty)
                }
                .padding()

                List {
                    // ForEach は ViewModel の tasks 配列を直接参照 (Binding不要)
                    ForEach(viewModel.tasks) { task in
                        // NavigationLink の遷移先も ViewModel を渡す必要があれば渡す
                        // (DetailView も EnvironmentObject を使うなら不要)
                        NavigationLink(destination: DetailView(task: task)) {
                            TaskRow(task: task) // 各行の表示は TaskRow View に分離
                        }
                    }
                    // onDelete は ViewModel の deleteTask メソッドを呼び出す
                    .onDelete(perform: viewModel.deleteTask)
                }
            }
            .navigationTitle("今日のタスク")
            // .onAppear は不要になる (ViewModel の init で読み込み)
        }
        // EnvironmentObject を使う場合、DetailView に個別に渡す必要はない
        // .environmentObject(viewModel) // App 側で設定済み
    }
}

// リストの各行を表示するための専用View (リファクタリング)
struct TaskRow: View {
// EnvironmentObject を使って共有された ViewModel を受け取る
@EnvironmentObject var viewModel: TaskViewModel
let task: Task // 表示するタスクデータ

var body: some View {
    HStack {
        Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
            .onTapGesture {
                // アイコンタップで完了状態を切り替える (ViewModelのメソッド呼び出し)
                viewModel.toggleCompletion(for: task)
            }
        Text(task.title)
            .strikethrough(task.isCompleted, color: .gray)
            .foregroundColor(task.isCompleted ? .gray : .primary)
    }
}

}

ContentViewから@AppStorageや@State private var newTaskTitle、各種操作メソッドが消え、代わりに@EnvironmentObject var viewModel: TaskViewModelで共有されたViewModelを受け取るようになった。UI要素はViewModelのプロパティ($viewModel.newTaskTitle, viewModel.tasks)を参照し、アクションはViewModelのメソッド(viewModel.addTask(), viewModel.deleteTask()) を呼び出すだけになった。コードが大幅にスリムになり、役割分担が明確になった。さらに、リストの各行の表示をTaskRowという別のViewに切り出した。
最後にDetailView.swiftも修正する。

import SwiftUI

struct DetailView: View {
    // EnvironmentObject を使って共有された ViewModel を受け取る
    @EnvironmentObject var viewModel: TaskViewModel
    // Binding ではなく、編集対象のタスクデータを保持する @State 変数を用意
    @State var editingTask: Task

    init(task: Task) {
        // 受け取った task で editingTask を初期化
        _editingTask = State(initialValue: task)
    }

    var body: some View {
        VStack(alignment: .leading) {
            // TextField はローカルの editingTask.title にバインディング
            TextField("タスクタイトル", text: $editingTask.title)
                .textFieldStyle(.roundedBorder)
                .padding()
                .onChange(of: editingTask.title) { newTitle in
                    // タイトルが変更されたらViewModelのメソッドを呼び出して更新・保存
                    viewModel.updateTaskTitle(for: editingTask, newTitle: newTitle)
                }

            // Toggle もローカルの editingTask.isCompleted にバインディング
            Toggle("完了", isOn: $editingTask.isCompleted)
                .padding()
                .onChange(of: editingTask.isCompleted) { _ in
                    // 完了状態が変更されたらViewModelのメソッドを呼び出して更新・保存
                    viewModel.toggleCompletion(for: editingTask)
                    // Toggle の変更だけでは toggleCompletion で再度反転してしまう可能性があるので
                    // ViewModel側に更新メソッドを用意するか、ここで直接ViewModelのtasks配列を更新する方が良いかも
                    // 例: viewModel.updateTaskCompletion(for: editingTask, isCompleted: editingTask.isCompleted)
                }


            Spacer()
        }
        .navigationTitle("タスク編集")
    }
}

DetailViewも@EnvironmentObjectでViewModelを受け取るようにし、@Bindingの代わりに@State var editingTask: Taskを用意。TextFieldやToggleでの変更はまずこのローカルのeditingTaskに反映させ、.onChangeモディファイアを使って変更があったタイミングでViewModelの更新メソッド (updateTaskTitle, toggleCompletionなど、必要に応じて用意) を呼び出して、変更をViewModelと永続化ストレージに反映させる形にした。
「ふぅ… 大掛かりな修正だった…」
リファクタリングは、単にコードを書き換えるだけでなく、アプリの構造そのものを見直し、より堅牢で保守しやすい設計へと進化させるプロセスだ。ViewModelの導入により、Viewとロジックが分離され、それぞれの役割が明確になった。ContentViewはすっきりと読みやすくなり、TaskViewModelを見ればデータと操作に関する処理が一目瞭然だ。
カイトは、自分が単にコードを書けるようになっただけでなく、ソフトウェアの「設計」という、より高度な領域に足を踏み入れたことを感じていた。構造の森に迷い込みながらも、ViewModelという地図を手に入れたことで、道筋が見えてきた気がした。
もちろん、これが唯一の正解ではない。もっと洗練された状態管理ライブラリもあるし、テストの書き方も学ばなければならない。技術習得の旅は終わりがない。
それでも、カイトは自分の成長を確かに感じていた。灰色のディスプレイに最初に灯った小さな光は、今や彼の進むべき道を照らす、頼もしい灯台の光となっていた。彼は完成したTodoアプリを眺め、次は何を作ろうか、と思いを馳せる。その目は、確かな自信と、未来への期待に輝いていた。

Discussion