🕌

アプリ作成日誌#1 すぐに消したくなるタスク管理アプリ

に公開

初心者エンジニアのyoshiです。
この度、タスク管理を少しでも楽しくしたいという思いから、新感覚のToDoアプリ「ウェザードゥ」を開発しました。このアプリは、登録したタスクの数に応じて画面の天気が変化するという、ユニークな特徴を持っています。

IMG_0622.png

この記事では、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