🕌
アプリ作成日誌#1 すぐに消したくなるタスク管理アプリ
初心者エンジニアのyoshiです。
この度、タスク管理を少しでも楽しくしたいという思いから、新感覚のToDoアプリ「ウェザードゥ」を開発しました。このアプリは、登録したタスクの数に応じて画面の天気が変化するという、ユニークな特徴を持っています。
この記事では、SwiftUIを使った開発過程で、特に私が「これは難しい…」と感じた3つの課題と、それをどのように乗り越えたかについて、備忘録も兼ねて共有したいと思います。
開発環境
- macOS
- Xcode
- Swift
- SwiftUI
1:Referencing instance method 'firstIndex(of:)' ... requires that 'Task' conform to 'Equatable'
私が定義したTaskという独自のデータ構造(struct)に、同値性を比較するためのルールが定義されていなかったことが原因です。
これを解決するために、TaskモデルにEquatableプロトコルを準拠させ、ユニークなidプロパティが同じであれば「同じタスク」と見なす、というルールを明記しました。
// Task.swift
struct Task: Identifiable, Codable, Equatable {
let id: UUID
var title: String
// ... init ...
// Equatableに準拠するためのルールを定義
// lhs(左辺)とrhs(右辺)のidが同じならtrueを返す
static func == (lhs: Task, rhs: Task) -> Bool {
lhs.id == rhs.id
}
}
2 :アニメーションの作り方
アニメーションの作り方、今回の場合は天気をどのようにつくるかよくわからなかったので
chatAIに相談。
難しい雨の描写については、
TimelineView: 一定間隔でビューを更新するためのトリガーを生成してくれます。これを使うことで、アニメーションを滑らかに動かす「時間」の流れを作りました。
Canvas: 描画専用の領域を提供してくれるビューです。TimelineViewが更新されるたびに、Canvas内で雨粒一つ一つの座標を再計算して描画し直す、という処理を実装しました。
// RainyAnimationView.swift
struct RainyAnimationView: View {
@State private var raindrops: [Raindrop] = []
var body: some View {
// .animationスケジュールで、画面の更新頻度に合わせてビューを再描画
TimelineView(.animation) { timeline in
// 描画領域
Canvas { context, size in
// 全ての雨粒を描画
for drop in raindrops {
// ... 描画処理 ...
}
}
.onChange(of: timeline.date) { _ in
// timelineが更新されるたびに雨粒の位置を更新
updateRaindrops()
}
}
.onAppear {
// 最初に雨粒を生成
createInitialRaindrops()
}
}
private func updateRaindrops() {
// 各雨粒のy座標を下にずらす処理
}
}
もっとこうすればいいというコードの書き方があれば教えてください。
//
// ContentView.swift
// tenki
//
// Created by y s on 2025/08/14.
//
import SwiftUI
struct ContentView: View {
@State private var tasks: [Task] = []
@State private var isShowingAddTaskView = false
// タスク編集用のアラート関連の状態変数
@State private var taskToEdit: Task?
@State private var newTitle: String = ""
@State private var isShowingEditAlert = false
var body: some View {
ZStack{
if tasks.count <= 3 {
SunnyAnimationView()
} else if tasks.count <= 8 {
RainyAnimationView()
} else {
StormyAnimationView()
}
VStack {
Text("ToDoリスト")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.top, 60)
// タスクが1つ以上ある場合のみリストを表示
if !tasks.isEmpty {
List{
ForEach(tasks) { task in
HStack{
Text(task.title)
.fontWeight(.bold)
.font(.title3)
.foregroundColor(.white)
.onTapGesture {
taskToEdit = task
newTitle = task.title
isShowingEditAlert = true
}
Spacer()
Image(systemName: "circle")
.font(.title2)
.foregroundColor(.white)
.onTapGesture {
deleteTask(task: task)
}
}
.listRowBackground(Color.clear)
}
}
// List全体の背景を半透明にする
.frame(height: calculateListHeight()) // タスク数に応じて高さを動的に変更
.scrollContentBackground(.hidden)
.background(Color.black.opacity(0.35))
.cornerRadius(15)
.padding(.horizontal)
}
Spacer() // リストが短い場合にコンテンツが中央に寄らないようにする
}
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
isShowingAddTaskView = true
}) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 80))
.foregroundColor(.accentColor)
.shadow(radius: 5)
}
.padding()
}
}
}
.ignoresSafeArea()
// 画面が表示された時にデータを読み込む
.onAppear(perform: loadTasks)
// タスク追加用のシート
.sheet(isPresented: $isShowingAddTaskView) {
AddTaskView(tasks: $tasks)
}
// タスク編集用のアラート
.alert("タスクを編集", isPresented: $isShowingEditAlert) {
TextField("タスク名", text: $newTitle)
Button("保存") {
updateTask()
}
Button("キャンセル", role: .cancel) {}
} message: {
Text("新しいタスク名を入力してください。")
}
}
// MARK: - データ操作の関数
private func deleteTask(task: Task) {
// アニメーション付きで削除
withAnimation {
tasks.removeAll { $0.id == task.id }
}
saveTasks()
}
/// 既存のタスクのタイトルを更新する
private func updateTask() {
guard let taskToEdit = taskToEdit,
let index = tasks.firstIndex(of: taskToEdit) else { return }
tasks[index].title = newTitle
saveTasks()
}
// UserDefaultsにタスクを保存する
private func saveTasks() {
if let encoded = try? JSONEncoder().encode(tasks) {
UserDefaults.standard.set(encoded, forKey: "tasks")
}
}
// UserDefaultsからタスクを読み込む
private func loadTasks() {
if let data = UserDefaults.standard.data(forKey: "tasks") {
if let decoded = try? JSONDecoder().decode([Task].self, from: data) {
tasks = decoded
return
}
}
// データがなければ空の配列
tasks = []
}
private func calculateListHeight() -> CGFloat {
let rowHeight: CGFloat = 70 // 1行あたりの高さ(フォントサイズ等に合わせて調整)
// Listが内部に持つ上下の余白のための追加分
let maxListHeight = UIScreen.main.bounds.height * 0.6 // 画面の高さの60%を上限
return min(CGFloat(tasks.count) * rowHeight, maxListHeight)
}
}
// MARK: - Preview
#Preview {
ContentView()
}
Discussion