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:
- Ein Ringdiagramm: Wie verteilen sich die Ausgaben auf die Kategorien?
- Ein Balkendiagramm: Wann wurde wie viel ausgegeben?
- Eine Tabelle: die genauen Zahlen.
Schritt 1: Tabs
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!
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
Aufgabe
Leg ein Enum Period mit den Fällen week, month und year an (String-Rohwerte „Woche“, „Monat“, „Jahr“, CaseIterable, Identifiable). Es bekommt:
interval: DateInterval: den aktuellen Zeitraum, also diese Woche, diesen Monat oder dieses Jahr.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!
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.
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.
Aufgabe
- Leg ein
struct CategorySum: Identifiablean mitname,icon,color,totalundcount. Dieidist der Name. - Berechne in
StatsViewein Arraysumsaus den gefilterten Ausgaben, gruppiert nach Kategoriename, absteigend nach Summe sortiert. Ausgaben ohne Kategorie landen in „Ohne Kategorie“ mit grauer Farbe.
LösungErst selbst probieren!
struct CategorySum: Identifiable {
let name: String
let icon: String
let color: Color
let total: Double
let count: Int
var id: String { name }
}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
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!
import Charts@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.
@ViewBuildererlaubt dasifin 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.
Schritt 5: Balken über die Zeit
Aufgabe
Zeig unter dem Ring ein Balkendiagramm über den ganzen Zeitraum:
- Ein Balken pro Tag (bei Jahr: pro Monat), gestapelt nach Kategorie, mit denselben Farben wie der Ring.
- Die x-Achse zeigt immer den ganzen Zeitraum, auch Tage ohne Ausgaben.
- Beschriftung: bei Woche jeder Tag als Kurzname, bei Monat jeder siebte Tag als Zahl, beim Jahr jeder Monat als Anfangsbuchstabe.
- Eine gestrichelte Linie zeigt den Durchschnitt pro Tag bzw. Monat mit Ausgaben.
- 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:
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:
@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.unitfasst 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.bucketTotalsfasst 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
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!
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.42wird 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.
Bonus-Aufgaben
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).
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
TabViewtrennt Erfassen und Auswerten, jeder Tab mit eigenemNavigationStack. - 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,.chartXSelectiondie Summe unter dem Finger.Gridmit rechtsbündigen Spalten für die Zahlentabelle.