Projekt E · Teil 4 · Stufe 3 – Fortgeschritten

Ausgaben-Tracker mit SwiftData und Charts (Teil 4)

ca. 15 Minuten Lesezeit

Lernziel

In diesem Projektteil gibst du dem Ausgaben-Tracker den letzten Schliff: ein kleines Designsystem mit Markenfarbe, Abständen und Karten, rollende Beträge, einen eigenen Zeitraum-Schalter mit wanderndem Hintergrund, animierte Diagramme, einen schwebenden Glas-Button, haptisches Feedback, ein wackelndes Formular bei ungültigen Eingaben und Zeilen, die mit großer Schrift mitwachsen.

Ausgangslage

Nach Teil 3 kann die App alles, was sie können soll. Jetzt geht es darum, wie sie sich anfühlt. Dafür kommen die Werkzeuge aus den Lektionen 3.23 bis 3.30 zum Einsatz.

Eine Warnung vorweg: Animationen und Effekte machen Spaß, und es ist verlockend, alles zu bewegen. Jede Änderung in diesem Teil hat einen Zweck: Sie erklärt etwas, gibt Rückmeldung oder macht die App lesbarer. Halte dich beim eigenen Weiterbauen an dieselbe Regel.

Schritt 1: Das Designsystem

Fortgeschritten

Aufgabe

  1. Leg im Asset-Katalog eine Farbe BrandPrimary mit Hell- und Dunkelvariante an, etwa ein frisches Grün, und trag dieselben Werte in AccentColor ein.
  2. Leg einen Ordner DesignSystem an mit Spacing und Radius als Enums ohne Fälle (Lektion 3.29).
  3. Baue eine Container-View Card, die beliebigen Inhalt mit Innenabstand, Hintergrund secondarySystemGroupedBackground und runden Ecken umgibt.
LösungErst selbst probieren!

Im Asset-Katalog: + → Color Set → BrandPrimary, Appearances „Any, Dark“, zum Beispiel #2E9E5B für hell und #4CC97D für dunkel. Dieselben Werte für AccentColor.

Swift
import SwiftUI

enum Spacing {
    static let xs: CGFloat = 4
    static let s: CGFloat = 8
    static let m: CGFloat = 16
    static let l: CGFloat = 24
}

enum Radius {
    static let small: CGFloat = 8
    static let medium: CGFloat = 16
}

struct Card<Content: View>: View {
    @ViewBuilder let content: Content

    var body: some View {
        VStack(alignment: .leading, spacing: Spacing.s) {
            content
        }
        .padding(Spacing.m)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Color(.secondarySystemGroupedBackground),
                    in: .rect(cornerRadius: Radius.medium))
    }
}

secondarySystemGroupedBackground ist die Systemfarbe für Karten auf einem gruppierten Hintergrund: weiß auf hellgrauem Grund im hellen Modus, dunkelgrau auf Schwarz im dunklen.

Schritt 2: Die Auswertung in Karten

Anfänger

Aufgabe

Pack die Bausteine der Auswertung in Karten: Summe und Ring in eine, das Balkendiagramm in eine, die Tabelle in eine. Der Hintergrund des ganzen Bildschirms ist systemGroupedBackground. Ersetze alle festen Abstände durch die Konstanten aus Spacing.

LösungErst selbst probieren!
Swift
var body: some View {
    ScrollView {
        VStack(alignment: .leading, spacing: Spacing.m) {
            PeriodPicker(selection: $period)

            Card {
                Text(period.currentTitle)
                    .foregroundStyle(.secondary)
                AmountText(value: total)
                    .font(.largeTitle.bold())
                categoryChart
            }

            if !sums.isEmpty {
                Card { timeChart }
                Card { table }
            }
        }
        .padding(Spacing.m)
    }
    .background(Color(.systemGroupedBackground))
    .navigationTitle("Auswertung")
}

PeriodPicker und AmountText baust du in den nächsten beiden Schritten. Bis dahin kannst du den segmentierten Picker und den bisherigen Text stehen lassen.

Erwartetes Ergebnis

Die Auswertung besteht jetzt aus drei abgesetzten Karten auf grauem Grund, im Stil der Einstellungen-App. Im Dark Mode sind die Karten dunkelgrau auf Schwarz.

Screenshot folgt: Auswertung mit drei Karten auf gruppiertem Hintergrund

Schritt 3: Rollende Beträge

Ändert sich eine Summe, etwa beim Wechsel des Zeitraums, springt die Zahl bisher einfach um. Schöner und verständlicher ist es, wenn die Ziffern rollen (Lektion 3.23).

Fortgeschritten

Aufgabe

Baue eine Komponente AmountText, die einen Betrag als Euro-Wert anzeigt, mit gleich breiten Ziffern und rollendem Übergang. Die Animation soll in der Komponente selbst stecken, sodass jede Änderung des Werts animiert ist, egal woher sie kommt. Verwende sie für die Summe in der Auswertung und die Monatssumme in der Liste.

LösungErst selbst probieren!
Swift
struct AmountText: View {
    let value: Double

    var body: some View {
        Text(value, format: .currency(code: "EUR"))
            .monospacedDigit()
            .contentTransition(.numericText(value: value))
            .animation(.snappy, value: value)
    }
}
  • .contentTransition(.numericText(value:)) lässt die Ziffern rollen, aufwärts bei steigenden und abwärts bei fallenden Werten.
  • Die implizite Animation .animation(.snappy, value: value) sorgt dafür, dass jede Änderung animiert wird: beim Zeitraumwechsel, nach dem Erfassen einer Ausgabe, nach dem Löschen. Das ist genau der Fall aus Lektion 3.23, in dem implizite Animationen ihre Stärke haben.

In ExpenseList wird die Monatssumme zu:

Swift
AmountText(value: monthTotal)
    .font(.largeTitle.bold())

Schritt 4: Ein eigener Zeitraum-Schalter

Der segmentierte Picker funktioniert, sieht aber aus wie überall. Ein eigener Schalter mit einer Pille, die zur Auswahl gleitet, gibt der App Charakter (Lektion 3.25).

Profi

Aufgabe

  1. Baue PeriodPicker mit einem @Binding var selection: Period. Jede Option ist ein Button, hinter der gewählten liegt eine Kapsel in der Markenfarbe, die mit matchedGeometryEffect zur neuen Auswahl gleitet.
  2. Der Zeitraumwechsel ist mit withAnimation animiert, sodass auch die Diagramme sanft in den neuen Zustand übergehen.
  3. Die Buttons sollen für VoiceOver als ausgewählt erkennbar sein. Tipp: .accessibilityAddTraits(.isSelected).
LösungErst selbst probieren!
Swift
struct PeriodPicker: View {
    @Binding var selection: Period
    @Namespace private var namespace

    var body: some View {
        HStack(spacing: Spacing.xs) {
            ForEach(Period.allCases) { period in
                Button {
                    withAnimation(.snappy) {
                        selection = period
                    }
                } label: {
                    Text(period.rawValue)
                        .font(.subheadline.weight(.semibold))
                        .foregroundStyle(period == selection ? Color.white : Color.primary)
                        .padding(.vertical, Spacing.s)
                        .frame(maxWidth: .infinity)
                        .background {
                            if period == selection {
                                Capsule()
                                    .fill(.brandPrimary)
                                    .matchedGeometryEffect(id: "selection", in: namespace)
                            }
                        }
                        .contentShape(.capsule)
                }
                .buttonStyle(.plain)
                .accessibilityAddTraits(period == selection ? .isSelected : [])
            }
        }
        .padding(Spacing.xs)
        .background(Color(.secondarySystemGroupedBackground), in: .capsule)
    }
}
  • Weil der Zustandswechsel in withAnimation steht, wird alles animiert, was davon abhängt: die Pille gleitet, die Summe rollt, die Ringstücke wachsen und schrumpfen, und die Balken ordnen sich neu.
  • .buttonStyle(.plain) verhindert die Standard-Einfärbung, die Gestaltung übernimmt das Label.
  • .accessibilityAddTraits(.isSelected) lässt VoiceOver „ausgewählt“ ansagen, wie bei einem echten Picker. [] heißt: keine zusätzlichen Eigenschaften.

Erwartetes Ergebnis

Ein Tipp auf „Jahr“ lässt die grüne Pille hinübergleiten. Gleichzeitig rollt die Summe hoch, die Ringstücke verändern ihre Größe, und aus den Tagesbalken werden Monatsbalken.

Schritt 5: Der schwebende Button

Das Erfassen ist die wichtigste Aktion der App. Statt eines kleinen Plus oben rechts bekommt sie einen großen, schwebenden Button unten, gut mit dem Daumen erreichbar.

Fortgeschritten

Aufgabe

  1. Ersetze den Plus-Button in der Toolbar von ExpenseListView durch einen runden Button unten rechts über der Liste, im Stil .glassProminent (Lektion 3.30).
  2. Wird eine neue Ausgabe gespeichert, soll das iPhone mit .sensoryFeedback kurz eine Erfolgs-Vibration geben. Tipp: Die Anzahl der Ausgaben steigt.
LösungErst selbst probieren!

In ExpenseListView am ExpenseList:

Swift
.overlay(alignment: .bottomTrailing) {
    Button {
        showingNew = true
    } label: {
        Image(systemName: "plus")
            .font(.title2.weight(.semibold))
            .frame(width: 56, height: 56)
    }
    .buttonStyle(.glassProminent)
    .buttonBorderShape(.circle)
    .accessibilityLabel("Neue Ausgabe")
    .padding(Spacing.l)
}

In ExpenseList:

Swift
.sensoryFeedback(.success, trigger: expenses.count) { oldCount, newCount in
    newCount > oldCount
}
  • .overlay(alignment: .bottomTrailing) legt den Button über die Liste in die Ecke unten rechts.
  • .buttonBorderShape(.circle) macht den Glas-Button rund.
  • Weil der Button nur ein Symbol zeigt, bekommt er mit .accessibilityLabel einen Namen für VoiceOver.
  • Die Variante von .sensoryFeedback mit Bedingung vibriert nur, wenn die neue Anzahl größer ist. Beim Löschen bleibt es still.

Tipp

Unterstützt deine App iOS-Versionen vor 26, verwende .buttonStyle(.borderedProminent) als Ersatz: if #available(iOS 26, *) mit zwei Varianten des Buttons.

Schritt 6: Das Formular wackelt

Ein ausgegrauter „Sichern“-Button sagt dem Nutzer nicht, was fehlt. Eine verbreitete Alternative: Der Button bleibt aktiv, und bei ungültigen Eingaben wackeln die betroffenen Felder kurz, wie beim falschen iPhone-Code.

Profi

Aufgabe

  1. Baue einen Modifier .shake(trigger:), der eine View mit einem keyframeAnimator (Lektion 3.26) kurz nach links und rechts wackeln lässt, wann immer sich der Int-Trigger ändert.
  2. In ExpenseForm ist „Sichern“ immer aktiv. Ist die Eingabe ungültig, wird ein Zähler attempts erhöht statt gespeichert. Titel- und Betragsfeld wackeln, und das iPhone gibt eine Fehler-Vibration.
  3. Leere Pflichtfelder werden nach dem ersten Versuch rot beschriftet.
LösungErst selbst probieren!
Swift
struct Shake: ViewModifier {
    let trigger: Int

    func body(content: Content) -> some View {
        content
            .keyframeAnimator(initialValue: 0.0, trigger: trigger) { view, offset in
                view.offset(x: offset)
            } keyframes: { _ in
                CubicKeyframe(10, duration: 0.05)
                CubicKeyframe(-10, duration: 0.08)
                CubicKeyframe(7, duration: 0.08)
                CubicKeyframe(-4, duration: 0.08)
                CubicKeyframe(0, duration: 0.08)
            }
    }
}

extension View {
    func shake(trigger: Int) -> some View {
        modifier(Shake(trigger: trigger))
    }
}

Im Unterschied zum Modifier aus Lektion 3.28 hat dieser keinen eigenen Zustand: Der Auslöser kommt von außen. So entscheidet das Formular, wann gewackelt wird.

In ExpenseForm:

Swift
@State private var attempts = 0

private var titleMissing: Bool {
    title.trimmingCharacters(in: .whitespaces).isEmpty
}

Die Felder:

Swift
Section {
    TextField("Wofür?", text: $title)
        .shake(trigger: titleMissing ? attempts : 0)
    TextField("Betrag", value: $amount, format: .currency(code: "EUR"))
        .keyboardType(.decimalPad)
        .shake(trigger: amount <= 0 ? attempts : 0)
} footer: {
    if attempts > 0 && !isValid {
        Text("Bitte gib an, wofür und wie viel du ausgegeben hast.")
            .foregroundStyle(.red)
    }
}

Der Button, jetzt ohne .disabled:

Swift
Button("Sichern") {
    if isValid {
        save()
        dismiss()
    } else {
        attempts += 1
    }
}

Und an der Form:

Swift
.sensoryFeedback(.error, trigger: attempts)

Der Trick bei .shake(trigger: titleMissing ? attempts : 0): Ist der Titel ausgefüllt, bleibt der Trigger immer 0 und ändert sich nicht, das Feld wackelt also nicht. Fehlt der Titel, ist der Trigger attempts und ändert sich bei jedem Versuch. So wackeln nur die Felder, die wirklich fehlen.

Schritt 7: Große Schrift und Dark Mode

Zum Schluss der wichtigste Test: Funktioniert die App für alle (Lektion 3.30)?

Fortgeschritten

Aufgabe

  1. Lass in ExpenseRow den Kreis mit dem Symbol mit @ScaledMetric mitwachsen.
  2. Ab den Bedienungshilfe-Größen stehen Titel und Betrag untereinander statt nebeneinander. Verwende AnyLayout.
  3. Prüfe alle Bildschirme mit „Color Scheme Variants“ und „Dynamic Type Variants“ in der Vorschau und behebe, was dir auffällt.
LösungErst selbst probieren!
Swift
struct ExpenseRow: View {
    let expense: Expense

    @ScaledMetric private var iconSize = 36.0
    @Environment(\.dynamicTypeSize) private var typeSize

    var body: some View {
        HStack(spacing: Spacing.m - 4) {
            Image(systemName: expense.category?.icon ?? "questionmark")
                .font(.system(size: iconSize * 0.45, weight: .semibold))
                .foregroundStyle(.white)
                .frame(width: iconSize, height: iconSize)
                .background(expense.category?.tint.color ?? .gray, in: .circle)

            let layout = typeSize.isAccessibilitySize
                ? AnyLayout(VStackLayout(alignment: .leading, spacing: Spacing.xs))
                : AnyLayout(HStackLayout())

            layout {
                VStack(alignment: .leading, spacing: 2) {
                    Text(expense.title)
                    Text(expense.category?.name ?? "Ohne Kategorie")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Spacer(minLength: 0)
                Text(expense.amount, format: .currency(code: "EUR"))
                    .monospacedDigit()
            }
        }
    }
}

#Preview(traits: .sizeThatFitsLayout) {
    let expense = Expense(title: "Wocheneinkauf", amount: 64.2,
                          category: Category(name: "Lebensmittel", icon: "cart.fill", tint: .green))
    return ExpenseRow(expense: expense)
        .padding()
        .modelContainer(previewContainer)
}

Typische Funde bei der Prüfung und ihre Lösung:

  • Abgeschnittene Kategorienamen in der Tabelle der Auswertung: .lineLimit(1) durch .lineLimit(2) ersetzen oder bei Bedienungshilfe-Größen die Spalte „Anz.“ ausblenden.
  • Achsenbeschriftung im Balkendiagramm überlappt bei großer Schrift: Swift Charts lässt dann Beschriftungen weg, das ist in Ordnung.
  • Weiß im Dark Mode: Wo noch Color.white als Hintergrund steht, durch Systemfarben ersetzen. Die Schrift auf farbigen Kreisen darf weiß bleiben, weil die Kreise in beiden Modi farbig sind.

Erwartetes Ergebnis

Mit der größten Schrift wächst der Kreis mit, und Titel und Betrag stehen untereinander, statt abgeschnitten zu werden. Im Dark Mode passen sich Karten, Hintergründe und die Markenfarbe an.

Screenshot folgt: Ausgabenliste im Dark Mode mit großer Schrift

Geschafft!

Dein Ausgaben-Tracker ist fertig. Schau zurück, was darin steckt:

  • SwiftData mit zwei verknüpften Modellen, dynamischen Abfragen und sicherem Löschen
  • Formulare mit Validierung für Neu und Bearbeiten
  • Raster für Symbol- und Farbauswahl
  • Tabs für Erfassen und Auswerten
  • Swift Charts mit Ring, gestapelten Balken, Durchschnitt und Auswahl
  • ein Designsystem mit Markenfarbe, Abständen und Komponenten
  • Animationen mit Zweck: rollende Zahlen, gleitende Auswahl, wackelnde Felder
  • Haptik, Liquid Glass, Dark Mode und Dynamic Type

Das ist eine App, die du in den App Store stellen könntest. Wie das geht, lernst du am Ende von Stufe 4.

Bonus-Aufgaben

Fortgeschritten

Bonus 1: Budget

Lass den Nutzer in einem Einstellungs-Sheet ein Monatsbudget festlegen und mit @AppStorage speichern. Zeig unter der Monatssumme einen Fortschrittsbalken, der grün ist, ab 80 % orange und über 100 % rot wird. Die Farbe wechselt animiert.

Profi

Bonus 2: Export

Füge in der Auswertung einen ShareLink hinzu, der alle Ausgaben des Zeitraums als CSV-Text teilt: eine Zeile pro Ausgabe mit Datum, Titel, Kategorie und Betrag, getrennt durch Semikolons. So lassen sich die Daten in einer Tabellenkalkulation öffnen.

Zusammenfassung

  • Ein Designsystem mit Asset-Farbe, Spacing, Radius und einer Card-Komponente vereinheitlicht die ganze App.
  • AmountText kapselt rollende Beträge mit .numericText und einer impliziten Animation.
  • Ein eigener Picker mit matchedGeometryEffect und withAnimation animiert gleich die ganze Auswertung mit.
  • .glassProminent und .buttonBorderShape(.circle) für einen schwebenden Hauptbutton.
  • .sensoryFeedback mit Bedingung gibt Rückmeldung nur, wenn sie passt.
  • Ein .shake(trigger:)-Modifier mit Keyframes zeigt, welche Felder fehlen.
  • @ScaledMetric und AnyLayout machen Zeilen fit für große Schrift.

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.