Projekt E · Teil 1 · Stufe 3 – Fortgeschritten

Ausgaben-Tracker mit SwiftData und Charts (Teil 1)

ca. 15 Minuten Lesezeit

Lernziel

In diesem Projektteil legst du das Fundament eines Ausgaben-Trackers: ein SwiftData-Modell mit Ausgaben und Kategorien, Beispieldaten für Previews, Standardkategorien beim ersten Start und eine nach Tagen gruppierte Liste mit Monatssumme, Löschen und leerem Zustand.

Worum es geht

In Projekt E baust du einen Ausgaben-Tracker: eine App, in der du festhältst, wofür du Geld ausgibst, und die dir zeigt, wohin es fließt. Alle Daten bleiben auf dem Gerät, gespeichert mit SwiftData.

TeilInhaltLektionen
1Datenmodell, Beispieldaten, Liste der Ausgaben3.14–3.16
2Erfassen, Bearbeiten, Kategorien verwalten, Filtern3.15, 3.16, 3.17
3Auswertung mit Swift Charts und Tabs3.17, 3.21, 3.31
4Animationen und ein eigenes kleines Designsystem3.23–3.30

Am Ende hast du eine App, die du wirklich benutzen kannst, und die nebenbei fast alles aus Stufe 3 zusammenbringt.

So arbeitest du mit diesem Projekt

Wie gewohnt: Erst selbst versuchen, dann aufklappen. Gerade bei SwiftData lohnt es sich, nach jedem Schritt die App im Simulator zu starten, ein paar Einträge anzulegen und zu prüfen, ob sie einen Neustart überleben.

Schritt 1: Das Projekt anlegen

Anfänger

Aufgabe

Leg ein neues iOS-App-Projekt namens Ausgaben an. Wähle bei Storage „None“: Den Container richtest du gleich selbst ein, dann weißt du genau, was passiert. Lösche die Beispielinhalte aus ContentView.

LösungErst selbst probieren!

Datei → Neu → Projekt → iOS → App, Name „Ausgaben“, Interface „SwiftUI“, Storage „None“. In ContentView bleibt zunächst nur:

Swift
struct ContentView: View {
    var body: some View {
        Text("Ausgaben")
    }
}

Die Xcode-Vorlage mit „SwiftData“ würde ein Beispielmodell namens Item erzeugen, das du ohnehin löschen müsstest.

Schritt 2: Die Modelle

Die App braucht zwei Modelle: Ausgaben und Kategorien. Eine Ausgabe gehört zu höchstens einer Kategorie, eine Kategorie hat viele Ausgaben (Lektion 3.16).

Fortgeschritten

Aufgabe

  1. Leg ein Enum CategoryTint an (String, Codable, CaseIterable) mit den Fällen orange, blue, green, purple, pink, teal, red und gray und einer Eigenschaft color: Color.
  2. Leg ein Modell Category an mit name, icon (Name eines SF Symbols), tint: CategoryTint und einer Beziehung expenses: [Expense]. Beim Löschen einer Kategorie sollen ihre Ausgaben erhalten bleiben.
  3. Leg ein Modell Expense an mit title, amount: Double, date: Date, note: String und einer optionalen category.
LösungErst selbst probieren!
Swift
import SwiftUI
import SwiftData

enum CategoryTint: String, Codable, CaseIterable {
    case orange, blue, green, purple, pink, teal, red, gray

    var color: Color {
        switch self {
        case .orange: .orange
        case .blue: .blue
        case .green: .green
        case .purple: .purple
        case .pink: .pink
        case .teal: .teal
        case .red: .red
        case .gray: .gray
        }
    }
}

@Model
final class Category {
    var name: String
    var icon: String
    var tint: CategoryTint

    @Relationship(deleteRule: .nullify, inverse: \Expense.category)
    var expenses: [Expense] = []

    init(name: String, icon: String, tint: CategoryTint) {
        self.name = name
        self.icon = icon
        self.tint = tint
    }
}

@Model
final class Expense {
    var title: String
    var amount: Double
    var date: Date
    var note: String
    var category: Category?

    init(title: String, amount: Double, date: Date = .now, note: String = "", category: Category? = nil) {
        self.title = title
        self.amount = amount
        self.date = date
        self.note = note
        self.category = category
    }
}
  • deleteRule: .nullify: Wird eine Kategorie gelöscht, bleiben ihre Ausgaben erhalten, nur ohne Kategorie. Geld, das ausgegeben wurde, ist ja trotzdem weg.
  • tint ist ein Enum mit String-Rohwert und Codable. Solche Enums kann SwiftData direkt speichern. Die SwiftUI-Farbe selbst ließe sich nicht speichern, deshalb der Umweg über das Enum.
  • final heißt: Von dieser Klasse kann niemand erben (Lektion 1.13). Das ist bei Modellen üblich und erlaubt dem Compiler kleine Optimierungen.

Warum Double für Geld?

Für eine persönliche Ausgaben-App reicht Double. In Apps, in denen Cent-genaue Rechnungen kritisch sind, etwa in der Buchhaltung, verwendet man Decimal, weil Double manche Dezimalzahlen nur ungefähr speichert: 0.1 + 0.2 ergibt 0.30000000000000004. Für das Anzeigen mit zwei Nachkommastellen spielt das hier keine Rolle.

Schritt 3: Den Container einrichten

Anfänger

Aufgabe

Richte in der App-Struktur einen Model-Container für beide Modelle ein.

LösungErst selbst probieren!
Swift
import SwiftUI
import SwiftData

@main
struct AusgabenApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Expense.self, Category.self])
    }
}

Genau genommen würde Expense.self reichen: SwiftData findet Category über die Beziehung selbst. Beide anzugeben, macht aber klar, was gespeichert wird.

Schritt 4: Beispieldaten

Für Previews und den ersten Start brauchst du Daten. Zwei Arten:

  • Standardkategorien, die beim ersten Start der echten App angelegt werden.
  • Beispielausgaben, nur für Previews.
Fortgeschritten

Aufgabe

  1. Leg ein Enum SampleData ohne Fälle an mit einer Funktion insertCategories(into:), die sechs Standardkategorien anlegt und zurückgibt, und einer Funktion insertExamples(into:), die die Kategorien und dazu 40 zufällige Ausgaben aus den letzten 60 Tagen anlegt.
  2. Leg einen previewContainer an, der nur im Arbeitsspeicher liegt und mit den Beispielen gefüllt ist (Lektion 3.14).
LösungErst selbst probieren!
Swift
import SwiftData
import Foundation

enum SampleData {
    @discardableResult
    static func insertCategories(into context: ModelContext) -> [Category] {
        let categories = [
            Category(name: "Lebensmittel", icon: "cart.fill", tint: .green),
            Category(name: "Essen gehen", icon: "fork.knife", tint: .orange),
            Category(name: "Verkehr", icon: "tram.fill", tint: .blue),
            Category(name: "Freizeit", icon: "gamecontroller.fill", tint: .purple),
            Category(name: "Wohnen", icon: "house.fill", tint: .teal),
            Category(name: "Sonstiges", icon: "ellipsis", tint: .gray)
        ]
        for category in categories {
            context.insert(category)
        }
        return categories
    }

    static func insertExamples(into context: ModelContext) {
        let categories = insertCategories(into: context)
        let titles = [
            "Lebensmittel": ["Wocheneinkauf", "Bäckerei", "Markt"],
            "Essen gehen": ["Pizza", "Mittagessen", "Café"],
            "Verkehr": ["Monatskarte", "Tanken", "Taxi"],
            "Freizeit": ["Kino", "Konzert", "Buch"],
            "Wohnen": ["Strom", "Internet", "Möbel"],
            "Sonstiges": ["Geschenk", "Apotheke", "Friseur"]
        ]

        for _ in 0..<40 {
            let category = categories.randomElement()!
            let title = titles[category.name]?.randomElement() ?? "Ausgabe"
            let daysAgo = Int.random(in: 0...60)
            let date = Calendar.current.date(byAdding: .day, value: -daysAgo, to: .now)!
            let amount = (Double.random(in: 3...90) * 100).rounded() / 100

            context.insert(Expense(title: title, amount: amount, date: date, category: category))
        }
    }
}

@MainActor
let previewContainer: ModelContainer = {
    let container = try! ModelContainer(
        for: Expense.self, Category.self,
        configurations: ModelConfiguration(isStoredInMemoryOnly: true)
    )
    SampleData.insertExamples(into: container.mainContext)
    return container
}()
  • @discardableResult erlaubt, insertCategories aufzurufen, ohne das Ergebnis zu verwenden. Sonst gäbe es eine Warnung.
  • (x * 100).rounded() / 100 rundet auf zwei Nachkommastellen, damit die Beispielbeträge wie echte Preise aussehen.
  • Die ! bei randomElement() und date(byAdding:) sind hier vertretbar: Die Liste ist nie leer, und das Datum lässt sich immer berechnen. In Code, der mit Nutzerdaten arbeitet, würdest du sie vermeiden.

Schritt 5: Standardkategorien beim ersten Start

Fortgeschritten

Aufgabe

Sorg dafür, dass beim allerersten Start der echten App die Standardkategorien angelegt werden, aber nur, wenn es noch keine Kategorien gibt.

LösungErst selbst probieren!
Swift
struct ContentView: View {
    @Environment(\.modelContext) private var context

    var body: some View {
        ExpenseListView()
            .task {
                let count = (try? context.fetchCount(FetchDescriptor<Category>())) ?? 0
                if count == 0 {
                    SampleData.insertCategories(into: context)
                }
            }
    }
}

fetchCount zählt die gespeicherten Objekte, ohne sie zu laden. Ein FetchDescriptor<Category>() ohne Filter beschreibt „alle Kategorien“. Sind es null, werden die Standardkategorien angelegt.

Warum nicht einfach mit @AppStorage("didSeed") merken? Das ginge auch. Die Zählung hat aber einen Vorteil: Löscht der Nutzer irgendwann alle Kategorien und startet neu, bekommt er wieder sinnvolle Vorschläge.

Schritt 6: Eine Zeile

Anfänger

Aufgabe

Baue eine View ExpenseRow, die eine Ausgabe zeigt: links ein farbiger Kreis mit dem Symbol der Kategorie, daneben Titel und Kategoriename, rechts der Betrag in Euro. Ausgaben ohne Kategorie zeigen ein Fragezeichen auf Grau und „Ohne Kategorie“.

LösungErst selbst probieren!
Swift
struct ExpenseRow: View {
    let expense: Expense

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: expense.category?.icon ?? "questionmark")
                .font(.body.weight(.semibold))
                .foregroundStyle(.white)
                .frame(width: 36, height: 36)
                .background(expense.category?.tint.color ?? .gray, in: .circle)

            VStack(alignment: .leading, spacing: 2) {
                Text(expense.title)
                Text(expense.category?.name ?? "Ohne Kategorie")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            Text(expense.amount, format: .currency(code: "EUR"))
                .monospacedDigit()
        }
    }
}

#Preview {
    let expense = Expense(title: "Wocheneinkauf", amount: 64.2,
                          category: Category(name: "Lebensmittel", icon: "cart.fill", tint: .green))
    return List { ExpenseRow(expense: expense) }
        .modelContainer(previewContainer)
}
  • .monospacedDigit() gibt allen Ziffern dieselbe Breite. Dann stehen Beträge in einer Liste sauber untereinander.
  • Die Preview braucht einen Container, weil @Model-Objekte nur mit einem Container zuverlässig funktionieren.

Schritt 7: Die Liste nach Tagen

Ausgaben-Apps gruppieren Einträge meist nach Tagen: „Heute“, „Gestern“, „Montag, 12. Oktober“. Das macht lange Listen übersichtlich.

Profi

Aufgabe

Baue ExpenseListView:

  1. Lade alle Ausgaben mit @Query, neueste zuerst.
  2. Gruppiere sie nach Tag. Tipp: Dictionary(grouping:by:) mit Calendar.current.startOfDay(for:).
  3. Zeig jeden Tag als Section mit dem Datum als Überschrift (heute als „Heute“, gestern als „Gestern“) und der Tagessumme rechts.
  4. Einträge sollen sich per Wischen löschen lassen.
LösungErst selbst probieren!
Swift
struct ExpenseListView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \Expense.date, order: .reverse) private var expenses: [Expense]

    private var days: [(date: Date, items: [Expense])] {
        let groups = Dictionary(grouping: expenses) { expense in
            Calendar.current.startOfDay(for: expense.date)
        }
        return groups
            .map { (date: $0.key, items: $0.value) }
            .sorted { $0.date > $1.date }
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(days, id: \.date) { day in
                    Section {
                        ForEach(day.items) { expense in
                            ExpenseRow(expense: expense)
                        }
                        .onDelete { offsets in
                            for index in offsets {
                                context.delete(day.items[index])
                            }
                        }
                    } header: {
                        HStack {
                            Text(title(for: day.date))
                            Spacer()
                            Text(day.items.reduce(0) { $0 + $1.amount }, format: .currency(code: "EUR"))
                        }
                    }
                }
            }
            .navigationTitle("Ausgaben")
        }
    }

    private func title(for date: Date) -> String {
        if Calendar.current.isDateInToday(date) { return "Heute" }
        if Calendar.current.isDateInYesterday(date) { return "Gestern" }
        return date.formatted(.dateTime.weekday(.wide).day().month(.wide))
    }
}

#Preview {
    ExpenseListView()
        .modelContainer(previewContainer)
}

So funktioniert die Gruppierung:

  • Dictionary(grouping:by:) sortiert alle Ausgaben in „Fächer“. Der Schlüssel ist der Tagesanfang, also derselbe Wert für alle Ausgaben desselben Tages.
  • Ein Dictionary hat keine Reihenfolge (Lektion 1.7). Deshalb wird es in ein Array aus Tupeln (date:, items:) umgewandelt und nach Datum sortiert.
  • Die Ausgaben innerhalb eines Tages bleiben in der Reihenfolge der @Query: neueste zuerst.
  • ForEach(days, id: \.date): Tupel sind nicht Identifiable, aber jedes Datum kommt nur einmal vor und eignet sich als ID.

Beim Löschen beziehen sich die offsets auf die Einträge dieses Tages, deshalb day.items[index].

Erwartetes Ergebnis

Eine Liste mit Abschnitten „Heute“, „Gestern“ und darunter Wochentage mit Datum. Jeder Abschnitt zeigt rechts in der Überschrift die Tagessumme. Wischen nach links löscht einen Eintrag, und die Summe aktualisiert sich sofort.

Screenshot folgt: Ausgabenliste gruppiert nach Tagen mit Tagessummen

Schritt 8: Monatssumme und leerer Zustand

Fortgeschritten

Aufgabe

  1. Zeig ganz oben in der Liste einen eigenen Abschnitt mit der Summe des aktuellen Monats, groß und fett.
  2. Gibt es noch keine Ausgaben, zeig statt der Liste einen leeren Zustand mit ContentUnavailableView (Lektion 2.15).
LösungErst selbst probieren!
Swift
private var monthTotal: Double {
    expenses
        .filter { Calendar.current.isDate($0.date, equalTo: .now, toGranularity: .month) }
        .reduce(0) { $0 + $1.amount }
}

Im body, vor dem ForEach(days ...):

Swift
Section {
    VStack(alignment: .leading, spacing: 4) {
        Text("Diesen Monat")
            .font(.subheadline)
            .foregroundStyle(.secondary)
        Text(monthTotal, format: .currency(code: "EUR"))
            .font(.largeTitle.bold())
    }
    .padding(.vertical, 4)
}

Und an der List:

Swift
.overlay {
    if expenses.isEmpty {
        ContentUnavailableView(
            "Noch keine Ausgaben",
            systemImage: "eurosign.circle",
            description: Text("Tippe auf Plus, um deine erste Ausgabe zu erfassen.")
        )
    }
}
  • isDate(_:equalTo:toGranularity: .month) prüft, ob zwei Daten im selben Monat (und Jahr) liegen.
  • Das .overlay liegt über der leeren Liste. Sobald es Einträge gibt, verschwindet es.

Den Plus-Button, auf den der Text verweist, baust du in Teil 2.

Bonus-Aufgaben

Fortgeschritten

Bonus 1: Notiz anzeigen

Hat eine Ausgabe eine Notiz, zeig in der Zeile ein kleines Notiz-Symbol neben dem Titel.

Profi

Bonus 2: Gruppierung im ViewModel

Die Gruppierung wird bei jeder Änderung von body neu berechnet. Für ein paar hundert Einträge ist das kein Problem. Überlege, wie du sie in eine eigene, testbare Funktion group(_ expenses: [Expense]) -> [DayGroup] auslagerst, mit einem eigenen struct DayGroup: Identifiable.

Zusammenfassung

  • Zwei @Model-Klassen mit einer Eins-zu-viele-Beziehung und .nullify als Löschregel.
  • Ein Codable-Enum speichert die Farbe einer Kategorie.
  • SampleData legt Standardkategorien und Beispielausgaben an, ein previewContainer im Arbeitsspeicher versorgt die Previews.
  • fetchCount prüft beim Start, ob schon Kategorien existieren.
  • Dictionary(grouping:by:) mit startOfDay gruppiert Ausgaben nach Tagen.
  • ContentUnavailableView im .overlay zeigt einen freundlichen leeren Zustand.

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.