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.
| Teil | Inhalt | Lektionen |
|---|---|---|
| 1 | Datenmodell, Beispieldaten, Liste der Ausgaben | 3.14–3.16 |
| 2 | Erfassen, Bearbeiten, Kategorien verwalten, Filtern | 3.15, 3.16, 3.17 |
| 3 | Auswertung mit Swift Charts und Tabs | 3.17, 3.21, 3.31 |
| 4 | Animationen und ein eigenes kleines Designsystem | 3.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
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:
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).
Aufgabe
- Leg ein Enum
CategoryTintan (String,Codable,CaseIterable) mit den Fällenorange,blue,green,purple,pink,teal,redundgrayund einer Eigenschaftcolor: Color. - Leg ein Modell
Categoryan mitname,icon(Name eines SF Symbols),tint: CategoryTintund einer Beziehungexpenses: [Expense]. Beim Löschen einer Kategorie sollen ihre Ausgaben erhalten bleiben. - Leg ein Modell
Expensean mittitle,amount: Double,date: Date,note: Stringund einer optionalencategory.
LösungErst selbst probieren!
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.tintist ein Enum mitString-Rohwert undCodable. Solche Enums kann SwiftData direkt speichern. Die SwiftUI-Farbe selbst ließe sich nicht speichern, deshalb der Umweg über das Enum.finalheiß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
Aufgabe
Richte in der App-Struktur einen Model-Container für beide Modelle ein.
LösungErst selbst probieren!
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.
Aufgabe
- Leg ein Enum
SampleDataohne Fälle an mit einer FunktioninsertCategories(into:), die sechs Standardkategorien anlegt und zurückgibt, und einer FunktioninsertExamples(into:), die die Kategorien und dazu 40 zufällige Ausgaben aus den letzten 60 Tagen anlegt. - Leg einen
previewContaineran, der nur im Arbeitsspeicher liegt und mit den Beispielen gefüllt ist (Lektion 3.14).
LösungErst selbst probieren!
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
}()@discardableResulterlaubt,insertCategoriesaufzurufen, ohne das Ergebnis zu verwenden. Sonst gäbe es eine Warnung.(x * 100).rounded() / 100rundet auf zwei Nachkommastellen, damit die Beispielbeträge wie echte Preise aussehen.- Die
!beirandomElement()unddate(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
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!
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
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!
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.
Aufgabe
Baue ExpenseListView:
- Lade alle Ausgaben mit
@Query, neueste zuerst. - Gruppiere sie nach Tag. Tipp:
Dictionary(grouping:by:)mitCalendar.current.startOfDay(for:). - Zeig jeden Tag als
Sectionmit dem Datum als Überschrift (heute als „Heute“, gestern als „Gestern“) und der Tagessumme rechts. - Einträge sollen sich per Wischen löschen lassen.
LösungErst selbst probieren!
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 nichtIdentifiable, 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.
Schritt 8: Monatssumme und leerer Zustand
Aufgabe
- Zeig ganz oben in der Liste einen eigenen Abschnitt mit der Summe des aktuellen Monats, groß und fett.
- Gibt es noch keine Ausgaben, zeig statt der Liste einen leeren Zustand mit
ContentUnavailableView(Lektion 2.15).
LösungErst selbst probieren!
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 ...):
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:
.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
.overlayliegt ü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
Bonus 1: Notiz anzeigen
Hat eine Ausgabe eine Notiz, zeig in der Zeile ein kleines Notiz-Symbol neben dem Titel.
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.nullifyals Löschregel. - Ein
Codable-Enum speichert die Farbe einer Kategorie. SampleDatalegt Standardkategorien und Beispielausgaben an, einpreviewContainerim Arbeitsspeicher versorgt die Previews.fetchCountprüft beim Start, ob schon Kategorien existieren.Dictionary(grouping:by:)mitstartOfDaygruppiert Ausgaben nach Tagen.ContentUnavailableViewim.overlayzeigt einen freundlichen leeren Zustand.