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:
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ückAufgabe
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!
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:
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
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!
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..fillund.strokelassen 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
Aufgabe
Baue HabitDetailView mit @Binding var habit: Habit. Sie zeigt in einem ScrollView untereinander:
- das Symbol groß in der Farbe der Gewohnheit,
- die Streak groß, z. B. „5 Tage in Folge“ (bei 1: „1 Tag in Folge“),
- die
WeekViewin einer Karte mit dem Titel „Letzte 7 Tage“, - 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!
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 mitwithAnimationzä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“.
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.
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!
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
Aufgabe
Ergänze in der Detailansicht ein Menü in der Toolbar (ellipsis.circle) mit zwei Einträgen:
- „Umbenennen“ öffnet einen Alert mit Textfeld (Lektion 2.20). Beim Sichern wird der Name geändert, sofern er nicht leer ist.
- „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:
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:
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“.
Aufgabe
Setz mindestens drei dieser Verbesserungen um:
- Streak in der Liste: Zeig in
HabitRowunter dem Namen klein „🔥 5“, wenn die Streak größer als 0 ist. - Animierte Liste: Neue und gelöschte Gewohnheiten gleiten sanft ein und aus. Tipp:
.animation(.default, value: habits)an der Liste. Dafür mussHabitEquatablesein, und das ist es dankHashableschon. - Wischgeste zum Abhaken nach rechts, in der Farbe der Gewohnheit.
- App-Symbol: Gestalte ein eigenes Symbol und leg es im Asset-Katalog unter
AppIconab. - Akzentfarbe der App passend zum Symbol im Asset-Katalog ändern.
Lösung zu 1 und 3Erst selbst probieren!
In HabitRow:
VStack(alignment: .leading, spacing: 2) {
Text(habit.name)
if habit.streak > 0 {
Text("🔥 \(habit.streak)")
.font(.caption)
.foregroundStyle(.secondary)
}
}In HabitListView, am NavigationLink:
.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!
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
| Baustein | Wo im Projekt | Lektion |
|---|---|---|
@State, @Binding | Liste besitzt, Zeilen und Details ändern | 2.9, 2.10 |
| Buttons, Haptik | Abhaken, Hauptaktion | 2.11 |
| Textfelder, Fokus | Name im Sheet | 2.12 |
| Picker | Farbauswahl | 2.13 |
| Formular | Neue Gewohnheit | 2.14 |
Listen, Identifiable, Löschen | Gewohnheitsliste | 2.15, 2.16 |
| Navigation | Liste → Details | 2.17 |
| Toolbar, Menü | Plus-Button, Optionen | 2.18 |
| Sheet | Neue Gewohnheit | 2.19 |
| Alerts | Umbenennen, Löschen | 2.20 |
| Views zerlegen, Container | HabitRow, WeekView, IconPicker, Card | 2.21 |
| Datum und Kalender | Streak, Wochenübersicht | 2.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
Bonus 1: Rekord
Berechne zusätzlich zur aktuellen Streak die längste Serie aller Zeiten und zeig sie in der Detailansicht an.
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.
WeekViewzeigt die letzten sieben Tage mitForEachüber berechnete Daten.- Die Detailansicht bekommt ein Binding, damit Änderungen überall sichtbar sind. Deshalb hier
NavigationLinkmit Ziel-Closure statt wertbasierter Navigation. - Menü, Alerts und
dismissermöglichen Umbenennen und Löschen mit Rückfrage. - Kleine Details wie Animationen, Haptik und Symboleffekte machen die App lebendig.