Projekt E · Teil 2 · Stufe 3 – Fortgeschritten

Ausgaben-Tracker mit SwiftData und Charts (Teil 2)

ca. 15 Minuten Lesezeit

Lernziel

In diesem Projektteil machst du den Ausgaben-Tracker benutzbar: ein Formular, das neue Ausgaben erfasst und bestehende bearbeitet, eine Verwaltung für Kategorien mit Symbol- und Farbauswahl im Raster, sicheres Löschen mit Rückfrage und einen Filter nach Kategorie mit dynamischer Abfrage.

Ausgangslage

Nach Teil 1 zeigt die App Ausgaben an, gruppiert nach Tagen. Aber eintragen kann man noch nichts. In diesem Teil kommen dazu:

  1. Erfassen und Bearbeiten von Ausgaben in einem Formular,
  2. Kategorien verwalten: anlegen, ändern, löschen,
  3. Filtern der Liste nach einer Kategorie.

Schritt 1: Das Formular

Wie das BookForm aus Lektion 3.15 soll ein einziges Formular beides können: neue Ausgaben anlegen und bestehende bearbeiten.

Fortgeschritten

Aufgabe

Baue ExpenseForm mit einem optionalen Parameter expense: Expense?:

  1. Felder für Titel, Betrag (in Euro), Datum (nur Tag, nicht in der Zukunft), Kategorie (inklusive „Keine“) und eine mehrzeilige Notiz.
  2. Beim Bearbeiten werden die Felder mit den bestehenden Werten gefüllt.
  3. „Sichern“ ist nur aktiv, wenn ein Titel eingegeben und der Betrag größer als null ist.
  4. Eine neue Ausgabe bekommt die erste Kategorie vorausgewählt, damit der Nutzer seltener tippen muss.
LösungErst selbst probieren!
Swift
struct ExpenseForm: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss
    @Query(sort: \Category.name) private var categories: [Category]

    let expense: Expense?

    @State private var title = ""
    @State private var amount = 0.0
    @State private var date = Date.now
    @State private var category: Category?
    @State private var note = ""

    private var isNew: Bool { expense == nil }

    private var isValid: Bool {
        !title.trimmingCharacters(in: .whitespaces).isEmpty && amount > 0
    }

    init(expense: Expense? = nil) {
        self.expense = expense
    }

    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("Wofür?", text: $title)
                    TextField("Betrag", value: $amount, format: .currency(code: "EUR"))
                        .keyboardType(.decimalPad)
                }

                Section {
                    Picker("Kategorie", selection: $category) {
                        Text("Keine").tag(nil as Category?)
                        ForEach(categories) { category in
                            Label(category.name, systemImage: category.icon)
                                .tag(category as Category?)
                        }
                    }
                    DatePicker("Datum", selection: $date, in: ...Date.now, displayedComponents: .date)
                }

                Section("Notiz") {
                    TextField("Optional", text: $note, axis: .vertical)
                        .lineLimit(3...6)
                }
            }
            .navigationTitle(isNew ? "Neue Ausgabe" : "Bearbeiten")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Abbrechen") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Sichern") {
                        save()
                        dismiss()
                    }
                    .disabled(!isValid)
                }
            }
            .onAppear {
                if let expense {
                    title = expense.title
                    amount = expense.amount
                    date = expense.date
                    category = expense.category
                    note = expense.note
                } else if category == nil {
                    category = categories.first
                }
            }
        }
    }

    private func save() {
        let trimmed = title.trimmingCharacters(in: .whitespaces)
        if let expense {
            expense.title = trimmed
            expense.amount = amount
            expense.date = date
            expense.category = category
            expense.note = note
        } else {
            let new = Expense(title: trimmed, amount: amount, date: date, note: note, category: category)
            context.insert(new)
        }
    }
}

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

Ein paar Details:

  • in: ...Date.now erlaubt nur Daten bis heute. Ausgaben in der Zukunft wären ein Tippfehler.
  • displayedComponents: .date zeigt nur den Tag, keine Uhrzeit.
  • axis: .vertical mit .lineLimit(3...6) macht das Notizfeld mehrzeilig: mindestens drei Zeilen hoch, wächst bis sechs.
  • Die Tags im Picker sind Category?, passend zur optionalen Auswahl (Lektion 3.16).
  • Beim Bearbeiten werden die Änderungen erst in save() auf das Modell übertragen. Bricht der Nutzer ab, bleibt alles, wie es war.

Der eigene Initializer

init(expense: Expense? = nil) erlaubt beide Schreibweisen: ExpenseForm() für eine neue und ExpenseForm(expense: e) für eine bestehende Ausgabe. Ohne eigenen Initializer müsstest du immer ExpenseForm(expense: nil) schreiben.

Schritt 2: Formular öffnen

Fortgeschritten

Aufgabe

  1. Füge in ExpenseListView einen Plus-Button in die Toolbar ein, der das Formular für eine neue Ausgabe als Sheet öffnet.
  2. Ein Tipp auf eine Zeile öffnet das Formular zum Bearbeiten dieser Ausgabe. Verwende .sheet(item:).
LösungErst selbst probieren!
Swift
@State private var showingNew = false
@State private var editing: Expense?

Die Zeile wird zum Button:

Swift
ForEach(day.items) { expense in
    Button {
        editing = expense
    } label: {
        ExpenseRow(expense: expense)
    }
    .tint(.primary)
}

Und an der List:

Swift
.toolbar {
    Button("Neue Ausgabe", systemImage: "plus") {
        showingNew = true
    }
}
.sheet(isPresented: $showingNew) {
    ExpenseForm()
}
.sheet(item: $editing) { expense in
    ExpenseForm(expense: expense)
}
  • .sheet(item:) öffnet das Sheet, sobald editing einen Wert hat, und übergibt ihn (Lektion 2.19). Das funktioniert, weil @Model-Klassen automatisch Identifiable sind.
  • .tint(.primary) verhindert, dass der Text in der Zeile blau wird, wie es bei Buttons in Listen sonst passiert.

Erwartetes Ergebnis

Plus öffnet ein leeres Formular mit vorausgewählter Kategorie. Nach dem Sichern erscheint die Ausgabe sofort unter „Heute“, und die Monatssumme wächst. Ein Tipp auf eine Zeile öffnet sie zum Bearbeiten.

Screenshot folgt: Formular für eine neue Ausgabe mit Betrag, Kategorie und Datum

Schritt 3: Kategorien anzeigen

Anfänger

Aufgabe

Baue CategoryListView: eine Liste aller Kategorien, sortiert nach Name. Jede Zeile zeigt Symbol und Name und rechts die Anzahl der Ausgaben. Öffne sie aus der Ausgabenliste über einen Toolbar-Button mit dem Symbol "square.grid.2x2" als Sheet.

LösungErst selbst probieren!
Swift
struct CategoryListView: View {
    @Query(sort: \Category.name) private var categories: [Category]

    var body: some View {
        List(categories) { category in
            HStack {
                Image(systemName: category.icon)
                    .foregroundStyle(.white)
                    .frame(width: 32, height: 32)
                    .background(category.tint.color, in: .rect(cornerRadius: 8))
                Text(category.name)
                Spacer()
                Text("\(category.expenses.count)")
                    .foregroundStyle(.secondary)
            }
        }
        .navigationTitle("Kategorien")
    }
}

In ExpenseListView:

Swift
@State private var showingCategories = false

// in .toolbar
ToolbarItem(placement: .topBarLeading) {
    Button("Kategorien", systemImage: "square.grid.2x2") {
        showingCategories = true
    }
}

// an der List
.sheet(isPresented: $showingCategories) {
    NavigationStack {
        CategoryListView()
    }
}

Weil jetzt zwei Toolbar-Elemente da sind, bekommt der Plus-Button ein eigenes ToolbarItem(placement: .primaryAction).

Schritt 4: Das Kategorie-Formular

Für eine Kategorie braucht der Nutzer eine Auswahl an Symbolen und Farben. Beides passt wunderbar in ein Raster (Lektion 3.17).

Profi

Aufgabe

Baue CategoryForm mit optionalem category: Category?, analog zu ExpenseForm:

  1. Ein Textfeld für den Namen.
  2. Ein Raster mit etwa 20 passenden SF Symbols. Das gewählte ist hervorgehoben.
  3. Eine Reihe farbiger Kreise für alle Fälle von CategoryTint. Der gewählte hat einen Rahmen.
  4. Ganz oben eine Vorschau der Kategorie mit Symbol und Farbe.
  5. Sichern ist nur möglich, wenn der Name nicht leer ist und keine andere Kategorie schon so heißt.
LösungErst selbst probieren!
Swift
struct CategoryForm: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss
    @Query private var allCategories: [Category]

    let category: Category?

    @State private var name = ""
    @State private var icon = "cart.fill"
    @State private var tint = CategoryTint.orange

    static let icons = [
        "cart.fill", "fork.knife", "cup.and.saucer.fill", "tram.fill", "car.fill", "fuelpump.fill",
        "house.fill", "bolt.fill", "wifi", "gamecontroller.fill", "film.fill", "book.fill",
        "tshirt.fill", "gift.fill", "heart.fill", "pawprint.fill", "airplane", "graduationcap.fill",
        "dumbbell.fill", "ellipsis"
    ]

    init(category: Category? = nil) {
        self.category = category
    }

    private var trimmedName: String {
        name.trimmingCharacters(in: .whitespaces)
    }

    private var nameIsTaken: Bool {
        allCategories.contains { other in
            other !== category && other.name.localizedCaseInsensitiveCompare(trimmedName) == .orderedSame
        }
    }

    private var isValid: Bool {
        !trimmedName.isEmpty && !nameIsTaken
    }

    var body: some View {
        NavigationStack {
            Form {
                Section {
                    VStack(spacing: 12) {
                        Image(systemName: icon)
                            .font(.largeTitle)
                            .foregroundStyle(.white)
                            .frame(width: 72, height: 72)
                            .background(tint.color, in: .rect(cornerRadius: 18))
                        Text(trimmedName.isEmpty ? "Neue Kategorie" : trimmedName)
                            .font(.headline)
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 8)
                }

                Section {
                    TextField("Name", text: $name)
                } footer: {
                    if nameIsTaken {
                        Text("Eine Kategorie mit diesem Namen gibt es schon.")
                            .foregroundStyle(.red)
                    }
                }

                Section("Symbol") {
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 44))], spacing: 12) {
                        ForEach(Self.icons, id: \.self) { symbol in
                            Image(systemName: symbol)
                                .font(.title3)
                                .frame(width: 44, height: 44)
                                .foregroundStyle(symbol == icon ? Color.white : Color.primary)
                                .background(symbol == icon ? tint.color : Color(.tertiarySystemFill),
                                            in: .rect(cornerRadius: 10))
                                .onTapGesture { icon = symbol }
                        }
                    }
                    .padding(.vertical, 4)
                }

                Section("Farbe") {
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 36))], spacing: 12) {
                        ForEach(CategoryTint.allCases, id: \.self) { option in
                            Circle()
                                .fill(option.color)
                                .frame(width: 32, height: 32)
                                .padding(3)
                                .overlay {
                                    if option == tint {
                                        Circle().strokeBorder(.primary, lineWidth: 2)
                                    }
                                }
                                .onTapGesture { tint = option }
                        }
                    }
                    .padding(.vertical, 4)
                }
            }
            .navigationTitle(category == nil ? "Neue Kategorie" : "Kategorie bearbeiten")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Abbrechen") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Sichern") {
                        save()
                        dismiss()
                    }
                    .disabled(!isValid)
                }
            }
            .onAppear {
                if let category {
                    name = category.name
                    icon = category.icon
                    tint = category.tint
                }
            }
        }
    }

    private func save() {
        if let category {
            category.name = trimmedName
            category.icon = icon
            category.tint = tint
        } else {
            context.insert(Category(name: trimmedName, icon: icon, tint: tint))
        }
    }
}

Die wichtigsten Stellen:

  • other !== category: !== vergleicht bei Klassen, ob es sich um dasselbe Objekt handelt (Lektion 1.13). Beim Bearbeiten darf die Kategorie ihren eigenen Namen natürlich behalten.
  • localizedCaseInsensitiveCompare vergleicht ohne Rücksicht auf Groß- und Kleinschreibung. „essen“ und „Essen“ gelten als gleich.
  • Der Hinweis im footer erscheint nur, wenn der Name vergeben ist.
  • Die Vorschau oben aktualisiert sich bei jeder Eingabe, weil sie aus denselben States gebaut wird.

Schritt 5: Anlegen, Bearbeiten, Löschen

Profi

Aufgabe

Ergänze CategoryListView:

  1. Ein Plus-Button öffnet CategoryForm(), ein Tipp auf eine Zeile öffnet sie zum Bearbeiten.
  2. Wischen zum Löschen fragt mit einem .confirmationDialog nach (Lektion 2.20). Hat die Kategorie Ausgaben, nennt die Nachricht ihre Anzahl und erklärt, dass sie erhalten bleiben.
  3. Ein „Fertig“-Button schließt das Sheet.
LösungErst selbst probieren!
Swift
struct CategoryListView: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss
    @Query(sort: \Category.name) private var categories: [Category]

    @State private var showingNew = false
    @State private var editing: Category?
    @State private var deleting: Category?

    var body: some View {
        List {
            ForEach(categories) { category in
                Button {
                    editing = category
                } label: {
                    CategoryRow(category: category)
                }
                .tint(.primary)
            }
            .onDelete { offsets in
                deleting = offsets.first.map { categories[$0] }
            }
        }
        .navigationTitle("Kategorien")
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button("Fertig") { dismiss() }
            }
            ToolbarItem(placement: .primaryAction) {
                Button("Neue Kategorie", systemImage: "plus") { showingNew = true }
            }
        }
        .sheet(isPresented: $showingNew) {
            CategoryForm()
        }
        .sheet(item: $editing) { category in
            CategoryForm(category: category)
        }
        .confirmationDialog(
            "Kategorie löschen?",
            isPresented: Binding(
                get: { deleting != nil },
                set: { if !$0 { deleting = nil } }
            ),
            titleVisibility: .visible,
            presenting: deleting
        ) { category in
            Button("„\(category.name)“ löschen", role: .destructive) {
                context.delete(category)
            }
        } message: { category in
            if category.expenses.isEmpty {
                Text("Die Kategorie wird entfernt.")
            } else {
                Text("\(category.expenses.count) Ausgaben bleiben erhalten und stehen danach unter „Ohne Kategorie“.")
            }
        }
    }
}

struct CategoryRow: View {
    let category: Category

    var body: some View {
        HStack {
            Image(systemName: category.icon)
                .foregroundStyle(.white)
                .frame(width: 32, height: 32)
                .background(category.tint.color, in: .rect(cornerRadius: 8))
            Text(category.name)
            Spacer()
            Text("\(category.expenses.count)")
                .foregroundStyle(.secondary)
        }
    }
}
  • Die Zeile ist in eine eigene View CategoryRow ausgelagert (Lektion 2.21).
  • offsets.first.map { categories[$0] }: Aus dem ersten Index wird die passende Kategorie, oder nil, wenn es keinen gibt.
  • presenting: deleting übergibt die Kategorie an den Dialog, sodass Button und Nachricht sie verwenden können.
  • Das eigene Binding übersetzt zwischen „ist deleting gesetzt?“ und dem Bool, das der Dialog erwartet. Schließt der Dialog, wird deleting wieder nil.

Weil die Löschregel .nullify ist, verlieren die Ausgaben nur ihre Kategorie. In der Liste erscheinen sie dann mit Fragezeichen und „Ohne Kategorie“.

Schritt 6: Nach Kategorie filtern

Zum Schluss soll die Ausgabenliste nur eine Kategorie zeigen können. Die Abfrage muss sich dafür ändern, und @Query lässt sich nur im Initializer einer View festlegen (Lektion 3.15). Also wird die Liste aufgeteilt: Die äußere View hält den Filter, die innere fragt passend ab.

Profi

Aufgabe

  1. Benenne die bisherige Liste in ExpenseList um. Sie bekommt einen Initializer mit filter: Category? und baut ihre @Query darin: ohne Filter alle Ausgaben, mit Filter nur die der Kategorie.
  2. ExpenseListView hält einen @State private var filter: Category?, den NavigationStack, die Toolbar und die Sheets. In der Toolbar gibt es ein Menu mit einem Picker für den Filter.
  3. Ist ein Filter aktiv, zeigt der Titel den Namen der Kategorie.
LösungErst selbst probieren!
Swift
struct ExpenseList: View {
    @Environment(\.modelContext) private var context
    @Query private var expenses: [Expense]
    @Binding var editing: Expense?

    init(filter: Category?, editing: Binding<Expense?>) {
        _editing = editing
        if let filter {
            let name = filter.name
            _expenses = Query(
                filter: #Predicate<Expense> { $0.category?.name == name },
                sort: \Expense.date,
                order: .reverse
            )
        } else {
            _expenses = Query(sort: \Expense.date, order: .reverse)
        }
    }

    // days, monthTotal und title(for:) wie in Teil 1

    var body: some View {
        List {
            // Monatssumme und Tagesabschnitte wie in Teil 1,
            // Zeilen als Button, die `editing` setzen
        }
        .overlay {
            if expenses.isEmpty {
                ContentUnavailableView("Keine Ausgaben", systemImage: "eurosign.circle")
            }
        }
    }
}

struct ExpenseListView: View {
    @Query(sort: \Category.name) private var categories: [Category]
    @State private var filter: Category?
    @State private var showingNew = false
    @State private var showingCategories = false
    @State private var editing: Expense?

    var body: some View {
        NavigationStack {
            ExpenseList(filter: filter, editing: $editing)
                .navigationTitle(filter?.name ?? "Ausgaben")
                .toolbar {
                    ToolbarItem(placement: .topBarLeading) {
                        Button("Kategorien", systemImage: "square.grid.2x2") {
                            showingCategories = true
                        }
                    }
                    ToolbarItem(placement: .topBarTrailing) {
                        Menu("Filter", systemImage: filter == nil
                             ? "line.3.horizontal.decrease.circle"
                             : "line.3.horizontal.decrease.circle.fill") {
                            Picker("Kategorie", selection: $filter) {
                                Text("Alle").tag(nil as Category?)
                                ForEach(categories) { category in
                                    Label(category.name, systemImage: category.icon)
                                        .tag(category as Category?)
                                }
                            }
                        }
                    }
                    ToolbarItem(placement: .primaryAction) {
                        Button("Neue Ausgabe", systemImage: "plus") {
                            showingNew = true
                        }
                    }
                }
                .sheet(isPresented: $showingNew) {
                    ExpenseForm()
                }
                .sheet(item: $editing) { expense in
                    ExpenseForm(expense: expense)
                }
                .sheet(isPresented: $showingCategories) {
                    NavigationStack { CategoryListView() }
                }
        }
    }
}
  • Ändert sich filter, baut SwiftUI ExpenseList mit dem neuen Wert, und der Initializer erzeugt eine neue Abfrage.
  • Im #Predicate wird der Name vorher in eine Konstante name kopiert. Ein Predicate kann keine Eigenschaften von Objekten außerhalb lesen, nur einfache Werte (Lektion 3.15). Weil Namen eindeutig sind (Schritt 4), ist der Filter zuverlässig.
  • Das gefüllte Filtersymbol zeigt auf einen Blick, dass ein Filter aktiv ist.
  • editing lebt jetzt in der äußeren View und wird als @Binding an die Liste gegeben. So bleibt das Sheet in der äußeren View, egal welche Liste gerade angezeigt wird.

Erwartetes Ergebnis

Über das Filtersymbol wählst du etwa „Essen gehen“. Die Liste zeigt nur noch diese Ausgaben, der Titel heißt „Essen gehen“, und die Monatssumme gilt nur für diese Kategorie. „Alle“ hebt den Filter wieder auf.

Bonus-Aufgaben

Fortgeschritten

Bonus 1: Wischen zum Duplizieren

Füge jeder Zeile eine Wischaktion von links hinzu, die die Ausgabe mit heutigem Datum dupliziert. Praktisch für wiederkehrende Ausgaben wie den täglichen Kaffee. Tipp: .swipeActions(edge: .leading).

Profi

Bonus 2: Zuletzt verwendete Kategorie

Merke dir mit @AppStorage den Namen der zuletzt verwendeten Kategorie und wähle sie im Formular für neue Ausgaben vor, statt immer der ersten.

Zusammenfassung

  • Ein Formular für Neu und Bearbeiten: optionales Modell, lokale States, Übernahme erst in save().
  • .sheet(item:) öffnet das Formular für ein bestimmtes Modell.
  • Raster mit LazyVGrid eignen sich hervorragend für Symbol- und Farbauswahl.
  • Validierung mit berechneten Eigenschaften, Hinweise im footer eines Abschnitts.
  • !== prüft bei Klassen, ob es dasselbe Objekt ist.
  • .confirmationDialog(presenting:) fragt vor dem Löschen nach und nennt die Folgen.
  • Dynamische Abfragen: Filter in der äußeren View, @Query im Initializer der inneren View.

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.