Projekt E · Teil 3 · Stufe 3 – Fortgeschritten

Ausgaben-Tracker mit SwiftData und Charts (Teil 3)

ca. 15 Minuten Lesezeit

Lernziel

In diesem Projektteil bekommt der Ausgaben-Tracker eine Auswertung: eine Tab-Leiste, einen Zeitraum-Schalter für Woche, Monat und Jahr, ein Ringdiagramm der Kategorien mit Summe in der Mitte, ein gestapeltes Balkendiagramm über die Zeit mit Durchschnittslinie und Auswahl per Finger und eine Tabelle mit Anteilen.

Ausgangslage

Nach Teil 2 kannst du Ausgaben erfassen, bearbeiten und filtern. Was noch fehlt, ist die eigentliche Frage hinter jeder Ausgaben-App: Wohin geht mein Geld?

Dafür baust du einen zweiten Bereich „Auswertung“ mit drei Bausteinen:

  1. Ein Ringdiagramm: Wie verteilen sich die Ausgaben auf die Kategorien?
  2. Ein Balkendiagramm: Wann wurde wie viel ausgegeben?
  3. Eine Tabelle: die genauen Zahlen.

Schritt 1: Tabs

Anfänger

Aufgabe

Mach aus ContentView eine TabView mit zwei Tabs: „Ausgaben“ mit dem Symbol "list.bullet" und ExpenseListView, und „Auswertung“ mit dem Symbol "chart.pie" und einer vorerst leeren StatsView in einem eigenen NavigationStack. Das Anlegen der Standardkategorien bleibt erhalten.

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

    var body: some View {
        TabView {
            Tab("Ausgaben", systemImage: "list.bullet") {
                ExpenseListView()
            }
            Tab("Auswertung", systemImage: "chart.pie") {
                NavigationStack {
                    StatsView()
                }
            }
        }
        .task {
            let count = (try? context.fetchCount(FetchDescriptor<Category>())) ?? 0
            if count == 0 {
                SampleData.insertCategories(into: context)
            }
        }
    }
}

struct StatsView: View {
    var body: some View {
        Text("Auswertung")
            .navigationTitle("Auswertung")
    }
}

ExpenseListView enthält bereits ihren eigenen NavigationStack. Wichtig ist, dass jeder Tab seinen eigenen Stack hat und nicht ein Stack um die ganze TabView liegt (Lektion 3.21).

Schritt 2: Der Zeitraum

Fortgeschritten

Aufgabe

Leg ein Enum Period mit den Fällen week, month und year an (String-Rohwerte „Woche“, „Monat“, „Jahr“, CaseIterable, Identifiable). Es bekommt:

  1. interval: DateInterval: den aktuellen Zeitraum, also diese Woche, diesen Monat oder dieses Jahr.
  2. unit: Calendar.Component: wie die Balken zusammengefasst werden. Bei Woche und Monat pro Tag, beim Jahr pro Monat.

In StatsView lädst du alle Ausgaben mit @Query, wählst den Zeitraum mit einem segmentierten Picker und berechnest die Ausgaben im Zeitraum und ihre Summe.

LösungErst selbst probieren!
Swift
enum Period: String, CaseIterable, Identifiable {
    case week = "Woche"
    case month = "Monat"
    case year = "Jahr"

    var id: Self { self }

    var interval: DateInterval {
        let component: Calendar.Component = switch self {
        case .week: .weekOfYear
        case .month: .month
        case .year: .year
        }
        return Calendar.current.dateInterval(of: component, for: .now)!
    }

    var unit: Calendar.Component {
        self == .year ? .month : .day
    }

    var currentTitle: String {
        switch self {
        case .week: "Diese Woche"
        case .month: "Diesen Monat"
        case .year: "Dieses Jahr"
        }
    }
}

Calendar.current.dateInterval(of: .month, for: .now) liefert Anfang und Ende des aktuellen Monats, unter Berücksichtigung der Ländereinstellungen. In Deutschland beginnt die Woche zum Beispiel am Montag, in den USA am Sonntag.

Swift
struct StatsView: View {
    @Query private var expenses: [Expense]
    @State private var period = Period.month

    private var filtered: [Expense] {
        let interval = period.interval
        return expenses.filter { interval.contains($0.date) }
    }

    private var total: Double {
        filtered.reduce(0) { $0 + $1.amount }
    }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 24) {
                Picker("Zeitraum", selection: $period) {
                    ForEach(Period.allCases) { period in
                        Text(period.rawValue).tag(period)
                    }
                }
                .pickerStyle(.segmented)

                VStack(alignment: .leading) {
                    Text(period.currentTitle)
                        .foregroundStyle(.secondary)
                    Text(total, format: .currency(code: "EUR"))
                        .font(.largeTitle.bold())
                }
            }
            .padding()
        }
        .navigationTitle("Auswertung")
    }
}

Hier wird bewusst in Swift gefiltert statt mit einem #Predicate: Alle Diagramme brauchen dieselben Daten, und für ein paar tausend Ausgaben ist das schnell genug.

Texte gehören ins Enum

„Diese Woche“, „Diesen Monat“, „Dieses Jahr“: Die Endung hängt vom Wort ab. Statt das in der View mit verschachtelten Bedingungen zusammenzubauen, liefert currentTitle im Enum den fertigen Text. Alles, was vom Zeitraum abhängt, steht so an einer Stelle.

Schritt 3: Summen pro Kategorie

Beide Diagramme und die Tabelle brauchen die Ausgaben zusammengefasst nach Kategorie.

Fortgeschritten

Aufgabe

  1. Leg ein struct CategorySum: Identifiable an mit name, icon, color, total und count. Die id ist der Name.
  2. Berechne in StatsView ein Array sums aus den gefilterten Ausgaben, gruppiert nach Kategoriename, absteigend nach Summe sortiert. Ausgaben ohne Kategorie landen in „Ohne Kategorie“ mit grauer Farbe.
LösungErst selbst probieren!
Swift
struct CategorySum: Identifiable {
    let name: String
    let icon: String
    let color: Color
    let total: Double
    let count: Int

    var id: String { name }
}
Swift
private var sums: [CategorySum] {
    let groups = Dictionary(grouping: filtered) { $0.category?.name ?? "Ohne Kategorie" }

    return groups.map { name, items in
        let category = items.first?.category
        return CategorySum(
            name: name,
            icon: category?.icon ?? "questionmark",
            color: category?.tint.color ?? .gray,
            total: items.reduce(0) { $0 + $1.amount },
            count: items.count
        )
    }
    .sorted { $0.total > $1.total }
}

Dasselbe Muster wie bei den Tagen in Teil 1: gruppieren mit Dictionary(grouping:by:), in ein Array umwandeln, sortieren. Die Kategorie selbst holt sich die Funktion von der ersten Ausgabe der Gruppe, denn alle Ausgaben einer Gruppe haben dieselbe.

Schritt 4: Das Ringdiagramm

Fortgeschritten

Aufgabe

Zeig unter der Summe ein Ringdiagramm der Kategorien (Lektion 3.31). Jede Kategorie bekommt ihre eigene Farbe aus sums, nicht die automatischen Farben von Swift Charts. In der Mitte steht die Anzahl der Ausgaben. Gibt es im Zeitraum keine Ausgaben, zeig stattdessen eine ContentUnavailableView.

LösungErst selbst probieren!
Swift
import Charts
Swift
@ViewBuilder
private var categoryChart: some View {
    if sums.isEmpty {
        ContentUnavailableView("Keine Ausgaben", systemImage: "chart.pie",
                               description: Text("In diesem Zeitraum wurde nichts erfasst."))
    } else {
        Chart(sums) { sum in
            SectorMark(
                angle: .value("Betrag", sum.total),
                innerRadius: .ratio(0.62),
                angularInset: 2
            )
            .cornerRadius(4)
            .foregroundStyle(by: .value("Kategorie", sum.name))
        }
        .chartForegroundStyleScale(domain: sums.map(\.name), range: sums.map(\.color))
        .chartLegend(.hidden)
        .chartBackground { _ in
            VStack {
                Text("\(filtered.count)")
                    .font(.title.bold())
                Text(filtered.count == 1 ? "Ausgabe" : "Ausgaben")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
        .frame(height: 220)
    }
}

Im VStack von body unter der Summe einfach categoryChart.

  • .chartForegroundStyleScale(domain:range:) ordnet jedem Namen die Farbe an derselben Position zu. So hat „Lebensmittel“ im Diagramm genau das Grün, das die Kategorie in der Liste hat.
  • Die Legende wird ausgeblendet, weil die Tabelle in Schritt 6 die Kategorien ohnehin mit Farbe aufführt.
  • @ViewBuilder erlaubt das if in der berechneten Eigenschaft.

Erwartetes Ergebnis

Ein farbiger Ring mit einem Stück pro Kategorie in ihrer eigenen Farbe, in der Mitte die Anzahl der Ausgaben. Beim Wechsel zwischen Woche, Monat und Jahr ändern sich Ring und Summe.

Screenshot folgt: Auswertung mit Zeitraum-Schalter, Summe und Ringdiagramm

Schritt 5: Balken über die Zeit

Profi

Aufgabe

Zeig unter dem Ring ein Balkendiagramm über den ganzen Zeitraum:

  1. Ein Balken pro Tag (bei Jahr: pro Monat), gestapelt nach Kategorie, mit denselben Farben wie der Ring.
  2. Die x-Achse zeigt immer den ganzen Zeitraum, auch Tage ohne Ausgaben.
  3. Beschriftung: bei Woche jeder Tag als Kurzname, bei Monat jeder siebte Tag als Zahl, beim Jahr jeder Monat als Anfangsbuchstabe.
  4. Eine gestrichelte Linie zeigt den Durchschnitt pro Tag bzw. Monat mit Ausgaben.
  5. Streicht der Finger über das Diagramm, steht über dem Diagramm die Summe des gewählten Tages bzw. Monats, sonst der Durchschnitt.
LösungErst selbst probieren!

Zuerst bekommt Period die Angaben für die Achse:

Swift
extension Period {
    var axisStride: Calendar.Component {
        self == .year ? .month : .day
    }

    var axisCount: Int {
        self == .month ? 7 : 1
    }

    var axisFormat: Date.FormatStyle {
        switch self {
        case .week: .dateTime.weekday(.abbreviated)
        case .month: .dateTime.day()
        case .year: .dateTime.month(.narrow)
        }
    }
}

In StatsView:

Swift
@State private var selectedDate: Date?

private var bucketTotals: [Date: Double] {
    let calendar = Calendar.current
    let groups = Dictionary(grouping: filtered) { expense in
        calendar.dateInterval(of: period.unit, for: expense.date)!.start
    }
    return groups.mapValues { items in items.reduce(0) { $0 + $1.amount } }
}

private var average: Double {
    bucketTotals.isEmpty ? 0 : total / Double(bucketTotals.count)
}

private var selectedTotal: Double? {
    guard let selectedDate else { return nil }
    let start = Calendar.current.dateInterval(of: period.unit, for: selectedDate)!.start
    return bucketTotals[start] ?? 0
}

private var timeChart: some View {
    VStack(alignment: .leading, spacing: 8) {
        if let selectedDate, let selectedTotal {
            Text("\(selectedDate.formatted(period == .year ? .dateTime.month(.wide) : .dateTime.day().month())): \(selectedTotal.formatted(.currency(code: "EUR")))")
                .font(.subheadline.bold())
        } else {
            Text("Ø \(average.formatted(.currency(code: "EUR"))) pro \(period == .year ? "Monat" : "Tag")")
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }

        Chart {
            ForEach(filtered) { expense in
                BarMark(
                    x: .value("Datum", expense.date, unit: period.unit),
                    y: .value("Betrag", expense.amount)
                )
                .foregroundStyle(by: .value("Kategorie", expense.category?.name ?? "Ohne Kategorie"))
            }

            if average > 0 {
                RuleMark(y: .value("Durchschnitt", average))
                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [4]))
                    .foregroundStyle(.secondary)
            }
        }
        .chartForegroundStyleScale(domain: sums.map(\.name), range: sums.map(\.color))
        .chartLegend(.hidden)
        .chartXScale(domain: period.interval.start...period.interval.end)
        .chartXAxis {
            AxisMarks(values: .stride(by: period.axisStride, count: period.axisCount)) { _ in
                AxisGridLine()
                AxisValueLabel(format: period.axisFormat)
            }
        }
        .chartXSelection(value: $selectedDate)
        .frame(height: 200)
    }
}

Im body unter categoryChart, aber nur wenn es Ausgaben gibt: if !sums.isEmpty { timeChart }.

Was hier passiert:

  • unit: period.unit fasst alle Ausgaben eines Tages bzw. Monats zu einem Balken zusammen. Swift Charts stapelt dabei automatisch nach Kategorie.
  • .chartXScale(domain:) legt die Achse auf den ganzen Zeitraum fest. Ohne diese Angabe würde die Achse beim ersten und letzten Tag mit Ausgaben beginnen und enden.
  • bucketTotals fasst die Summen pro Tag bzw. Monat in einem Dictionary zusammen. Der Schlüssel ist der Anfang des Tages bzw. Monats. Daraus ergeben sich Durchschnitt und die Summe für die Auswahl.
  • .chartXSelection(value: $selectedDate) liefert das Datum unter dem Finger. dateInterval(of:for:) macht daraus den Anfang des Tages, damit es zum Schlüssel im Dictionary passt.

Warum der Durchschnitt nur Tage mit Ausgaben zählt

Am 3. des Monats durch 31 Tage zu teilen, ergäbe einen viel zu niedrigen Wert. Mit den Tagen mit Ausgaben bekommst du eine ehrliche Antwort auf die Frage: „Wenn ich Geld ausgebe, wie viel ungefähr?“ Eine Alternative wäre, durch die bisher vergangenen Tage des Zeitraums zu teilen. Beides ist vertretbar, wichtig ist, dass die Beschriftung sagt, was gemeint ist.

Schritt 6: Die Tabelle

Fortgeschritten

Aufgabe

Zeig unter den Diagrammen mit Grid (Lektion 3.17) eine Tabelle mit den Spalten Kategorie (mit farbigem Symbol), Anzahl, Summe und Anteil in Prozent. Die Zahlen sind rechtsbündig. Unten steht eine Summenzeile über eine Trennlinie.

LösungErst selbst probieren!
Swift
private var table: some View {
    Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
        GridRow {
            Text("Kategorie")
            Text("Anz.").gridColumnAlignment(.trailing)
            Text("Summe").gridColumnAlignment(.trailing)
            Text("Anteil").gridColumnAlignment(.trailing)
        }
        .font(.caption)
        .foregroundStyle(.secondary)

        ForEach(sums) { sum in
            GridRow {
                Label {
                    Text(sum.name).lineLimit(1)
                } icon: {
                    Image(systemName: sum.icon)
                        .foregroundStyle(sum.color)
                }
                Text("\(sum.count)")
                Text(sum.total, format: .currency(code: "EUR"))
                Text(sum.total / total, format: .percent.precision(.fractionLength(0)))
                    .foregroundStyle(.secondary)
            }
            .monospacedDigit()
        }

        Divider()

        GridRow {
            Text("Gesamt").bold()
            Text("\(filtered.count)")
            Text(total, format: .currency(code: "EUR")).bold()
            Text("100 %").foregroundStyle(.secondary)
        }
        .monospacedDigit()
    }
}

Im body unter timeChart, ebenfalls nur bei vorhandenen Ausgaben.

  • .gridColumnAlignment(.trailing) in der Kopfzeile richtet die ganze Spalte rechtsbündig aus.
  • .percent.precision(.fractionLength(0)) zeigt Anteile ohne Nachkommastellen. Der Wert muss dafür zwischen 0 und 1 liegen: 0.42 wird zu „42 %“.
  • Die Reihenfolge ist dieselbe wie im Ring: größte Kategorie zuerst.

Erwartetes Ergebnis

Die Auswertung zeigt untereinander: die Summe, den farbigen Ring, das Balkendiagramm mit Durchschnittslinie und die Tabelle. Streichst du über die Balken, erscheint oben die Summe des Tages. Wechselst du zu „Jahr“, gibt es zwölf Monatsbalken.

Screenshot folgt: Balkendiagramm über den Monat und Tabelle mit Anteilen

Bonus-Aufgaben

Fortgeschritten

Bonus 1: Vergleich zum Vorzeitraum

Zeig unter der Summe, ob du im Vergleich zum vorherigen Zeitraum mehr oder weniger ausgegeben hast, etwa „12 % weniger als letzten Monat“, grün bei weniger, rot bei mehr. Tipp: Calendar.current.date(byAdding: .month, value: -1, to: .now).

Profi

Bonus 2: Vom Diagramm in die Liste

Ein Tipp auf eine Zeile der Tabelle soll zum Tab „Ausgaben“ wechseln und dort den Filter auf diese Kategorie setzen. Tipp: Tab-Auswahl und Filter müssen dafür in ContentView liegen und nach unten gereicht werden (Lektion 3.21).

Zusammenfassung

  • Eine TabView trennt Erfassen und Auswerten, jeder Tab mit eigenem NavigationStack.
  • Ein Period-Enum bündelt alles, was vom Zeitraum abhängt: Intervall, Einheit und Achsenformat.
  • Calendar.dateInterval(of:for:) liefert Anfang und Ende von Woche, Monat oder Jahr.
  • Dictionary(grouping:by:) fasst Ausgaben nach Kategorie und nach Tag bzw. Monat zusammen.
  • .chartForegroundStyleScale(domain:range:) gibt jeder Kategorie im Diagramm ihre eigene Farbe.
  • .chartXScale(domain:) zeigt den ganzen Zeitraum, .chartXSelection die Summe unter dem Finger.
  • Grid mit rechtsbündigen Spalten für die Zahlentabelle.

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.