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
Aufgabe
- Leg im Asset-Katalog eine Farbe
BrandPrimarymit Hell- und Dunkelvariante an, etwa ein frisches Grün, und trag dieselben Werte inAccentColorein. - Leg einen Ordner
DesignSysteman mitSpacingundRadiusals Enums ohne Fälle (Lektion 3.29). - Baue eine Container-View
Card, die beliebigen Inhalt mit Innenabstand, HintergrundsecondarySystemGroupedBackgroundund 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.
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
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!
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.
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).
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!
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:
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).
Aufgabe
- Baue
PeriodPickermit einem@Binding var selection: Period. Jede Option ist ein Button, hinter der gewählten liegt eine Kapsel in der Markenfarbe, die mitmatchedGeometryEffectzur neuen Auswahl gleitet. - Der Zeitraumwechsel ist mit
withAnimationanimiert, sodass auch die Diagramme sanft in den neuen Zustand übergehen. - Die Buttons sollen für VoiceOver als ausgewählt erkennbar sein. Tipp:
.accessibilityAddTraits(.isSelected).
LösungErst selbst probieren!
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
withAnimationsteht, 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.
Aufgabe
- Ersetze den Plus-Button in der Toolbar von
ExpenseListViewdurch einen runden Button unten rechts über der Liste, im Stil.glassProminent(Lektion 3.30). - Wird eine neue Ausgabe gespeichert, soll das iPhone mit
.sensoryFeedbackkurz eine Erfolgs-Vibration geben. Tipp: Die Anzahl der Ausgaben steigt.
LösungErst selbst probieren!
In ExpenseListView am ExpenseList:
.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:
.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
.accessibilityLabeleinen Namen für VoiceOver. - Die Variante von
.sensoryFeedbackmit 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.
Aufgabe
- Baue einen Modifier
.shake(trigger:), der eine View mit einemkeyframeAnimator(Lektion 3.26) kurz nach links und rechts wackeln lässt, wann immer sich derInt-Trigger ändert. - In
ExpenseFormist „Sichern“ immer aktiv. Ist die Eingabe ungültig, wird ein Zählerattemptserhöht statt gespeichert. Titel- und Betragsfeld wackeln, und das iPhone gibt eine Fehler-Vibration. - Leere Pflichtfelder werden nach dem ersten Versuch rot beschriftet.
LösungErst selbst probieren!
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:
@State private var attempts = 0
private var titleMissing: Bool {
title.trimmingCharacters(in: .whitespaces).isEmpty
}Die Felder:
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:
Button("Sichern") {
if isValid {
save()
dismiss()
} else {
attempts += 1
}
}Und an der Form:
.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)?
Aufgabe
- Lass in
ExpenseRowden Kreis mit dem Symbol mit@ScaledMetricmitwachsen. - Ab den Bedienungshilfe-Größen stehen Titel und Betrag untereinander statt nebeneinander. Verwende
AnyLayout. - 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!
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.whiteals 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.
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
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.
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,Radiusund einerCard-Komponente vereinheitlicht die ganze App. AmountTextkapselt rollende Beträge mit.numericTextund einer impliziten Animation.- Ein eigener Picker mit
matchedGeometryEffectundwithAnimationanimiert gleich die ganze Auswertung mit. .glassProminentund.buttonBorderShape(.circle)für einen schwebenden Hauptbutton..sensoryFeedbackmit Bedingung gibt Rückmeldung nur, wenn sie passt.- Ein
.shake(trigger:)-Modifier mit Keyframes zeigt, welche Felder fehlen. @ScaledMetricundAnyLayoutmachen Zeilen fit für große Schrift.