Projekt C · Teil 3 · Stufe 2 – SwiftUI-Einstieg

Habit-Tracker (Teil 3)

ca. 15 Minuten Lesezeit

Lernziel

In diesem Projektteil vollendest du den Habit-Tracker: mit einer Detailansicht, die die aktuelle Serie und die letzten sieben Tage zeigt, einer Navigation von der Liste dorthin, dem Bearbeiten und Löschen mit Rückfrage und einigen Details, die die App rund machen.

Ausgangslage

Nach Teil 2 kann man Gewohnheiten anlegen, abhaken und löschen. Was fehlt, ist der eigentliche Motivationsschub: zu sehen, wie gut es läuft. Dafür bekommt jede Gewohnheit eine Detailansicht mit:

  • der Streak, also wie viele Tage in Folge erledigt wurden,
  • einer Wochenübersicht der letzten sieben Tage,
  • einem großen Knopf zum Abhaken,
  • Möglichkeiten zum Umbenennen und Löschen.

Schritt 1: Die Streak berechnen

Die Streak lässt sich aus completions berechnen. Die Idee:

Text
Starte bei heute.
Wenn heute noch nicht erledigt ist, starte bei gestern
  (die Serie ist ja nicht gebrochen, der Tag läuft noch).
Solange der aktuelle Tag erledigt ist:
    zähle eins hoch
    gehe einen Tag zurück
Profi

Aufgabe

Ergänze Habit um eine berechnete Eigenschaft streak: Int, die genau diesen Ablauf umsetzt.

Tipp: Calendar.current.date(byAdding: .day, value: -1, to: day) liefert den Vortag, als Optional. Verwende kein !, sondern guard let oder if let (Lektion 1.9).

LösungErst selbst probieren!
Swift
var streak: Int {
    let calendar = Calendar.current
    var day = Date.now

    if !isDone(on: day), let yesterday = calendar.date(byAdding: .day, value: -1, to: day) {
        day = yesterday
    }

    var count = 0
    while isDone(on: day) {
        count += 1
        guard let previous = calendar.date(byAdding: .day, value: -1, to: day) else {
            break
        }
        day = previous
    }
    return count
}
  • if !isDone(on: day), let yesterday = ... kombiniert eine Bedingung mit Optional Binding (Lektion 1.9).
  • Die while-Schleife (Lektion 1.5) läuft rückwärts durch die Tage, bis ein Tag fehlt.
  • Das guard let ... else { break } ist eine reine Absicherung: date(byAdding:) liefert in der Praxis immer ein Datum.

Zum Testen lohnen sich Beispieldaten mit einer echten Serie. Ergänze ein Beispiel, das an den letzten fünf Tagen erledigt wurde:

Swift
extension Habit {
    static let withStreak = Habit(
        name: "Meditieren",
        icon: "brain.head.profile",
        color: .purple,
        completions: (0..<5).compactMap {
            Calendar.current.date(byAdding: .day, value: -$0, to: .now)
        }
    )
}

compactMap (Lektion 1.11) erzeugt für die Werte 0 bis 4 je ein Datum, heute bis vor vier Tagen, und lässt mögliche nil-Ergebnisse weg. Habit.withStreak.streak sollte 5 ergeben.

Schritt 2: Die Wochenübersicht

Fortgeschritten

Aufgabe

Baue eine View WeekView, die eine Habit bekommt und die letzten sieben Tage nebeneinander zeigt, ältester links, heute rechts. Jeder Tag ist ein Kreis: gefüllt in der Farbe der Gewohnheit, wenn erledigt, sonst nur ein grauer Ring. Darüber steht der Wochentag als einzelner Buchstabe.

Tipp: Text(day, format: .dateTime.weekday(.narrow)) liefert „M“, „D“, „M“ …

LösungErst selbst probieren!
Swift
import SwiftUI

struct WeekView: View {
    let habit: Habit

    private var lastSevenDays: [Date] {
        (0..<7).reversed().compactMap {
            Calendar.current.date(byAdding: .day, value: -$0, to: .now)
        }
    }

    var body: some View {
        HStack {
            ForEach(lastSevenDays, id: \.self) { day in
                VStack(spacing: 6) {
                    Text(day, format: .dateTime.weekday(.narrow))
                        .font(.caption)
                        .foregroundStyle(.secondary)

                    Circle()
                        .fill(habit.isDone(on: day) ? habit.color.color : .clear)
                        .stroke(habit.color.color.opacity(0.4), lineWidth: 2)
                        .frame(width: 28, height: 28)
                }
                .frame(maxWidth: .infinity)
            }
        }
    }
}

#Preview(traits: .sizeThatFitsLayout) {
    WeekView(habit: .withStreak)
        .padding()
}
  • (0..<7).reversed() zählt 6, 5, … 0 herunter. So steht der älteste Tag links.
  • .fill und .stroke lassen sich bei Formen kombinieren: Füllung innen, Ring außen.
  • Jeder Tag bekommt mit .frame(maxWidth: .infinity) gleich viel Platz (Lektion 2.8).

Schritt 3: Die Detailansicht

Fortgeschritten

Aufgabe

Baue HabitDetailView mit @Binding var habit: Habit. Sie zeigt in einem ScrollView untereinander:

  1. das Symbol groß in der Farbe der Gewohnheit,
  2. die Streak groß, z. B. „5 Tage in Folge“ (bei 1: „1 Tag in Folge“),
  3. die WeekView in einer Karte mit dem Titel „Letzte 7 Tage“,
  4. einen großen Button „Heute erledigt“ bzw. „Heute rückgängig machen“, der toggleToday() aufruft.

Der Navigationstitel ist der Name der Gewohnheit.

Tipp: Die Card aus Lektion 2.21 passt hier perfekt.

LösungErst selbst probieren!
Swift
import SwiftUI

struct HabitDetailView: View {
    @Binding var habit: Habit

    private var streakText: String {
        habit.streak == 1 ? "1 Tag in Folge" : "\(habit.streak) Tage in Folge"
    }

    var body: some View {
        ScrollView {
            VStack(spacing: 24) {
                VStack(spacing: 12) {
                    Image(systemName: habit.icon)
                        .font(.system(size: 64))
                        .foregroundStyle(habit.color.color)
                        .symbolEffect(.bounce, value: habit.isDoneToday)

                    Text(streakText)
                        .font(.title)
                        .bold()
                        .contentTransition(.numericText())
                }
                .padding(.top)

                Card(title: "Letzte 7 Tage") {
                    WeekView(habit: habit)
                }

                Button {
                    withAnimation {
                        habit.toggleToday()
                    }
                } label: {
                    Label(
                        habit.isDoneToday ? "Heute rückgängig machen" : "Heute erledigt",
                        systemImage: habit.isDoneToday ? "arrow.uturn.backward" : "checkmark"
                    )
                    .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                .controlSize(.large)
                .tint(habit.isDoneToday ? .gray : habit.color.color)
                .sensoryFeedback(.success, trigger: habit.isDoneToday)
            }
            .padding()
        }
        .navigationTitle(habit.name)
        .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    @Previewable @State var habit = Habit.withStreak
    NavigationStack {
        HabitDetailView(habit: $habit)
    }
}

Wenn du Card noch nicht in deinem Projekt hast, kopier sie aus Lektion 2.21 in eine eigene Datei Card.swift in Views.

  • .symbolEffect(.bounce, value:) lässt das Symbol beim Abhaken hüpfen (Lektion 2.6).
  • .contentTransition(.numericText()) animiert die Zahl, wenn sich die Streak ändert. Zusammen mit withAnimation zählt sie sichtbar hoch.
  • Die Preview packt die View in einen NavigationStack, damit der Titel zu sehen ist (Lektion 2.17).

Erwartetes Ergebnis

Ein großes lila Gehirn-Symbol, darunter „5 Tage in Folge“, eine Karte mit sieben Kreisen, von denen die letzten fünf gefüllt sind, und ein Knopf „Heute rückgängig machen“. Tippst du darauf, wird die Zahl zu „4“, der letzte Kreis leert sich, und der Knopf wechselt zu „Heute erledigt“.

Screenshot folgt: Detailansicht mit Streak und Wochenübersicht

Schritt 4: Navigation von der Liste

Die Detailansicht braucht ein Binding, damit Abhaken dort auch in der Liste sichtbar ist. Wertbasierte Navigation (Lektion 2.17) liefert aber nur einen Wert, kein Binding. Deshalb verwenden wir hier die Variante mit Ziel-Closure.

Fortgeschritten

Aufgabe

Mach in HabitListView jede Zeile zu einem NavigationLink, der HabitDetailView mit einem Binding auf die Gewohnheit öffnet. Der Abhak-Knopf in der Zeile soll weiterhin für sich allein funktionieren.

LösungErst selbst probieren!
Swift
ForEach($habits) { $habit in
    NavigationLink {
        HabitDetailView(habit: $habit)
    } label: {
        HabitRow(habit: $habit)
    }
}
.onDelete { habits.remove(atOffsets: $0) }

Weil der Knopf in HabitRow .buttonStyle(.borderless) hat (Teil 1), reagiert er unabhängig von der Zeile: Ein Tipp auf den Kreis hakt ab, ein Tipp auf den Rest der Zeile öffnet die Details.

Schritt 5: Umbenennen und Löschen

Profi

Aufgabe

Ergänze in der Detailansicht ein Menü in der Toolbar (ellipsis.circle) mit zwei Einträgen:

  1. „Umbenennen“ öffnet einen Alert mit Textfeld (Lektion 2.20). Beim Sichern wird der Name geändert, sofern er nicht leer ist.
  2. „Löschen“ (rot) öffnet einen Alert mit Rückfrage. Bestätigt der Nutzer, wird die Gewohnheit aus der Liste entfernt und die Detailansicht geschlossen.

Für das Löschen braucht die Detailansicht eine Closure let onDelete: () -> Void (Lektion 2.21) und @Environment(\.dismiss).

LösungErst selbst probieren!

In HabitDetailView:

Swift
struct HabitDetailView: View {
    @Binding var habit: Habit
    let onDelete: () -> Void

    @Environment(\.dismiss) private var dismiss
    @State private var showRename = false
    @State private var showDelete = false
    @State private var newName = ""

    // … streakText und body wie bisher, ergänzt um:

    var body: some View {
        ScrollView {
            // … wie bisher …
        }
        .navigationTitle(habit.name)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            Menu("Optionen", systemImage: "ellipsis.circle") {
                Button("Umbenennen", systemImage: "pencil") {
                    newName = habit.name
                    showRename = true
                }
                Button("Löschen", systemImage: "trash", role: .destructive) {
                    showDelete = true
                }
            }
        }
        .alert("Umbenennen", isPresented: $showRename) {
            TextField("Name", text: $newName)
            Button("Abbrechen", role: .cancel) { }
            Button("Sichern") {
                let trimmed = newName.trimmingCharacters(in: .whitespaces)
                if !trimmed.isEmpty {
                    habit.name = trimmed
                }
            }
        }
        .alert("„\(habit.name)“ löschen?", isPresented: $showDelete) {
            Button("Löschen", role: .destructive) {
                dismiss()
                onDelete()
            }
            Button("Abbrechen", role: .cancel) { }
        } message: {
            Text("Alle bisherigen Einträge gehen verloren.")
        }
    }
}

In HabitListView:

Swift
NavigationLink {
    HabitDetailView(habit: $habit) {
        habits.removeAll { $0.id == habit.id }
    }
} label: {
    HabitRow(habit: $habit)
}

Beachte die Reihenfolge beim Löschen: erst dismiss(), dann onDelete(). Würde die Gewohnheit zuerst aus dem Array entfernt, zeigte das Binding der noch offenen Detailansicht ins Leere. Mit dieser Reihenfolge schließt die Ansicht, bevor die Daten verschwinden.

Die Preview der Detailansicht braucht jetzt ebenfalls eine Closure: HabitDetailView(habit: $habit) { }.

Schritt 6: Feinschliff

Die App ist funktional fertig. Ein paar Details machen den Unterschied zwischen „funktioniert“ und „fühlt sich gut an“.

Fortgeschritten

Aufgabe

Setz mindestens drei dieser Verbesserungen um:

  1. Streak in der Liste: Zeig in HabitRow unter dem Namen klein „🔥 5“, wenn die Streak größer als 0 ist.
  2. Animierte Liste: Neue und gelöschte Gewohnheiten gleiten sanft ein und aus. Tipp: .animation(.default, value: habits) an der Liste. Dafür muss Habit Equatable sein, und das ist es dank Hashable schon.
  3. Wischgeste zum Abhaken nach rechts, in der Farbe der Gewohnheit.
  4. App-Symbol: Gestalte ein eigenes Symbol und leg es im Asset-Katalog unter AppIcon ab.
  5. Akzentfarbe der App passend zum Symbol im Asset-Katalog ändern.
Lösung zu 1 und 3Erst selbst probieren!

In HabitRow:

Swift
VStack(alignment: .leading, spacing: 2) {
    Text(habit.name)
    if habit.streak > 0 {
        Text("🔥 \(habit.streak)")
            .font(.caption)
            .foregroundStyle(.secondary)
    }
}

In HabitListView, am NavigationLink:

Swift
.swipeActions(edge: .leading) {
    Button(habit.isDoneToday ? "Rückgängig" : "Erledigt", systemImage: "checkmark") {
        habit.toggleToday()
    }
    .tint(habit.color.color)
}

Weil habit hier ein Binding aus ForEach($habits) ist, ändert habit.toggleToday() direkt das Element im Array.

Der fertige Habit-Tracker

Kompletter Code von HabitListViewErst selbst probieren!
Swift
import SwiftUI

struct HabitListView: View {
    @State private var habits = Habit.examples
    @State private var showAddHabit = false

    private var doneCount: Int {
        habits.filter { $0.isDoneToday }.count
    }

    var body: some View {
        NavigationStack {
            Group {
                if habits.isEmpty {
                    ContentUnavailableView(
                        "Noch keine Gewohnheiten",
                        systemImage: "checklist",
                        description: Text("Lege deine erste Gewohnheit an und hake sie jeden Tag ab.")
                    )
                } else {
                    List {
                        Section {
                            VStack(alignment: .leading, spacing: 8) {
                                Text("\(doneCount) von \(habits.count) heute erledigt")
                                    .font(.headline)
                                ProgressView(value: Double(doneCount), total: Double(habits.count))
                                    .tint(.green)
                            }
                            .padding(.vertical, 4)
                        }

                        Section("Heute") {
                            ForEach($habits) { $habit in
                                NavigationLink {
                                    HabitDetailView(habit: $habit) {
                                        habits.removeAll { $0.id == habit.id }
                                    }
                                } label: {
                                    HabitRow(habit: $habit)
                                }
                                .swipeActions(edge: .leading) {
                                    Button(habit.isDoneToday ? "Rückgängig" : "Erledigt", systemImage: "checkmark") {
                                        habit.toggleToday()
                                    }
                                    .tint(habit.color.color)
                                }
                            }
                            .onDelete { habits.remove(atOffsets: $0) }
                        }
                    }
                    .animation(.default, value: habits)
                }
            }
            .navigationTitle("Gewohnheiten")
            .toolbar {
                Button("Neue Gewohnheit", systemImage: "plus") {
                    showAddHabit = true
                }
            }
            .sheet(isPresented: $showAddHabit) {
                AddHabitView { newHabit in
                    habits.append(newHabit)
                }
            }
        }
    }
}

#Preview {
    HabitListView()
}

Was du in diesem Projekt angewendet hast

BausteinWo im ProjektLektion
@State, @BindingListe besitzt, Zeilen und Details ändern2.9, 2.10
Buttons, HaptikAbhaken, Hauptaktion2.11
Textfelder, FokusName im Sheet2.12
PickerFarbauswahl2.13
FormularNeue Gewohnheit2.14
Listen, Identifiable, LöschenGewohnheitsliste2.15, 2.16
NavigationListe → Details2.17
Toolbar, MenüPlus-Button, Optionen2.18
SheetNeue Gewohnheit2.19
AlertsUmbenennen, Löschen2.20
Views zerlegen, ContainerHabitRow, WeekView, IconPicker, Card2.21
Datum und KalenderStreak, Wochenübersicht2.13, 1.5

Geschafft

Herzlichen Glückwunsch! Du hast Stufe 2 abgeschlossen und eine vollständige, alltagstaugliche App gebaut, mit Liste, Navigation, Formularen, Dialogen und Animationen. Installier sie auf deinem iPhone und benutz sie ein paar Tage (Lektion 0.4). Du wirst schnell merken, was noch fehlt: Nach einem Neustart sind alle Daten weg. Genau da setzt Stufe 3 an, mit @Observable, Netzwerk und dauerhaftem Speichern.

Bonus-Aufgaben

Fortgeschritten

Bonus 1: Rekord

Berechne zusätzlich zur aktuellen Streak die längste Serie aller Zeiten und zeig sie in der Detailansicht an.

Profi

Bonus 2: Vergangene Tage nachtragen

Mach die Kreise in der WeekView antippbar, sodass man vergessene Tage nachtragen oder entfernen kann. Dafür braucht WeekView ein Binding und Habit eine Methode toggle(on date: Date). Zukünftige Tage darf es nicht geben, aber das ist bei den letzten sieben Tagen ohnehin der Fall.

Zusammenfassung

  • Die Streak wird aus den Rohdaten berechnet: rückwärts durch die Tage, bis einer fehlt.
  • WeekView zeigt die letzten sieben Tage mit ForEach über berechnete Daten.
  • Die Detailansicht bekommt ein Binding, damit Änderungen überall sichtbar sind. Deshalb hier NavigationLink mit Ziel-Closure statt wertbasierter Navigation.
  • Menü, Alerts und dismiss ermöglichen Umbenennen und Löschen mit Rückfrage.
  • Kleine Details wie Animationen, Haptik und Symboleffekte machen die App lebendig.

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.